# Unified.to – Complete Documentation Unified.to provides a single API to integrate multiple B2B SaaS platforms. This file contains the full documentation for the Unified.to API. Source: https://docs.unified.to OpenAPI spec: https://api.unified.to/swagger.json --- # Getting Started ## Unified.to Documentation URL: https://docs.unified.to/ # Unified.to Documentation The [Unified.to API](/concepts/what-is-a-unified-api) allows you to add multiple integrations to your application by using a single (i.e. unified) API. Focus on your core product instead of developing and maintaining different integrations. ## Access your customer's data however you want :::grid :::gridhightlight [Unified REST API](/reference/rest) ::: :::gridhightlight [Unified MCP Server](/mcp) ::: :::gridhightlight [Unified Webhooks](/reference/webhooks) ::: :::gridhightlight [Unified SDKs](/reference/sdks) ::: :::gridhightlight [Unified CLI](https://github.com/unified-to/unified-cli) ::: ::: ## How to use these docs Our documentation is organized as follows: - **How-to guides**: These guides provide the answers to “How do I...?” types of questions. Accomplish common task flows like getting OAuth 2 credentials or testing connections. - **API Basics**: Get to know the fundamentals for interacting with our API, such as pagination, webhooks, and rate limits. - **Concepts**: Gain a deeper understanding of topics that are core to the Unified.to platform, such as virtual webhooks, scopes, and unified APIs. - **Reference**: Look up API specs for each of our unified integration categories as well as examples of API requests. If you're new, we recommend starting with our tutorial: [Build a simple Javascript app that calls the Unified.to API](/tutorials/build-a-simple-javascript-app). ## Accessing documentation programmatically The developer documentation at this website is published for human developers. If you need to access the documentation programmatically, via HTTP clients, curl, LLM client, or any non-browser tooling during development, use the following options instead: - Markdown exports: All pages can be accessed with a .md suffix which would provide a markdown version. (for example, this Introduction page is at https://docs.unified.to/index.md). A GET request for these URLs returns Markdown suitable for version control, search, and offline use. - Documentation index: Use the https://docs.unified.to/llms.txt which lists curated links to Markdown versions of guides and reference pages, including webhooks and API definitions. Use it as a stable entry point when you need to discover or bulk-fetch machine-friendly documentation. - OpenAPI definitions: Endpoint and webhook reference specifications can be found at https://docs.unified.to/reference/sdks.md. ## Additional resources Curious about Unified.to's security posture? Learn more at [Security](https://unified.to/security){:target="\_blank"}. Have a question or just want to chat? Join our [Discord](https://discord.gg/85z7HF7JbD){:target="\_blank"} server. Watch guided video walkthroughs on our [YouTube](https://www.youtube.com/@unified_api){:target="\_blank"} channel, such as [How to get started with Unified.to](https://youtu.be/arTwN_OT8Do) or [How to use Webhooks in Unified.to](https://youtu.be/_zbUplmJd84). ## Get started with Unified.to URL: https://docs.unified.to/quick-start # Get started with Unified.to This quick start guides you through connecting your application to third-party platforms using Unified.to. You'll see how to: - Set up your Unified.to account and credentials - Activate integrations in the sandbox environment - Add the Authorization component to your app - Make your first API call ::callout Want to play around with the Unified API right away? Download [Postman](https://www.postman.com/downloads/) and import our [Postman collection](/reference/sdks) to start making API calls. You'll need your workspace ID and API key first. :: ## Set up your workspace ::callout Building with an AI coding agent? The agent can create a workspace and API key itself with [Agent Signup](/mcp/agent-signup), then hand it to you to claim. :: 1. Create your Unified.to account - Sign up at [app.unified.to/login](https://app.unified.to/login) - Log in to your dashboard 2. Get your API credentials - Navigate to **Settings > API Keys** - Copy your **Workspace ID** and **API Key** - Store these securely where you can reference them in your app, such as in an `.env` file - you'll need them for API calls 3. Activate an integration in the sandbox environment - Go to **Integrations** - Select **Sandbox** from the environment dropdown in the top-right corner of the screen - Search for your desired integration - Click the integration card and select **Activate** (don't worry about filling in credentials - they're all mocked in the sandbox environment) ![Search for integrations in the sandbox environment](/images/screenshots/quick-start-integrations.png) ## Add authorization to your app Your users need to authorize your application to access their third-party accounts (like Lever, HubSpot, etc.). Unified.to provides an embedded Authorization component that handles this process securely. When users click on an integration in this component, they'll be redirected to an authorization page, then returned to your app with a unique connection ID that you'll use for making API calls. ![Quick start auth component](/images/screenshots/quick-start-auth-component.png) Add this to your app: ```html
``` Replace `YOUR_WORKSPACE_ID` with the Workspace ID you copied earlier. (Want to use a Javascript framework or make the API call from your server? Discover more options at [app.unified.to/embed](https://app.unified.to/embed?tab=Authorization)) ## Make your first API call After successful authorization, you'll receive a connection ID in the callback URL e.g. `localhost:3000?id={connection_id}`. Save this in your database and use it to make API calls. Our Unified endpoints generally follow the pattern of `{API_CATEGORY}/{CONNECTION_ID}/{DATA_OBJECT}`. For example: ### CRM (HubSpot, Salesforce, etc.) ```javascript // Fetch contacts const response = await fetch(`https://api.unified.to/crm/${connectionId}/contact`, { headers: { Authorization: `Bearer ${API_KEY}`, }, }); ``` ### ATS (Lever, Greenhouse, etc.) ```javascript // Fetch candidates const response = await fetch(`https://api.unified.to/ats/${connectionId}/candidate`, { headers: { Authorization: `Bearer ${API_KEY}`, }, }); ``` ### HRIS (BambooHR, Workday, etc.) ```javascript // Fetch employees const response = await fetch(`https://api.unified.to/hris/${connectionId}/employee`, { headers: { Authorization: `Bearer ${API_KEY}`, }, }); ``` Refer to our **API Reference** for the category and data object you are interested in. ## Go to production When you're ready to launch: 1. **Register your app with each vendor.** Create a developer app with the vendor to get OAuth or API-key credentials. This is the only step that happens outside Unified.to. 2. **Activate the integration in your Production environment** and enter those credentials. 3. **Configure scopes.** Enable the scopes in your vendor developer account that match the Unified.to scopes you're requesting. See [Scopes](/concepts/scopes). 4. **Switch the Authorization component to Production** by changing `env=Sandbox` to `env=Production` in the embed snippet. Copying activated integrations from one environment to another isn't available yet. If you need it, [contact us](https://unified.to/contact). ## Add more integrations Your API code doesn't change when you add an integration: endpoints and SDKs are the same across vendors, so calls to HubSpot and Salesforce differ only by connection ID. To add an integration, repeat the vendor registration, activation and scope steps above for that vendor. The Authorization component shows newly activated integrations automatically. ## Next steps When you're ready to move on to production environments, review these guides to set yourself up for success: - [Learn how to work with webhooks](https://docs.unified.to/reference/webhooks) - [Configure scopes for your authorization flow](https://docs.unified.to/concepts/scopes) - [Associate your end-users with connections](https://docs.unified.to/guides/how_to_associate_a_connection_id_with_your_end_user) - [SDKs for popular languages](https://docs.unified.to/reference/sdks) Need help? - Join our [Discord](https://discord.gg/2nsAPmbx) - Email [hello@unified.to](mailto:hello@unified.to) --- # Concepts ## Unified.to Architecture URL: https://docs.unified.to/concepts/architecture # Unified.to Architecture [Unified.to](https://unified.to/) is a real-time pass-through unified API. Every request routes directly to the source API and returns current data. End-customer data isn't stored, cached, or logged at rest on our side. Put our architecture next to a sync-and-store unified API and the high-level boxes may look the same, but the difference is in the flows: their order, number, and direction. ## Unified.to architecture ![Unified.to real-time pass-through architecture diagram](/images/unified_realtime_architecture_diagram.png) The dashed line marks the boundary between our infrastructure and yours. Everything to the left, the source API and the Unified.to API, holds no data at rest. Everything stored, your database and any vector store feeding your AI app, sits on your side. We move the data; we don't keep it. The only things that persist are the connection ID and minimal operational metadata. ### API: direct passthrough An on-demand read or write. Following the numbered path in the diagram: 1. Your server calls the Unified.to API, using the connection ID to identify the account. 2. [Unified.to](https://unified.to/) transforms the call (its pagination, query parameters, and payload) into the source API's format and routes it to the source. 3. The source API responds. 4. Unified.to transforms the response in memory into a normalized object and returns it to your server. You paginate and filter using a single unified method (offset/limit/query/updated_gte), regardless of how the source API expects those parameters. Dates are ISO, values are validated, and raw source data is returned alongside the normalized object. ### Virtual webhooks: scheduled Most source APIs don't offer native webhooks. For those, Unified.to delivers events on a schedule you set: 1. On an interval you configure, as frequent as every minute, Unified.to checks the source API for changes. 2. Changed records return to Unified.to. 3. Unified.to delivers the event to your endpoint, with the changed records in the payload. There's usually no follow-up fetch, because the event carries the data. Checks that find no change are not billed. Rate limits, backoff, and retries are handled for you. If your endpoint is unavailable, we retry three times immediately from memory, then switch to a Fibonacci backoff that continues for up to two weeks. Those longer retries hold a cursor, not your records: each attempt re-reads the page from the source and dispatches it fresh. Nothing sits in a delivery queue, and no end-customer data is stored, even on failure. → [**Understanding virtual webhooks**](/concepts/virtual_webhooks#virtual-vs-native-webhooks-comparison) ### Native webhooks Where the source API supports native [webhooks](/reference/webhooks): 1. The source API pushes the event to Unified.to. 2. Unified.to delivers it to your endpoint on the same interface as virtual webhooks. Your code consumes one event model and never branches on which delivery path an integration uses. ### Where the data lands All three paths deliver to your server, not ours. From there the data flows into your own database and, for AI products, into your embedding model and vector store feeding your application. The storage happens in your infrastructure. Unified.to holds none of it. ## Sync-and-store: the difference is custody Same boxes, different flows. Sync-and-store unified APIs store a normalized copy of your customers' data. Call that platform **the vendor**: it sits between your server and the source API, the same position Unified.to occupies above. A database sits on the vendor's side of the boundary, and reads are served from that copy rather than the source. The record you read is a copy of what was true at the last sync. The record you write lands in their database first and reaches the source later. ### API path: 1. On a schedule the vendor sets, typically every 3 to 24 hours, it syncs data from the source API into its own database. 2. That stored copy is what serves your every read. 3. Your server requests data from the vendor API. 4. The vendor API reads from its database. 5. The database returns the cached copy. 6. The vendor API returns it to your server. The read never reaches the source. ### Native webhooks path: 1. The source pushes the event into the vendor's database. 2. The vendor reads the change from that store. 3. The vendor delivers it to your server. The store is in the path either way. ### Write path: 1. Your server sends the update to the vendor API. 2. The vendor writes it to its own database. 3. The update reaches the source on the vendor's next sync. Many sync-and-store vendors cache your update for up to 24 hours before it reaches the source. Some may push it through sooner. Both patterns push where the source pushes and check on a schedule where it doesn't. Polling isn't the difference. Custody is: who holds your customers' records, and where they rest. When a platform holds the copy, that copy is theirs to scope, reconcile, secure, and delete, and your customers' records sit in their infrastructure as a sub-processor. When it holds nothing, there is nothing to scope, nothing to reconcile, and nothing to delete. Two questions separate the approaches: **Does the event carry the records, or a pointer?** Poll-store-notify detects a change in the vendor's copy and tells you to call back for it. Poll-detect-deliver puts the changed records in the payload. Unified.to does the latter. **Where does the data rest between detection and delivery?** Anything that polls into a store holds a copy. Unified.to holds none. For the full comparison across all four generations of integration architecture, ETL, iPaaS, first-generation unified APIs, and real-time, see [Technology overview](https://unified.to/technology). ## Integration Authorization URL: https://docs.unified.to/concepts/embedded-components # Authorization Authorization is the process of asking customers to authorize access to their third-party accounts. Unified provides several options to accomplish this, from easy 1-line embedded components to a full-featured API, and even CNAMEd domains. ## Embedded Authorization Component Unified.to provides a suite of pre-built front-end authorization components that support React, Angular, Vue, and Svelte (and plain JavaScript) for you to use in your app. These authorization components display a list of your activated integrations, returned directly from our API. Once the end-user clicks on an integration, they will be redirected to the authorization page for that integration, regardless if it is OAuth2, API Token and any other type of authorization. in their File Storage connection. ### Instructions To use any one of these components in your app: - Make sure you've activated some integrations first. You can do so at [app.unified.to/integrations](https://app.unified.to/integrations). - On app.unified.to, click on Embedded components and select which of the widgets you want to use from the tab. - Configure how you want the component to behave under the Configuration tab. - View and copy the code to add to your app under the Embedded Code tab. - Important: Under the Options tab, be sure to select the correct permissions scopes that the integrations you activated are expecting to receive. By leaving these options blank, Unified.to will request access to all possible scopes. To learn more about scopes, see: [Understanding scopes](/concepts/scopes). ### Front-end Frameworks | | | | ----------- | ------------------------------------------------------------------------------------------------------------------------------- | | **React** | [npmjs.com/package/@unified-api/react-directory](https://www.npmjs.com/package/@unified-api/react-directory){:target="\_blank"} | | **Angular** | [npmjs.com/package/@unified-api/angular-directory](https://npmjs.com/package/@unified-api/angular-directory){:target="\_blank"} | | **VueJS** | [npmjs.com/package/@unified-api/vuejs-directory](https://npmjs.com/package/@unified-api/vuejs-directory){:target="\_blank"} | | **Svelte** | [npmjs.com/package/@unified-api/svelte-directory](https://www.npmjs.com/package/@unified-api/svelte-directory) | ## Manually Authorizing Connections If you do not want to use our pre-built authorization components, nor want to copy our component source code and make changes, then you can use our [API](https://docs.unified.to/unified/integration/Authorize_or_re-authorize_a_connection) This is the flow to follow in your application with your end-user: 1. You redirect your user to our authorization URL. Make sure to include a `success_redirect`, `failure_redirect`, and `permissions`. Also include either `state` or `external_xref`. 2. Your end-user will authorize the connection 3. If they cancel, then we return them to the `failure_redirect` 4. If they authorize the connection, we then test their API credentials to see if it has the valid permissions 5. If that fails, then we redirect them back to the `failure_redirect` and also include an `error` message with a string denoting the error 6. If it succeeds, then we redirect them to the `success_redirect` and add additional parameters to the URL ## Successful authorization If the authorization is successful (either via our authorization components or via the API), we redirect the user to the specified `success_redirect` URL and add the following URL parameters: - `id`: the connection ID (the newly created connection, or the same existing connection when [re-authorizing](#re-authorize-an-existing-connection)) - `state`: the state value you provided - `nonce`: a random nonce value - `type`: the integration type of connection - `sig`: a security signature to ensure that this information has not been tampered with; ### Signature calculation The signature is calculated as follows: ```javascript sig = HmacSha256(workspace.secret).update(connection.id).update(nonce).update(state).digest('hex'); ``` ## Re-authorize an existing connection When a connection becomes unhealthy because its credentials were revoked or expired (for example, a `CONNECTION_UNHEALTHY` event with an HTTP `401`), ask your end-user to re-authorize **the same connection** instead of creating a new one. The connection keeps its ID, so your stored connection IDs, webhooks, and `external_xref` keep working. ![Re-authorizing an existing connection](/images/reauthorize_connection_flow.png) 1. From your backend, create a re-authorization token with your API key using [Create a re-authorization token for a connection](https://docs.unified.to/unified/connection/Create_a_re-authorization_token_for_a_connection): ```bash curl -X POST https://api.unified.to/unified/connection/{connection_id}/reauth \ -H "Authorization: bearer {your_api_key}" ``` ```json { "reauth_token": "6650...e1.9f2c...", "expires_at": "2026-10-01T17:45:00.000Z" } ``` 2. Redirect your end-user to the usual [authorization URL](https://docs.unified.to/unified/integration/Authorize_or_re-authorize_a_connection), adding `connection_id` and `reauth_token`: ``` https://api.unified.to/unified/integration/auth/{workspace_id}/{integration_type} ?connection_id={connection_id} &reauth_token={reauth_token} &success_redirect=... &failure_redirect=... ``` 3. Your end-user signs in to the integration again. We test the new credentials the same way as for a new connection. 4. If it succeeds, we update the existing connection and redirect to `success_redirect` with the **same** `id`. You also receive a `CONNECTION_UPDATED` webhook (and `CONNECTION_HEALTHY` if the connection was unhealthy). No `CONNECTION_CREATED` event is sent. Things to know: - The token is single-use and expires after 30 minutes. Create it right before you send the end-user to the authorization URL. If you email the link, create a fresh token when the end-user clicks it rather than embedding one in the email. - `connection_id` and `reauth_token` must be sent together, for the same connection, integration, and environment (`env`) the token was created for. - If the token is missing, invalid, expired, or already used, we redirect to `failure_redirect` with `error=authorization error: invalid or expired re-authorization link` (or return a `400` when there is no `failure_redirect`). Create a new token and try again. - If the end-user cancels or the credential test fails, the token is not used up, so they can retry with the same link until it expires. - Without a `scopes` parameter, re-authorizing asks for the permissions the connection already has, and its permissions and categories stay unchanged. Pass `scopes` to change them; the categories then come from your activated integration, as they do for new connections. - A paused connection stays paused after re-authorizing. - If your workspace has `trusted_domains` set, `success_redirect` and `failure_redirect` must use one of those hosts, for new connections and re-authorization alike. See [Restrict redirect hosts](/tutorials/customize-auth-flow#restrict-redirect-hosts-with-trusted_domains). ## Other Embedded Components Unified also has non-authorization components that you can embed into your app. These components are: - **Sign-in**: Authenticate users so they can sign into your app; [info](https://app.unified.to/embed?tab=Sign-in){:target="\_blank"}. Restrict where the login JWT can be sent with workspace `trusted_domains` — see [Authentication](/auth/overview). - **Public Directory**: Display a list of your integration so your users can learn about your third-party API offerings; [info](https://app.unified.to/embed?tab=Directory){:target="\_blank"} - **FileManager**: Display a list of files in a directory so your users can pick a file; [info](https://app.unified.to/embed?tab=FileManager){:target="\_blank"} - **CalendarScheduler**: Display a calendar so your users can schedule events in their Calendar connection; [info](https://app.unified.to/embed?tab=CalendarScheduler){:target="\_blank"} - **MessagingManager**: Display a list of channels/folders and messages; [info](https://app.unified.to/embed?tab=Messaging){:target="\_blank"} ## Multi-region synchronization URL: https://docs.unified.to/concepts/multi_region_synchronization # Multi-Region Workspace Synchronization ## Overview Multi-region synchronization keeps selected workspace settings consistent across Unified data regions (US, EU, AU). You can manage it from: **Settings → Workspace → Multi-region synchronization** This feature is for workspace configuration. It does not copy your customers' integration data between regions. ## Availability - Multi-region synchronization is available only on the Scale plan. - Only workspace admins can update workspace settings. - Your workspace is billed once, in the region where synchronization was first turned on. API usage from all your synced regions is combined on that invoice. ## How to turn it on 1. Go to **Settings → Workspace → Multi-region synchronization**. 2. First select **Workspace**. After Workspace is enabled, the full list of sync options appears. 3. Select the options you want, then click **Update**. | UI option | What it syncs | | --- | --- | | Workspace | General workspace settings, such as the workspace name and IP restrictions. | | Workspace Secrets Manager | Workspace credentials, such as your Datadog API key, Secrets Manager configuration, and SAML configuration. Select this together with Workspace. | | Workspace Integrations | Integrations that are enabled in this workspace. | | Users | Workspace users. | | Keys | Workspace API keys. | | Notifications | Workspace notifications. | ## Workspace Secrets Manager By default, workspace credentials are not included when you enable Workspace sync. These include: - Log and monitoring credentials, such as your Datadog API key and your AWS, Azure, GCP, Grafana, and ClickHouse logging settings - Your Secrets Manager configuration (AWS, Azure, GCP, HashiCorp Vault, etc.) - Your SAML configuration To sync workspace credentials, select both: - **Workspace** - **Workspace Secrets Manager** Use this when you want the same credentials to be available across your enabled regions. ## What is synced Multi-region synchronization can sync: | Category | Synced data | | --- | --- | | Workspace | General workspace settings, such as IP restrictions, workspace name, etc. | | Workspace Secrets Manager | Workspace credentials, such as your Datadog API key, Secrets Manager configuration, and SAML configuration. Synced when enabled together with Workspace. | | Workspace Integrations | Integrations that are enabled in this workspace. | | Users | Workspace users. | | Keys | Workspace API keys. | | Notifications | Workspace notifications. | ## What is not synced Multi-region synchronization does not sync: | Not synced | Meaning | | --- | --- | | Connections | Individual customer connections are not copied between regions. | | Webhooks | Webhook subscriptions, webhook events, and webhook delivery history are not copied by this setting. | | API logs | API request logs are not copied by this setting. | | Customer integration data | Contacts, jobs, employees, files, messages, tickets, and other third-party object data are not copied between regions. | | Sandbox data | Sandbox records are not copied by this setting. | | Billing records | Billing records are not copied by this setting. | ## How updates sync When you create, update, or delete a synced item in one region, Unified syncs that change to all your other regions. There may be a brief delay before all regions reflect the update. If the same item is changed in two regions at about the same time, the most recent change wins. ## Initial sync When you enable a new sync option, Unified also syncs the existing records for that option. For example, if you enable **Users**, existing workspace users are synced to your other regions. If you enable **Workspace Secrets Manager**, make sure **Workspace** is also selected. ## How to turn it off 1. Go to **Settings → Workspace → Multi-region synchronization**. 2. Unselect the options you no longer want to sync, then click **Update**. To fully turn off multi-region synchronization, unselect all options and click **Update**. Turning sync off stops future changes from syncing. It does not automatically delete settings that were already synced to other regions. :::callout **Important notes** Multi-region synchronization is intended for workspace setup and access configuration. It should not be used to migrate customers' integration data, connections, webhook history, or logs. ::: ## Sandbox / Synthetic Data environment URL: https://docs.unified.to/concepts/sandbox # Sandbox / Synthetic Data environment The synthetic data (ie. sandbox) environment is a controlled, isolated testing space provided by Unified.to. It allows developers to activate integrations, create connections, and make API calls without interacting with real third-party services or affecting production data. Key points to understand about the synthetic data environment: - It is specific to Unified.to and separate from any sandbox accounts you might have with third-party service providers. - All API calls made in the sandbox environment return synthetic, mock data. - At no point do you communicate with actual third-party providers when using the synthetic data environment. ## Using the synthetic data environment There are two ways to use the synthetic data environment: 1. **From the web app:** - On [app.unified.to](https://app.unified.to/), in the top right corner of the page, click on the **Env** dropdown. - Select **Synthetic Data** from the options. 2. **When calling the Core API:** - When calling the Unified Core API e.g. to query integrations or generate an authorization URL, include `env=Sandbox` in the query parameters. For example: ``` https://api.unified.to/unified/integration/auth/{workspaceId}/{integration}?env=Sandbox ``` **API reference:** [Core API overview](https://docs.unified.to/unified/overview) **Note**: If you use the Core API and do not specify `env`, we will default to using the environment that is currently active on app.unified.to. As a best practice, be explicit about the environment you want to use when working with this API. ## Sandbox vs Production environments | | **Synthetic Data / Sandbox** | **Production** | | ----------------- | ------------------------------------------------ | --------------------------------------------------- | | **Purpose** | For testing and development | For live, real-world usage | | **Data** | Synthetic, mock data generated by Unified.to | Real data from integrated services | | **Integrations** | Can be activated with mock credentials | Requires real credentials from third-party services | | **API Calls** | Return synthetic data | Interact with actual third-party services | | **Risk** | No risk of affecting real data or services | Can affect real data and services | | **Authorization** | Can use any mock credentials and bypasses scopes | Requires valid credentials and correct scopes | **Note**: Production environment refers to any non-sandbox environment. ## Key considerations about the sandbox environment ### Synthetic data generation - Mock data is generated when you activate an integration in the Sandbox environment. - If you deactivate and reactivate an integration, it will be populated with new synthetic data. ### Isolated testing environment - Each activated integration and connection in the Sandbox has its own isolated set of synthetic data. - This isolation allows you to test various scenarios without affecting real data or services. ### Mock credentials - In the sandbox environment, you can use any mock values for OAuth client IDs, secrets, API tokens, and other credentials. - When creating a connection for an integration in the sandbox environment, you can also use any mock values when asked for them. ### Integration-specific environments vs. [Unified.to](https://unified.to/) Synthetic Data / Sandbox An important distinction to be aware of: - Some integrations allow you to choose an "API region / environment" when activating them. This refers to that specific API provider's environment, not Unified.to's environment. - Choosing "Sandbox" when activating an integration this way is **not** the same as using the Unified.to sandbox environment. ### Environment-specific integrations and connections - Activated integrations are tied to the active environment at the time of their activation. - If you activate an integration (e.g., HubSpot) in the sandbox environment, it won't be available in the production environment, and vice versa. - Similarly, a connection created for an integration in the sandbox environment will only return synthetic mock data and won't be accessible in the production environment. ## When to use the sandbox environment The Synthetic Data / Sandbox environment is ideal for: 1. Initial integration testing and exploration 2. Developing and debugging your application without affecting real data 3. Running automated tests that don't require real-world data 4. Demonstrating functionality without needing access to actual third-party services ## Best practices 1. Clearly distinguish between your sandbox and production environments in your code and workflows. 2. Use the sandbox environment for initial testing before moving to production. 3. When seeking support or reporting issues, specify which environment you're working in. ## Additional information - [Tutorial: Build a simple Javascript app that calls the Unified API](https://docs.unified.to/tutorials/build-a-simple-javascript-app) - See how to build an app entirely in the synthetic data / sandbox environment ## sandboxes URL: https://docs.unified.to/concepts/sandboxes # Sandbox Accounts This directory provides guidance for obtaining sandbox accounts for integrations supported by Unified.to. These accounts can be used for testing your app with live data. :::callout Note: These sandbox accounts are different from Unified.to's [synthetic data sandbox environment](/concepts/sandbox). While our sandbox environment generates synthetic data for trying out he Unified API, these sandbox accounts are for testing with real integration endpoints in production. ::: :::sandboxes ::: For integrations requiring partner program enrollment or special access, we recommend: 1. Starting the application process early in your development cycle 2. Having a clear description of your integration use case ready ## Understanding scopes URL: https://docs.unified.to/concepts/scopes # Understanding scopes In the world of API integrations and OAuth, scopes play a crucial role in managing access to resources. This guide explains the concept of scopes, their importance in OAuth, and how they are handled specifically at Unified.to. ## What are scopes? Scopes are a way to limit an application's access to a user's account. Instead of granting complete access to an account, scopes allow for fine-grained permission control. Think of scopes as permission slips. When you use an app that integrates with another service (like signing in with Google), scopes define exactly what that app is allowed to do with your account. It's like telling the bouncer at a club, "This person can enter the main area, but not the VIP lounge." ## Why are scopes important? 1. **Security**: Scopes ensure that applications only have access to the specific data and actions they need, reducing the risk of unauthorized access. 2. **User Control**: Users can make informed decisions about what access they're granting to applications. 3. **Compliance**: Scopes help applications adhere to data protection regulations by implementing the principle of least privilege. ## How scopes work in OAuth In the OAuth flow: 1. An application requests one or more scopes. 2. The user is presented with these scope requests during the authorization process. 3. If the user approves, an access token is issued that's limited to the approved scopes. For example, an application might request the scope `https://www.googleapis.com/auth/drive.readonly` to read files from Google Drive, but not edit or delete them. ## Scopes at Unified.to Unified.to acts as a conduit between your application and various API providers. We use a unified set of scopes that map to provider-specific scopes. This abstraction simplifies the integration process across multiple providers. ### Unified scopes Unified.to uses its own set of scopes, which are then mapped to provider-specific scopes. For example: - `storage_file_read` maps to `https://www.googleapis.com/auth/drive.readonly` for Google Drive - `crm_company_read` maps to `oauth, crm.objects.owners.read, crm.objects.companies.read` for HubSpot You may notice that some Unified scopes map to multiple provider-specific scopes. This is done to ensure that all the necessary permissions are requested for the integration to function correctly. The mappings for all scopes are found under their respective Integration page at app.unified.to under OAuth 2 Credentials e.g. [here](https://app.unified.to/integrations/googledrive?tab=oauth2) are the mappings for Google Drive. ### Platform scopes When setting up your developer account and/or developer app, you will need to define your scopes with the API providers themselves - Unified.to is unable to do this for you. How this is done varies on a case-by-case basis, but it is usually configured while generating your OAuth credentials. We have several how-to guides for generating credentials and setting scopes for our most popular integrations. If you think any are missing, please let us know. ## Requesting scopes When using Unified.to, you can request scopes in one of two ways, depending on how you intend to authorize your users: 1. **(Recommended) Using the Authorization component**: On app.unified.to, you can select the scopes you need under Embedded components > Permission scopes. Screenshot

Example of the scope interface on app.unified.to

2. **Using the Authorization URL**: When generating an auth URL, you can include the scopes as a query parameter. For example: ``` https://api.unified.to/unified/integration/auth/{WORKSPACE_ID}/{INTEGRATION}?scopes=webhook,crm_deal_read,crm_event_read ``` ### Requesting custom or platform-specific scopes In addition to passing Unified scopes in the query parameter, you can also specify scopes that are specific to the integration only. Behind the scenes, Unified scopes are "unfurled" to map to the integration's scopes, and anything else that is passed in will be used as-is. For example: ``` https://api.unified.to/unified/integration/auth/{WORKSPACE_ID}/{INTEGRATION}?scopes=webhook,crm_deal_read,crm_event_read,hubspot_scope_1,hubspot_scope2s ``` :::callout **Note**: If you do not specify any scopes at all, then Unified.to will attempt to request _all_ the scopes available for the integration. We strongly advise you to consider which scopes you actually need and then intentionally specify them using one of the above two methods. The next section will go over best practices when requesting scopes. ::: ### Overriding scopes per integration Each Unified permission (like `ats_job_read`) maps to a default set of the integration's raw scopes. You can replace that default on a per-integration basis using the integration's scope override setting. If you want to use our API to change these scopes, update the WorkspaceIntegration object by adding the value in a map keyed by the Unified permission, where each value is a comma-separated list of the integration's raw scopes: ```json { "overriden_scopes": { "ats_job": "jobs_r" } } ``` In this example, Unified requests the provider's `jobs_r` scope for the `ats_job` permission instead of its default. At authorization time, the override replaces the default scopes for that permission in the OAuth authorize URL. To revert, change the value back to the default scope or remove the override entirely. You can also change the default scopes for the activation workspace integration in app.unified.to inside the integration's page. You can also change the default scopes for the activation workspace integration in app.unified.to inside the integration's page. :::callout **Note**: Scope overrides apply to the OAuth authorization flow. For API-key based integrations, scopes are determined by the key itself, so the override will not change runtime behavior. ::: ### Best practices for scope usage 1. **Request Only What You Need**: Always request the minimum set of scopes required for your application to function. 2. **Check Provider Requirements**: Some providers may require certain scopes to always be enabled. Check the Unified.to documentation for provider-specific requirements. 3. **Include the Webhook Scope**: If you're setting up webhooks, always include the `webhook` scope in your requests. 4. **Verify Scope Mappings**: You can view the mapping of Unified.to scopes to provider-specific scopes in the Unified.to dashboard for each integration. ## Conclusion Understanding and correctly implementing scopes is crucial for secure and efficient API integrations. By leveraging Unified.to's scope system, you can simplify the process of working with multiple providers while maintaining granular control over access permissions. ## Virtual Webhooks URL: https://docs.unified.to/concepts/virtual_webhooks # Understanding virtual webhooks This guide explains what virtual webhooks are, how they differ from native webhooks, and when you would want to use a virtual webhook. ## What are virtual webhooks? Virtual webhooks are [Unified.to](https://unified.to/)'s solution for providing webhook functionality even when an integration doesn't natively support it. They detect changes in your customers' connected applications on a schedule you set, for new or updated records, and for deleted records where the integration supports it, and deliver those changes as events to your webhook URL. While native webhooks are great when they're available, many software vendors either don't support webhooks or have limitations with their webhook implementations. Virtual webhooks bridge this gap by providing a consistent webhook experience across the integrations that support them. ## How virtual webhooks work Virtual webhooks work by: 1. Monitoring your connections for updates to specific objects (e.g., CRM deals, ATS jobs, etc) at regular intervals (polling) 2. Detecting when data has changed or new data has come in since the last check 3. Sending updates to your webhook URL only when changes are found 4. Managing rate limits automatically Each check reads what has changed since the last successful delivery. Nothing is stored between checks; if a delivery fails, the next attempt re-reads the same page from the integration. The key difference from native webhooks is that virtual webhooks use polling behind the scenes, but this complexity is managed by us so that you, the developer, don’t have to worry about it. You interact with virtual webhooks as you would with native webhooks. ## Virtual vs native webhooks comparison | | **Virtual Webhooks** | **Native Webhooks** | | --------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | **Implementation** | Detects changes on your interval and delivers events | Forwards events as the integration sends them | | **Setup** | Configured entirely through Unified.to | May require configuration in the integration's own settings | | **Cost Structure** | No charge for checks that find nothing. One API call per page of changes successfully delivered to your webhook URL. You set the interval. | One API call per successful delivery to your webhook URL | | **Event Types** | Created and updated; deleted where the integration supports it (see Feature Support) | Supports created, updated, and deleted events if the integration supports them | | **Retry Logic** | 3 immediate delivery retries, then progressive backoff for up to about two weeks; reads from the integration are retried with the same backoff | 3 immediate delivery retries; no extended backoff. Further delivery depends on the integration sending the event again | | **Health Monitoring** | Built-in is_healthy tracking | Built-in is_healthy tracking | ## When to use virtual webhooks Virtual webhooks are ideal for: - Tasks where 1+ minute delays are acceptable and you don’t need the data immediately - When you want greater control over costs by setting the frequency the integration is polled - Integrations that don't offer native webhook support When an integration offers both virtual and native webhooks, consider native webhooks when: - You need immediate updates as soon as they arrive ## How to identify webhook support You can check what type of webhook support an integration offers: 1. Navigate to the integration's page in the [Unified.to](https://unified.to/) dashboard 2. Click on **Feature Support** 3. Look under the **Webhooks** section: - Each event shows which types support it, for example "native & virtual created event" or "native deleted event" ![How to identify virtual webhooks](/images/screenshots/virtual-webhooks.png) _In this example, the integration supports virtual updated and virtual created events._ ## Creating and configuring virtual webhooks You create and configure virtual webhooks the same as you would with any other webhook, with the exception of also being able to set an interval, and filters where the integration supports them. The minimum interval is 1 minute. To see how to do that, refer to: [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks) ## Additional resources - [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks) - [Introduction to webhooks](https://docs.unified.to/reference/webhooks) ## What is a Unified API? URL: https://docs.unified.to/concepts/what-is-a-unified-api # What is a Unified API? A unified API is a single API interface that aggregates multiple third-party APIs in the same software category — CRM, accounting, HRIS, ATS, payroll, and others — behind one standardized endpoint. With a unified API, applications authenticate once, integrate once, and receive normalized data from any supported integration in that category. Unified APIs vary in their underlying architecture. Real-time pass-through unified APIs route every request live to the source system without storing customer data. Sync-based unified APIs cache data from source systems on a schedule and serve reads from that cache. The architectural choice has implications for data freshness, compliance scope, latency, and AI agent compatibility — covered in detail below. ## Key takeaways - A unified API gives applications one endpoint, one auth flow, and one data model across many vendors in the same category — instead of building per-vendor integrations. - Real-time pass-through unified APIs route requests live to source systems with no customer data stored at rest. Sync-based unified APIs cache data on a schedule and serve reads from that cache. - The architectural choice affects data freshness, compliance scope, latency profile, and AI agent compatibility. These trade-offs are concrete, not stylistic. - Most unified APIs handle the lowest-common-denominator critique through a "raw" passthrough mechanism that preserves vendor-specific custom fields and objects outside the normalized schema. - Pricing models vary substantially: per linked account, per active consumer, per API call, and consumption-based. Each scales differently with customer count and integration breadth. ## What problem does a unified API solve? Different APIs represent the same concepts in different ways, even within a single category. One CRM API refers to an end-user as a "User"; another calls them a "Contact"; a third uses "Lead." Each representation has different properties, field names, authentication schemes, pagination patterns, rate limits, and error formats. For an application that needs to integrate with several vendors in the same category, this fragmentation translates directly into engineering cost. Every integration requires reading new documentation, mapping a new data schema, handling new authentication flows, and building error-handling for new edge cases. Maintenance compounds: each vendor's API evolves independently, and breaking changes must be tracked and adapted to per integration. A unified API addresses this by providing an abstraction layer between an application and the underlying APIs in a category. The application interacts with a single, consistent interface; the unified API translates each request into the appropriate vendor-specific call and normalizes the response into a common schema. ## How does a unified API speed up development? A unified API reduces the number of integrations and the work associated with them. Instead of writing integration code per vendor, applications write integration code once against the unified data model. New vendors in the same category become available without additional integration work — the unified API vendor handles the mapping. For an application supporting a handful of integrations, the time savings are modest. For an application supporting dozens, savings are substantial — typically reducing months of engineering work per integration to days or hours. ## What complexity does a unified API actually remove? A unified API removes per-vendor variance in four specific areas: - **Authentication and authorization** — different OAuth2 flows, API key schemes, scope models, and token refresh behaviours collapse into a single authentication interface. - **Data representation** — different schemas, field names, and data types collapse into a normalized data model per category. - **Endpoint structure** — different URL patterns, HTTP methods, and request formats collapse into consistent endpoints across the category. - **Error handling** — different error codes, response formats, and rate-limit behaviours collapse into consistent error semantics. What a unified API does not remove: the underlying differences in vendor capability. If one vendor in a category supports a feature that others do not, the unified API still has to make a choice about how to surface that feature — typically through a "raw" passthrough mechanism (covered below). ## How does a unified API enhance functionality beyond raw aggregation? Some vendor APIs lack features that simplify integration — webhooks for change notifications, consistent pagination, predictable rate limits, or filtering. A well-designed unified API can add these capabilities as a layer above the underlying APIs: - Polling-based event delivery for vendors without native webhooks ("virtual webhooks"). - Consistent pagination patterns across all integrations in a category. - Centralized rate-limit management that handles per-vendor quotas transparently. - Standardized filtering and field selection. This is the difference between a unified API and a request proxy: a unified API can provide features the underlying APIs do not natively support. ## Real-time pass-through vs. sync-based architectures Unified APIs fall into two architectural categories. The choice has concrete implications for application design. | Property | Real-time pass-through | Sync-and-cache | |---|---|---| | Data freshness | Live; reflects source-system state at request time | Bounded by sync interval; can be hours stale | | Customer data storage | None; only operational metadata stored | Customer records cached for serving reads | | Compliance scope | Not a sub-processor for payload data | Full sub-processor for cached records | | Latency profile | Full source-system round-trip per request | Local cache reads (fast); source delays on writes | | Write-back behaviour | Synchronous; success/failure known immediately | Instant write, but reads may lag sync interval | | AI agent compatibility | Agents reason on current source-system state | Risk of agent acting on stale cached data | | Typical pricing model | Per API call or per request | Per linked account or per consumer | Note on operational metadata: real-time pass-through unified APIs typically store connection identifiers, encrypted OAuth credentials, and API call logs — but not customer payload data (records like contacts, employees, or invoices). This distinction is what determines compliance scope. ### When real-time pass-through is the right choice Real-time pass-through is appropriate when: - The application requires current data on every read (financial reconciliation, AI agent integrations, live dashboards). - The application's compliance posture benefits from minimizing stored customer data. - Write-back operations need immediate confirmation of success or failure at the source. - The application's latency budget can accommodate a full source-API round-trip per request. ### When sync-and-cache is the right choice Sync-and-cache is appropriate when: - The application's read patterns tolerate data being minutes or hours stale. - The application requires sub-100ms read latency that local cache reads provide. - The application performs high-volume historical analytics that would be expensive to execute against live source APIs. - The application's compliance framework already accommodates a unified API vendor as a data sub-processor. ## What should you look for in a unified API? ### How are unified API endpoints structured? A unified API needs unified endpoints for each integration category. An API call to retrieve CRM contacts should be identical regardless of which underlying CRM is providing the data. The endpoint structure should be predictable, RESTful, and consistent across all integrations in the category. ### How does data normalization work across vendors? Data models must be unified, not just endpoints. A CRM contact returned from one vendor should have the same field structure as a CRM contact returned from another. The normalized schema needs sufficient depth to support real use cases — names and emails are not enough for most applications. Two depth dimensions to evaluate: - **Field coverage**: how many fields per object are normalized into the unified schema? - **Object coverage**: how many distinct object types per category are supported (e.g., for CRM: contacts, companies, deals, pipelines, activities, notes)? ### How is authentication handled? The unified API should provide a single authentication flow across all integrations in a category. Common authentication metadata (vendor name, account name, scopes granted) should be available programmatically so the application can display connection state natively. A pre-built authorization UI component is often provided to handle the OAuth2 handshake with end users, reducing the application's auth implementation burden to a single integration. ### How are permission scopes managed? Along with unified authentication, a unified API should provide abstracted permission scopes so the application does not need to research per-vendor OAuth2 scopes for each use case. Scope abstraction maps the application's intent (e.g., "read contacts") to the appropriate per-vendor scope automatically. ### How do unified APIs handle vendors that don't support webhooks? Most SaaS APIs do not support native webhooks. A unified API should provide a consistent event-delivery model regardless of whether the underlying vendor supports webhooks natively. The common implementation pattern is "virtual webhooks": the unified API polls source APIs at configurable intervals, detects changes via timestamps or diffs, and delivers events to the application's endpoint only when changes are found. From the application's perspective, native and virtual webhooks use the same subscription model, the same event format, and the same retry semantics. This differs from sync-based notification systems, where data is first cached on a schedule and a notification fires after sync completion — requiring the application to fetch the changed data in a separate request. With virtual webhooks, change detection and event delivery are unified: detect changes at the source, deliver events directly. Polling intervals should be configurable. Default intervals vary substantially across unified API vendors (some default to 24 hours; others support per-minute polling). ### How are custom fields and custom objects handled? The "lowest common denominator" critique of unified APIs is that normalization across many vendors loses vendor-specific features, custom fields, and custom objects. Most modern unified APIs address this through three mechanisms: - **Custom field support** in the normalized schema, allowing per-customer field definitions. - **Metadata APIs** that surface vendor-specific schema information programmatically. - **Raw passthrough** that allows direct calls to vendor-specific endpoints while still using the unified API's authentication and connection management. Evaluating these mechanisms requires testing against specific use cases. The ability to handle Salesforce custom objects, HubSpot custom properties, or industry-specific HRIS fields varies meaningfully across unified API vendors. ### How does pricing scale with usage? Unified API pricing models vary substantially. The four common patterns: - **Per linked account**: priced by the number of customer-vendor connections (one customer using three integrations = three linked accounts). - **Per active consumer**: priced by the number of active end-customers using any integration. - **Per API call**: priced by request volume, with unlimited connections. - **Consumption-based**: priced by data volume transferred (typically GB). The right pricing model depends on the application's own pricing structure and customer-integration ratio. Per-linked-account pricing scales with customer × integration count, which can be predictable for low-integration applications and prohibitive for multi-integration ones. Per-API-call pricing scales with actual data activity, which favours applications with many connections but low per-connection volume. ## When is a unified API the wrong choice? A unified API is not the right architectural choice in every case. Three common scenarios where alternatives are better: ### Single-vendor or two-vendor integrations If an application only integrates with one or two vendors and has no near-term plans to add more, building per-vendor integrations directly against the source API is often faster and cheaper than adopting a unified API. The amortized engineering savings of a unified API only materialize at three or more integrations in a category. ### Workflow automation across internal applications If the integration use case is internal workflow automation (e.g., "when a Salesforce deal closes, create a Jira ticket and update an internal database"), an iPaaS platform like Workato, Zapier, or MuleSoft is typically a better fit than a unified API. iPaaS platforms are built for workflow orchestration; unified APIs are built for customer-facing data access. ### Deep per-customer customization If each customer requires unique integration logic — different field mappings, different sync schedules, different transformation rules — an embedded iPaaS platform (Paragon, Workato Embedded, Tray Embedded) is often a better fit. Embedded iPaaS gives end-users a configuration interface for their own integrations; unified APIs give developers a programmatic interface for standardized integrations. ### Use cases requiring full vendor-specific feature depth If the application needs deep access to vendor-specific features that are not represented in the unified schema (e.g., Salesforce Apex triggers, HubSpot workflow automation, Workday's full reporting framework), the unified API's normalized layer may not provide sufficient depth. The raw passthrough mechanism partially addresses this, but at the cost of losing the unified abstraction for those operations. ## Security considerations A unified API sits in the data path between an application and its customers' third-party data. Security evaluation should cover: - **Customer data storage**: does the unified API store customer payload data at rest, or pass requests through statelessly? This determines compliance scope and audit surface. - **Credential storage**: where are OAuth2 client credentials and customer access tokens stored? External secret management (e.g., AWS Secrets Manager) is preferable to in-platform storage for high-security applications. - **Encryption**: at-rest encryption for stored credentials and operational metadata; in-transit encryption (TLS 1.2+) for all API traffic. - **Compliance certifications**: SOC 2 Type II, GDPR, HIPAA, PIPEDA, ISO 27001 as applicable to the application's regulated data categories. - **Data residency**: regional infrastructure for applications subject to data residency requirements (US, EU, AU, etc.). ## Reliability considerations The unified API becomes a critical dependency for the application. Evaluation should cover: - **SLA and uptime history**: published uptime targets and historical performance. - **Connection health monitoring**: does the unified API detect broken third-party vendor connections proactively, before the application's customers do? - **Error reporting and observability**: API call logs, error categorization, retry visibility. - **Hosting and scaling**: where the unified API is hosted and how it scales under load. ## Use case compatibility: breadth, depth, and clustering Three dimensions to evaluate against the application's use case: - **Breadth**: number of supported vendors in any one category. Higher breadth supports more customer-vendor combinations. - **Depth**: number of fields and object types in the normalized data model. Higher depth supports more sophisticated use cases (e.g., full HRIS payroll vs. just employee directory). - **Clustering**: support for multiple categories with linked data models. Applications that span CRM + Support + Marketing benefit from clustering; single-category applications do not. ## Unified.to Unified.to provides a real-time, stateless pass-through unified API across 856 integrations and 34 categories — including CRM, accounting, HRIS, ATS, payroll, ticketing, e-commerce, messaging, and others. Every request is routed live to the source system. No customer payload data is stored at rest. Unified.to additionally provides a hosted MCP (Model Context Protocol) server with 43,456 callable MCP tools, allowing AI agents to read and write across customer SaaS integrations through a single connection-scoped interface. ### How Unified.to handles your customers' data Unified.to doesn't store your customers' payload data. When you make an API request, Unified.to fetches only the data you asked for from the vendor, processes it in memory, and discards it when the request completes. **What Unified.to stores:** - Connection records - Connection credentials - API call logs, which never include payloads. See [How long are logs retained](https://docs.unified.to/guides/how_long_are_logs_retained). **Shared responsibilities** You're responsible for: - Registering OAuth applications with each vendor and providing the credentials when you activate integrations - Integrating your application with the Unified API, through an SDK or REST - How you store and handle customer data, including personal information, in your systems Unified.to is responsible for: - Maintaining integrations as vendor APIs change - OAuth flows and token management - The stability, scalability, and uptime of the Unified API ### SLAs Unified.to doesn't currently offer a formal SLA on the API or webhooks. Custom agreements can include uptime or error-rate guarantees on a case-by-case basis. For pricing details, see [unified.to/pricing](https://unified.to/pricing). For full API documentation, see [docs.unified.to](https://docs.unified.to). --- # Guides ## Advertising Report Metrics by Integration URL: https://docs.unified.to/guides/advertising_report_metrics_by_integration # Advertising Report Metrics by Integration ------ _June 12, 2026_ **Endpoint:** `GET /ads/{connection_id}/report` — [List all reports](https://docs.unified.to/ads/report/List_all_reports) This is a reference for the metrics returned by the Advertising **Report** endpoint: which metrics each supported integration populates, and how each platform's native field maps onto Unified.to's single normalized reporting model. ## How reports return metrics A call to `GET /ads/{connection_id}/report` returns an array of [Report objects](https://docs.unified.to/ads/report/model). Each report describes the performance of an advertising entity over a window, and carries its metrics in a single normalized array: The value of the `metrics` array is that it is **the same shape regardless of which platform** **`connection_id`** **points to**. Whether the connection is Meta Ads or LinkedIn, a `CLICKS` metric means the same thing and is read the same way. Behind the scenes, Unified.to handles the platform-specific renaming, the nested-field extraction, and the unit conversions — and where a platform doesn't return a value directly, it computes it. If you ever need the original platform field, it remains available under `raw`. ## The unified metric model Each ad platform reports the same fundamental ideas — a click, an impression, a dollar spent — but names and shapes them differently. A click is `clicks` in Meta, `METRIC_CLICKS` in Google DV360, and `Clicks` in Microsoft Ads. Cost arrives as raw currency in some platforms and as micro-units (millionths of a currency unit) in Google Ads, which must be divided by 1,000,000 before it means anything. [Unified.to](https://unified.to/) collapses all of that into one vocabulary of **92 normalized metrics** (written in `UPPER_SNAKE_CASE`). The tables below show, for each normalized metric, the native field it is sourced from on each of the five integrations mapped in this reference: **Meta Ads, Google Ads, Google DV360, LinkedIn, and Microsoft Ads**. A `—` means that integration does not expose the metric, so it will not appear in the `metrics` array for that connection. ## Coverage at a glance The breadth of the `metrics` array depends entirely on which integration the connection is for. Not all platforms expose data at the same depth: | Integration | Metrics returned | Character of the data | | ----------------- | ---------------- | -------------------------------------------------------------------------------------------------------- | | **LinkedIn** | 57 / 92 | Deepest coverage, driven by a large set of social, lead-gen, and "viral" (organic amplification) metrics | | **Google DV360** | 45 / 92 | Strong on viewability, video engagement, and cost/financial breakdowns | | **Google Ads** | 30 / 92 | Broad core coverage plus search-specific and viewability metrics | | **Meta Ads** | 19 / 92 | Focused on core performance and detailed video-watch metrics | | **Microsoft Ads** | 14 / 92 | Core performance metrics only | Coverage is highly uneven across the metric set. Of the 92 normalized metrics, **60 are returned by only a single integration**, while just **8 are returned by all five**. The long tail is dominated by LinkedIn's social and viral metrics and DV360's financial and viewability metrics, which have no equivalent elsewhere. Write your code defensively: check for a metric's presence rather than assuming every report contains it. ## The universal core These eight metrics are the common denominator: every integration mapped here returns them, making them the safe foundation for any cross-connection report or comparison: `CLICKS` · `IMPRESSIONS` · `CONVERSIONS` · `COST` · `CTR` · `CPC` · `CONVERSION_VALUE` · `CPA` Even within this universal set, the source data differs. `COST` is `spend` in Meta but arrives in micro-units in Google Ads and as `costInLocalCurrency` in LinkedIn (all normalized to the report's `currency`). `CONVERSIONS` is a clean field in most platforms but is pulled from Meta's nested `actions[purchase]`. And `CTR`, `CPC`, and `CPA` are all **derived** for LinkedIn — a reminder that "returned" sometimes means "computed for you," not "delivered by the platform." Two near-universal ratios fall just short of full coverage: `ROAS` is derived everywhere it exists but is absent from DV360, and `CPM` is missing only from Google Ads. ## Core performance metrics The headline numbers most reports are built on — volume, cost, and the efficiency ratios derived from them. | Unified Metric | Meta Ads | Google Ads | Google DV360 | LinkedIn | Microsoft Ads | | ------------------ | ------------------------------ | -------------------------- | -------------------------------------------------- | ------------------------------ | ----------------- | | `CLICKS` | clicks | clicks | METRIC_CLICKS | clicks | Clicks | | `IMPRESSIONS` | impressions | impressions | METRIC_IMPRESSIONS | impressions | Impressions | | `CONVERSIONS` | actions[purchase] | conversions | METRIC_TOTAL_CONVERSIONS | externalWebsiteConversions | Conversions | | `COST` | spend | cost_micros (/1e6) | METRIC_REVENUE_ADVERTISER | costInLocalCurrency | Spend | | `CTR` | ctr | ctr | METRIC_CTR | derived | Ctr | | `CPC` | cpc | average_cpc (/1e6) | METRIC_REVENUE_ECPC_ADVERTISER | derived | AverageCpc | | `CONVERSION_VALUE` | action_values[purchase] | conversions_value | METRIC_TRUEVIEW_TOTAL_CONVERSION_VALUES_ADVERTISER | conversionValueInLocalCurrency | Revenue | | `CPA` | cost_per_action_type[purchase] | cost_per_conversion (/1e6) | METRIC_REVENUE_ECPA_ADVERTISER | derived | CostPerConversion | | `ROAS` | derived | derived | — | derived | derived | | `CPM` | cpm | — | METRIC_REVENUE_ECPM_ADVERTISER | derived | AverageCpm | | `ECPM` | — | — | METRIC_REVENUE_ECPM_ADVERTISER | — | — | ## Conversion detail Beyond the headline conversion count, platforms differ on how they attribute and segment conversions. Google Ads and Microsoft Ads add "all conversions" totals and their revenue counterparts; DV360 and LinkedIn distinguish post-click from view-through (post-view) conversions; and Google Ads alone exposes cross-device conversions and raw `interactions`. | Unified Metric | Meta Ads | Google Ads | Google DV360 | LinkedIn | Microsoft Ads | | -------------------------- | -------- | ------------------------ | ----------------------------- | ----------------------------------- | ---------------------- | | `POST_CLICK_CONVERSIONS` | — | — | METRIC_POST_CLICK_CONVERSIONS | externalWebsitePostClickConversions | — | | `VIEW_THROUGH_CONVERSIONS` | — | view_through_conversions | METRIC_POST_VIEW_CONVERSIONS | externalWebsitePostViewConversions | ViewThroughConversions | | `ALL_CONVERSIONS` | — | all_conversions | — | — | AllConversions | | `ALL_CONVERSION_VALUE` | — | all_conversions_value | — | — | AllRevenue | | `CROSS_DEVICE_CONVERSIONS` | — | cross_device_conversions | — | — | — | | `INTERACTIONS` | — | interactions | — | — | — | ## Video engagement Video is the most fragmented category, because each platform instruments playback in its own way. Quartile completion (25/50/75/100%) is the one widely shared concept, supported across Meta, Google Ads, DV360, and LinkedIn — though Google Ads reports quartiles as _rates_ while the others report counts. From there the platforms diverge: Meta uniquely tracks `VIDEO_AVG_TIME_WATCHED` and `VIDEO_THRUPLAY`; DV360 captures rich-media interaction signals like pauses, mutes, skips, and companion-banner activity; and Google Ads surfaces search-driven views. | Unified Metric | Meta Ads | Google Ads | Google DV360 | LinkedIn | Microsoft Ads | | ------------------------- | ------------------------------ | -------------------------------------- | ------------------------------------------------ | ----------------------------- | ------------- | | `VIDEO_VIEWS` | — | video_views | METRIC_TRUEVIEW_VIEWS | videoViews | VideoViews | | `VIDEO_PLAYS` | video_play_actions | — | METRIC_RICH_MEDIA_VIDEO_PLAYS | videoStarts | — | | `VIDEO_COMPLETIONS` | — | — | METRIC_RICH_MEDIA_VIDEO_COMPLETIONS | videoCompletions | — | | `VIDEO_QUARTILE_25` | video_p25_watched_actions | video_quartile_p25_rate | METRIC_RICH_MEDIA_VIDEO_FIRST_QUARTILE_COMPLETES | videoFirstQuartileCompletions | — | | `VIDEO_QUARTILE_50` | video_p50_watched_actions | video_quartile_p50_rate | METRIC_RICH_MEDIA_VIDEO_MIDPOINTS | videoMidpointCompletions | — | | `VIDEO_QUARTILE_75` | video_p75_watched_actions | video_quartile_p75_rate | METRIC_RICH_MEDIA_VIDEO_THIRD_QUARTILE_COMPLETES | videoThirdQuartileCompletions | — | | `VIDEO_QUARTILE_100` | video_p100_watched_actions | video_quartile_p100_rate | METRIC_RICH_MEDIA_VIDEO_COMPLETIONS | — | — | | `VIDEO_AVG_TIME_WATCHED` | video_avg_time_watched_actions | — | — | — | — | | `VIDEO_THRUPLAY` | video_thruplay_watched_actions | — | — | — | — | | `AVERAGE_CPV` | — | average_cpv (/1e6) | — | — | — | | `EARNED_VIEWS` | — | — | METRIC_TRUEVIEW_EARNED_VIEWS | — | — | | `UNIQUE_VIEWERS` | — | — | METRIC_TRUEVIEW_UNIQUE_VIEWERS | — | — | | `VIDEO_VIEWS_FROM_SEARCH` | — | video_views_from_google_search_results | — | — | — | | `VIDEO_FULLSCREENS` | — | — | METRIC_RICH_MEDIA_VIDEO_FULL_SCREENS | fullScreenPlays | — | | `VIDEO_PAUSES` | — | — | METRIC_RICH_MEDIA_VIDEO_PAUSES | — | — | | `VIDEO_MUTES` | — | — | METRIC_RICH_MEDIA_VIDEO_MUTES | — | — | | `VIDEO_SKIPS` | — | — | METRIC_RICH_MEDIA_VIDEO_SKIPS | — | — | | `COMPANION_CLICKS` | — | — | METRIC_RICH_MEDIA_VIDEO_COMPANION_CLICKS | — | — | | `COMPANION_VIEWS` | — | — | METRIC_RICH_MEDIA_VIDEO_COMPANION_VIEWS | — | — | ## Social engagement Likes, shares, comments, and follows are native to the social-first platforms. LinkedIn reports them directly, and DV360 exposes the YouTube "earned" equivalents (earned likes, shares, and subscribers). Curiously, Google Ads maps `SHARES` and `SAVES` onto Gmail-specific forward and save actions — an artifact of how Gmail Ads engagement was instrumented. | Unified Metric | Meta Ads | Google Ads | Google DV360 | LinkedIn | Microsoft Ads | | ------------------- | -------- | -------------- | ----------------------------------------- | ---------------- | ------------- | | `ENGAGEMENT` | — | — | METRIC_ENGAGEMENT_RATE | derived (sum) | — | | `ENGAGEMENTS` | — | engagements | METRIC_ENGAGEMENTS | — | — | | `TOTAL_ENGAGEMENTS` | — | — | — | totalEngagements | — | | `OTHER_ENGAGEMENTS` | — | — | — | otherEngagements | — | | `LIKES` | — | — | METRIC_TRUEVIEW_EARNED_LIKES | likes | — | | `SHARES` | — | gmail_forwards | METRIC_TRUEVIEW_EARNED_SHARES | shares | — | | `COMMENTS` | — | — | — | comments | — | | `FOLLOWS` | — | — | METRIC_TRUEVIEW_EARNED_SUBSCRIBERS | follows | — | | `SAVES` | — | gmail_saves | METRIC_TRUEVIEW_EARNED_PLAYLIST_ADDITIONS | — | — | | `COMMENT_LIKES` | — | — | — | commentLikes | — | | `OPENS` | — | — | — | opens | — | ## Click and impression breakdowns Most of these granular click types are LinkedIn-specific, reflecting its sponsored-content and document-ad formats (card clicks, company-page clicks, text-URL clicks, and so on). Meta contributes `LANDING_PAGE_CLICKS` (its `inline_link_clicks`) and `UNIQUE_CLICKS`, while LinkedIn adds an approximate unique-impression count. | Unified Metric | Meta Ads | Google Ads | Google DV360 | LinkedIn | Microsoft Ads | | ------------------------ | ------------------ | ---------------------- | ------------ | ---------------------------- | ------------- | | `LANDING_PAGE_CLICKS` | inline_link_clicks | — | — | landingPageClicks | — | | `AD_UNIT_CLICKS` | — | — | — | adUnitClicks | — | | `CARD_CLICKS` | — | — | — | cardClicks | — | | `CARD_IMPRESSIONS` | — | — | — | cardImpressions | — | | `COMPANY_PAGE_CLICKS` | — | — | — | companyPageClicks | — | | `ACTION_CLICKS` | — | — | — | actionClicks | — | | `TEXT_URL_CLICKS` | — | — | — | textUrlClicks | — | | `GMAIL_SECONDARY_CLICKS` | — | gmail_secondary_clicks | — | — | — | | `UNIQUE_CLICKS` | unique_clicks | — | — | — | — | | `UNIQUE_IMPRESSIONS` | — | — | — | approximateUniqueImpressions | — | ## Lead generation Native lead-form metrics are exclusive to LinkedIn in this mapping, tied to its one-click Lead Gen Forms. | Unified Metric | Meta Ads | Google Ads | Google DV360 | LinkedIn | Microsoft Ads | | ----------------- | -------- | ---------- | ------------ | --------------------- | ------------- | | `LEADS` | — | — | — | oneClickLeads | — | | `LEAD_FORM_OPENS` | — | — | — | oneClickLeadFormOpens | — | ## Viewability and measurement Viewability — whether an impression was actually seen — is a Google ecosystem strength. Google Ads and DV360 share the Active View family (viewable, measurable, and percent-viewable impressions), DV360 adds billable and eligible impression counts plus average viewable time, and Google Ads contributes top-of-page impression share metrics from search. | Unified Metric | Meta Ads | Google Ads | Google DV360 | LinkedIn | Microsoft Ads | | ------------------------------- | -------- | ---------------------------------- | ------------------------------------------- | -------- | ------------- | | `VIEWABLE_IMPRESSIONS` | — | active_view_impressions | METRIC_ACTIVE_VIEW_VIEWABLE_IMPRESSIONS | — | — | | `MEASURABLE_IMPRESSIONS` | — | active_view_measurable_impressions | METRIC_ACTIVE_VIEW_MEASURABLE_IMPRESSIONS | — | — | | `VIEWABILITY_RATE` | — | active_view_viewability | METRIC_ACTIVE_VIEW_PCT_VIEWABLE_IMPRESSIONS | — | — | | `BILLABLE_IMPRESSIONS` | — | — | METRIC_BILLABLE_IMPRESSIONS | — | — | | `ELIGIBLE_IMPRESSIONS` | — | — | METRIC_ACTIVE_VIEW_ELIGIBLE_IMPRESSIONS | — | — | | `ACTIVE_VIEW_AVG_TIME` | — | — | METRIC_ACTIVE_VIEW_AVERAGE_VIEWABLE_TIME | — | — | | `ABSOLUTE_TOP_IMPRESSION_SHARE` | — | absolute_top_impression_percentage | — | — | — | | `TOP_IMPRESSION_SHARE` | — | top_impression_percentage | — | — | — | ## Cost and financial breakdowns DV360 is the only integration that decomposes cost into its component fees — media cost, data fees, platform fees — and reports advertiser-level revenue and profit. This reflects its role as a programmatic buying platform where margin transparency matters. | Unified Metric | Meta Ads | Google Ads | Google DV360 | LinkedIn | Microsoft Ads | | ------------------ | -------- | ---------- | ---------------------------------- | -------- | ------------- | | `REVENUE` | — | — | METRIC_REVENUE_ADVERTISER | — | — | | `MEDIA_COST` | — | — | METRIC_MEDIA_COST_ADVERTISER | — | — | | `TOTAL_MEDIA_COST` | — | — | METRIC_TOTAL_MEDIA_COST_ADVERTISER | — | — | | `DATA_FEES` | — | — | METRIC_DATA_COST_ADVERTISER | — | — | | `PLATFORM_FEES` | — | — | METRIC_PLATFORM_FEE_ADVERTISER | — | — | | `PROFIT` | — | — | METRIC_PROFIT_ADVERTISER | — | — | ## Viral / organic amplification (LinkedIn) LinkedIn's "viral" metrics measure the organic spread of sponsored content — engagement that happens when someone's network sees an ad because a connection interacted with it. This entire 19-metric family is exclusive to LinkedIn and mirrors its standard metrics (impressions, clicks, conversions, video quartiles, leads) but for organically-amplified reach. No other integration models this concept. | Unified Metric | Meta Ads | Google Ads | Google DV360 | LinkedIn | Microsoft Ads | | -------------------------------- | -------- | ---------- | ------------ | ---------------------------------------- | ------------- | | `VIRAL_IMPRESSIONS` | — | — | — | viralImpressions | — | | `VIRAL_CLICKS` | — | — | — | viralClicks | — | | `VIRAL_LIKES` | — | — | — | viralLikes | — | | `VIRAL_COMMENTS` | — | — | — | viralComments | — | | `VIRAL_SHARES` | — | — | — | viralShares | — | | `VIRAL_FOLLOWS` | — | — | — | viralFollows | — | | `VIRAL_VIDEO_PLAYS` | — | — | — | viralVideoStarts | — | | `VIRAL_VIDEO_VIEWS` | — | — | — | viralVideoViews | — | | `VIRAL_VIDEO_COMPLETIONS` | — | — | — | viralVideoCompletions | — | | `VIRAL_VIDEO_QUARTILE_25` | — | — | — | viralVideoFirstQuartileCompletions | — | | `VIRAL_VIDEO_QUARTILE_50` | — | — | — | viralVideoMidpointCompletions | — | | `VIRAL_VIDEO_QUARTILE_75` | — | — | — | viralVideoThirdQuartileCompletions | — | | `VIRAL_LEADS` | — | — | — | viralOneClickLeads | — | | `VIRAL_LEAD_FORM_OPENS` | — | — | — | viralOneClickLeadFormOpens | — | | `VIRAL_LANDING_PAGE_CLICKS` | — | — | — | viralLandingPageClicks | — | | `VIRAL_CONVERSIONS` | — | — | — | viralExternalWebsiteConversions | — | | `VIRAL_POST_CLICK_CONVERSIONS` | — | — | — | viralExternalWebsitePostClickConversions | — | | `VIRAL_VIEW_THROUGH_CONVERSIONS` | — | — | — | viralExternalWebsitePostViewConversions | — | | `VIRAL_ENGAGEMENTS` | — | — | — | viralTotalEngagements | — | ## Integration-by-integration takeaways **LinkedIn** is the deepest integration by a wide margin. Beyond solid core coverage, it owns two large metric families that exist nowhere else: native lead-form metrics and the complete "viral" organic-amplification set. If a report needs B2B lead data or organic-reach attribution, LinkedIn is the only source. **Google DV360** is the choice for media-buying transparency. It uniquely breaks cost into media, data, and platform fees and reports profit, and it offers the richest video-interaction signals (pauses, mutes, skips, companion banners) alongside full Active View viewability. **Google Ads** provides broad, dependable core coverage plus search-native metrics — impression share, cross-device conversions, and "all conversions" rollups. Watch for its two quirks: micro-unit cost fields that need dividing by a million, and quartile metrics expressed as rates rather than counts. **Meta Ads** concentrates on core performance and granular video-watch behavior. Its conversion and value figures come from nested action breakdowns rather than flat fields, so the `purchase` action type must be extracted explicitly. **Microsoft Ads** is the leanest integration, covering the universal core plus video views, view-through conversions, and "all conversions" totals — sufficient for standard performance reporting but without engagement, viewability, or social depth. ## Working with the response - **Build cross-connection reports on the universal eight.** `CLICKS`, `IMPRESSIONS`, `CONVERSIONS`, `COST`, `CTR`, `CPC`, `CONVERSION_VALUE`, and `CPA` are the only metrics guaranteed to appear in the `metrics` array for every integration mapped here. - **Check for presence, don't assume it.** With 60 of 92 metrics returned by only one integration, code that reads the `metrics` array should handle a metric being absent rather than expecting a fixed set. - **Read currency from the report, not the metric.** All monetary metrics are normalized into the report's `currency` field; Google Ads micro-units (`/1e6`) and per-platform conventions are already resolved for you. - **Some metrics are computed.** Ratios like `ROAS`, and several of LinkedIn's rates, are derived by Unified.to rather than supplied by the platform — they arrive in the `metrics` array all the same. - **Drop to** **`raw`** **when you need the original.** Anything platform-specific that the normalized model doesn't capture remains available on the report's `raw` field. ## ATS to Vector DB: How to Power Talent Intelligence with Real-Time Data URL: https://docs.unified.to/guides/ats_to_vector_db_how_to_power_talent_intelligence_with_real_time_data # ATS to Vector DB: How to Power Talent Intelligence with Real-Time Data ------ _October 14, 2025_ _Updated October 2026_ With [Unified.to](https://unified.to/), you can build candidate search on your customers' ATS data (Greenhouse, Lever, Workable and other supported ATS integrations) by integrating once with [Unified.to](https://unified.to/)'s ATS API. You read candidate records through the ATS API, turn each candidate profile (title, skills, experience, education) into text, embed it with [Unified.to](https://unified.to/)'s GenAI API, and upsert the vectors into a vector database such as Pinecone for semantic search and recruiter agents. The ATS data is read at request time. This guide shows you how to go from ATS to vector DB, step by step, using Unified.to, its GenAI API, and Pinecone. ## What does this build? 1. Fetch candidate data from an ATS in one common schema. 2. Turn each candidate profile into text and embed it. 3. Store embeddings in a vector database. 4. Retrieve the most relevant candidates at query time. 5. Use the retrieved context to power recruiter search, ranking, or AI agents. [Unified.to](https://unified.to/) reads and maps the candidate records from each ATS at request time; chunking, embeddings and vector storage stay in your infrastructure. [Unified.to](https://unified.to/) keeps no customer records at rest; connection credentials and operational metadata are encrypted at rest. --- ## Prerequisites - Node.js 22 or later (required by the current Pinecone TypeScript SDK, v9, as of 21 October 2026) - A Unified.to account with an ATS integration enabled (e.g., Lever, Greenhouse) - A [Unified.to](https://unified.to/) API key, set as the `UNIFIED_API_KEY` environment variable - Your customer's ATS connection ID - A [Unified.to](https://unified.to/) GenAI connection for an integration that supports embeddings, such as OpenAI (used below), Azure OpenAI, Cohere, Google Gemini or MistralAI - A Pinecone API key and an index created with dimension 1536, the default output size of `text-embedding-3-small` --- ## Step 1: Set up your project ```bash mkdir ats-vector-demo cd ats-vector-demo npm init -y npm install @unified-api/typescript-sdk dotenv @pinecone-database/pinecone ``` Add your credentials to `.env`: ```plain text UNIFIED_API_KEY=your_unified_api_key CONNECTION_ATS=your_customer_ats_connection_id CONNECTION_GENAI=your_genai_connection_id PINECONE_API_KEY=your_pinecone_api_key PINECONE_INDEX=your_pinecone_index ``` --- ## Step 2: Initialize the SDKs ```typescript import 'dotenv/config'; import { UnifiedTo } from '@unified-api/typescript-sdk'; import { Pinecone } from '@pinecone-database/pinecone'; const { UNIFIED_API_KEY, CONNECTION_ATS, CONNECTION_GENAI, PINECONE_API_KEY, PINECONE_INDEX, } = process.env; const sdk = new UnifiedTo({ security: { jwt: UNIFIED_API_KEY! }, }); const pinecone = new Pinecone({ apiKey: PINECONE_API_KEY! }); const index = pinecone.index({ name: PINECONE_INDEX! }); ``` --- ## Step 3: Get your customer's connection ID Before you can fetch candidates, your customer authorizes your app to access their ATS (e.g., Lever, Greenhouse) through Unified.to's embedded authorization flow. Once authorized, you receive a **connection ID** for that customer's integration. Store it securely and use it in all API calls for that customer. --- ## Step 4: Fetch candidate records and turn each into text Read candidates from the ATS and flatten each profile into one text block for embedding. Building the same text shape for every candidate keeps embeddings comparable across ATS integrations; fields are filled only where the integration returns them (skills on 44 of 141 candidate integrations, experiences on 54, education on 60, as of 19 October 2026). Leave the name and email out of the embedded text: matching doesn't need them, so store them as metadata if you need them. ```typescript import type { AtsCandidate } from '@unified-api/typescript-sdk/sdk/models/shared'; export async function fetchCandidates(connectionId: string): Promise { return await sdk.ats.listAtsCandidates({ connectionId, limit: 50, }); } export function normalizeResume(candidate: AtsCandidate): string { const experiences = (candidate.experiences ?? []) .map((exp: any) => `${exp.title ?? ''} at ${exp.companyName ?? ''}`) .join('; '); const education = (candidate.education ?? []) .map((edu: any) => `${edu.degree ?? ''} in ${edu.fieldOfStudy ?? ''} from ${edu.institution ?? ''}`) .join('; '); return [ `Title: ${candidate.title ?? ''}`, `Skills: ${(candidate.skills ?? []).join(', ')}`, `Experience: ${experiences}`, `Education: ${education}`, ].join('\n'); } ``` --- ## Step 5: Embed candidate profiles with the GenAI API Use [Unified.to](https://unified.to/)'s GenAI embedding endpoint. Note `content` is an array of `{ text }` objects. The encoding field is spelled `enconding_format` in the API schema (`encondingFormat` in the TypeScript SDK) and takes `FLOAT`; `type` distinguishes documents you index (`SEARCH_DOC`) from the recruiter query (`SEARCH_QUERY`). The `embeddings` field returns a JSON string, so parse it before use. ```typescript export async function embed(text: string, kind: 'SEARCH_DOC' | 'SEARCH_QUERY'): Promise { const result = await sdk.genai.createGenaiEmbedding({ connectionId: CONNECTION_GENAI!, genaiEmbedding: { modelId: 'text-embedding-3-small', content: [{ text }], encondingFormat: 'FLOAT', // spelled this way in the API schema type: kind, dimension: 1536, }, }); // `embeddings` is a JSON string: one vector, or a list holding one vector per content item. const parsed = JSON.parse(result.embeddings ?? '[]'); return Array.isArray(parsed[0]) ? parsed[0] : parsed; } ``` ### Keeping the vector index current To keep the index current, subscribe to candidate created and updated events on supported integrations: 92 ATS integrations emit candidate events (as of 20 October 2026), including Greenhouse, Lever and Workable. When a candidate changes, re-read it, re-embed and upsert. Where the ATS sends no webhooks, [Unified.to](https://unified.to/)'s virtual webhooks check at an interval you set, as often as every minute on paid plans. --- ## Step 6: Upsert embeddings to Pinecone ```typescript export async function upsertCandidate(candidate: AtsCandidate, values: number[]) { await index.upsert({ records: [ { id: candidate.id!, values, metadata: { name: candidate.name ?? '', email: candidate.emails?.[0]?.email ?? '', candidate_id: candidate.id ?? '', }, }, ], }); } ``` --- ## Step 7: Retrieval Given a recruiter query, embed it with `SEARCH_QUERY` and search Pinecone for the closest candidates. ```typescript export async function searchCandidates(query: string) { const queryVector = await embed(query, 'SEARCH_QUERY'); const results = await index.query({ vector: queryVector, topK: 5, includeMetadata: true, }); return results.matches; } ``` --- ## Step 8: Putting it together ```typescript async function main() { const candidates = await fetchCandidates(CONNECTION_ATS!); for (const candidate of candidates) { const resumeText = normalizeResume(candidate); const values = await embed(resumeText, 'SEARCH_DOC'); await upsertCandidate(candidate, values); } const matches = await searchCandidates('Senior Python developer with fintech experience'); console.log('Top matches:', matches); } main(); ``` --- ## What did you build? - One list call per connection reads candidate records from the customer's ATS (Greenhouse, Lever and other supported integrations); page with limit and offset for more than one page. - Candidate profiles are embedded through [Unified.to](https://unified.to/)'s GenAI API, then upserted into Pinecone. - Recruiters search in natural language and retrieve the closest candidates from your index, built from ATS data read at request time; [Unified.to](https://unified.to/) keeps no customer records at rest. Related: [How to Build a RAG Pipeline for Live SaaS Data](https://unified.to/blog/how_to_build_a_rag_pipeline_for_live_saas_data) · [How to Match Candidates to Jobs Using ATS Data](https://unified.to/blog/how_to_match_candidates_to_jobs_using_ats_data_with_unified_ats_api) For the product-level walkthrough, see [How to Build a Candidate Assessment Product with Unified.to](https://unified.to/blog/how_to_build_a_candidate_assessment_product_with_unified). To wire up sourcing, see [How to Source Candidates Externally](https://unified.to/blog/how_to_source_candidates_externally_and_push_them_into_an_ats_using_unified_ats_api). Ready to build? [Sign up for a free 30-day trial](https://app.unified.to/) or [book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified). ## Building AI applications with Unified and Langbase URL: https://docs.unified.to/guides/building_ai_applications_with_unified_and_langbase # Building AI applications with Unified and Langbase ------ _February 27, 2025_ ![unified_langbase_2.png](https://s3.us-east-2.amazonaws.com/unified-article-images/building_ai_applications_with_unified_and_langbase-0.png) Developers building AI applications for their customers face a common challenge: they need to juggle many different APIs and authorization flows, configure and manage multiple data pipelines, and deal with complex data models. What if you could focus on building amazing AI experiences without getting bogged down by all that complexity? That's where the power of Unified.to's APIs combined with [Langbase's](https://langbase.com/) serverless AI developer platform comes in. In this article, we'll look at how you can leverage these platforms to build sophisticated AI applications using your customers' data while writing significantly less integration and AI boilerplate code. ## Why use Unified.to with Langbase? When you pair Unified.to's normalized data access with Langbase's AI capabilities, you get: - **One API, many integrations:** Instead of building separate integrations for each platform, use [Unified.to](https://unified.to/)'s unified APIs to access normalized data from multiple data sources through a single, unified API. Langbase does the same for AI—with multi-agent orchestration and advanced long-term memory, seamlessly integrating AI capabilities. It supports 250+ LLMs through one API, ensuring a unified developer experience with easy model switching and optimization. It's an API-first platform, with simple Pipe and Memory Agent APIs for effortless integration. - **Unified authorization:** Seamlessly handle authorization with Unified.to's pre-built components, creating a secure link between your app and your customers' data with just a few lines of code. - **Production-ready AI:** Langbase Pipes and Memory Agents (serverless RAG agents with long-term memory) eliminate infrastructure management, making scaling and deployment seamless for all developers—not just AI/ML experts. Its composable AI infrastructure lets developers chain multiple models into AI pipelines, optimizing cost, performance, and personalization at every step. - **Real-time updates:** Build dynamic applications using Unified.to's webhook system. Fine-tine AI models on the fly when your customers have new data, allowing your AI agent to provide more relevant responses. ## Real-world applications you can build Let's look at some practical applications you can create by combining these platforms. **Intelligent support bots** Build an AI-powered support bot that can answer questions using your customer's knowledge base, handle real-time inquiries, and maintain context across conversations. By combining Unified.to's KMS (Knowledge Management System) and messaging APIs with [Langbase's Memory Agents](https://langbase.com/docs/memory) , you can create a bot that gets smarter as your knowledge base grows. The best part? Since you're using unified APIs, you can easily adapt this pattern for different platforms: - Use Notion, Confluence or other KMS platforms for your knowledge base - Deploy your bot on Slack instead of Discord Want to build your own? Check out our step-by-step [guide on building a Discord support bot.](https://docs.unified.to/guides/how_to_build_a_discord_support_bot_with_unified_and_langbase#how-to-build-a-discord-support-bot-with-unifiedto-and-langbase) **AI-powered recruitment assistant** Create an intelligent recruiting assistant that streamlines your hiring process by automatically screening candidates, conducting initial interviews, updating candidates' application statuses, and providing detailed summaries to your hiring team. Using Unified.to's [ATS integrations](https://unified.to/ats) and Langbase [Pipe agents](https://langbase.com/docs/pipe/quickstart), your assistant can handle the time-consuming parts of recruitment while maintaining a personal touch. The unified approach means you can support multiple ATS platforms (Lever, Greenhouse, Workday, etc.) without rewriting your core logic. **Enhanced email agents** With Unified.to and Langbase, you can automate email responses and streamline communication. By combining Unified.to's email integrations with Langbase's composable multi-agent architecture, you can build an AI-powered email workflow that enhances efficiency and personalization. **Intelligent sales assistant** Supercharge your sales team with an AI assistant that understands your customer relationships, helps qualify leads, and generates personalized follow-ups based on past interactions. Using Unified.to's CRM integrations with [Langbase's Memory Agents](https://langbase.com/docs/memory) (that dynamically attach private data to any LLM at scale, with industry-leading accuracy in advanced agentic routing and intelligent reranking), you can create an assistant that becomes an invaluable part of your sales process. Create leads and deals in your CRM automatically and tap into Unified.to's Enrichment API to add extra contextual data for the Memory Agent. ## Getting started Ready to start building? Here's how to get going: 1. Sign up for accounts on [Unified.to](https://unified.to/) and[ Langbase](https://langbase.com/) 2. Join our [Discord community](https://discord.gg/85z7HF7JbD) to connect with other developers 3. Keep an eye on our blog for upcoming guides in this series ## Looking ahead The combination of Unified.to and Langbase opens up exciting possibilities for AI application development. As both platforms continue to evolve, you can expect: - More supported platforms and integrations - Enhanced AI capabilities through Langbase's Memory Agents - New unified APIs for different use cases - Expanded tutorials and example applications Stay tuned for our upcoming tutorials that will dive deep into each use case, providing concrete examples and code you can use in your own applications. ## Concur & Concur (Company) — Connection Guide URL: https://docs.unified.to/guides/concur_and_concur_company_connection_guide # Concur & Concur (Company) — Connection Guide ------ _April 10, 2026_ ## Overview There are two SAP Concur integrations are available. You will need to choose which one to use based off of the authentication type and required data objects. ### Integrations - **Concur** - End-user OAuth (authorization code flow) - Categories: Accounting, Storage, Auth - **Concur (Company)** - Company-level authentication - Uses credentials instead of user OAuth - Categories: Accounting, HRIS, Storage, Auth --- ## Concur vs Concur (Company) ### Concur - **Authentication:** OAuth 2.0 (user-based) - **Credentials:** - Client ID - Client Secret - Redirect URI - **Supported Objects:** - Accounting — Expense - Accounting — Invoice - Accounting — Contact - Storage — File - Accounting — Expense report - Expense type: reads map type=USER (employee T&E / Quick Expense) - **Use Case:** - User connects their own account and grants access --- ### Concur (Company) - **Authentication:** Company token flow (server-to-server) - **token_names:** - Client ID - Client Secret - Company UUID - Request Token - **Supported Objects:** - Accounting — Expense - Accounting — Invoice - Accounting — Contact - HRIS — Employee - Storage — File - Accounting — Expense report - Expense type: reads map type=COMPANY (company-scoped reports) - **Use Case:** - Company-wide access - Backend integrations - Employee data access --- ## When to Use Which - **Use Concur** - When a user signs in and authorizes access - **Use Concur (Company)** - When you need company-wide access - When working with employee data - When SAP requires Company UUID + Request Token --- ## OAuth Scopes | Object | Read | Write | | ------- | --------------------------------------------------- | --------------------------------------------------- | | Expense | EXPRPT | EXPRPT | | Invoice | INVPMT | INVPMT | | File | IMAGE | IMAGE | | User | `identity.user.ids.read`, `identity.user.core.read` | `identity.user.ids.read`, `identity.user.core.read` | | Vendor | INVVEN | INVVEN | --- ## Concur (Company) — Credential Details ### Required Fields - **Client ID:** From SAP Concur OAuth Application Management - **Client Secret:** From the same OAuth application - **Company UUID:** Unique identifier for your company in SAP Concur - **Request Token:** Temporary token generated via SAP Concur Company Request Token tool (Guide: [https://developer.concur.com/api-reference/authentication/company-refresh-tool.html](https://developer.concur.com/api-reference/authentication/company-refresh-tool.html)) - Not long-lived - Must be regenerated when expired --- ## Region Reference Select the correct API region based on your tenant: - Production (US2 default) - EU2 - APJ1 - US Government - Sandbox / Implementation If unsure, confirm with your SAP Concur administrator. --- ## Network Requirements To ensure successful API communication, you may need to whitelist Unified IP addresses on your SAP Concur account. You can find the list of IP addresses here: [https://app.unified.to/apikeys](https://app.unified.to/apikeys) Failure to whitelist these IPs may result in connection or data access issues. ## Accounting expense create (Concur) Personal Concur create routing for accounting_expense (do not use user_id to choose the path): - type=USER and omit name → Quick Expense v4 (create-only). The returned id cannot be retrieved with get; use accounting_expensereport / report list for reads. - type=USER with a name (or any create that includes name) → Expense Report v3 create. - Concur (Company) expense reads use type=COMPANY; personal Concur reads use type=USER. - Prefer accounting_expensereport for expense reports when available. ## Configure Greenhouse Native Candidate Webhooks URL: https://docs.unified.to/guides/configure_greenhouse_native_candidate_webhooks # Configure Greenhouse Native Candidate Webhooks ------ _July 15, 2026_ Greenhouse Recruiting requires native webhooks to be created manually in its **Dev Center**. This guide explains how to connect Greenhouse candidate events to a Unified webhook subscription. --- ## Before you begin You need: - An active Greenhouse connection in Unified - The Greenhouse connection ID - A public HTTPS endpoint in your application that can receive Unified webhook payloads - Greenhouse's **Manage and configure webhooks** developer permission - A Unified API key --- ## How the webhook flow works 1. Greenhouse sends a candidate event to Unified. 2. Unified converts the event into the unified `ats_candidate` format. 3. Unified sends the normalized webhook payload to your application's webhook URL. > **Note** > > For `created` and `updated` events, Unified retrieves the latest candidate record from Greenhouse before sending the payload to your application. > > > For `deleted` events, the payload contains the deleted candidate ID. > > --- ## Step 1: Create Unified webhook subscriptions Create one native Unified webhook subscription for each event type you need: - `created` - `updated` - `deleted` ### Example: Created candidate subscription ```plain text curl --request POST \ --url 'https://api.unified.to/unified/webhook' \ --header 'Authorization: Bearer YOUR_UNIFIED_API_KEY' \ --header 'Content-Type: application/json' \ --data '{ "connection_id": "YOUR_GREENHOUSE_CONNECTION_ID", "object_type": "ats_candidate", "event": "created", "webhook_type": "native", "hook_url": "https://your-app.example.com/webhooks/unified" }' ``` The `hook_url` is your application's endpoint. Unified sends normalized webhook payloads to this URL. Repeat the request with `event` set to `updated` and `deleted` when those events are required. Save the `id` returned for each webhook subscription. [Create webhook subscription](https://docs.unified.to/unified/webhook/Create_webhook_subscription) --- ## Step 2: Build the Greenhouse endpoint URLs Build a separate Greenhouse endpoint URL using each Unified webhook subscription ID. | Unified region | Greenhouse endpoint | | -------------- | ----------------------------------------------------------------- | | US | `https://api.unified.to/webhook/greenhouse/UNIFIED_WEBHOOK_ID` | | EU | `https://api-eu.unified.to/webhook/greenhouse/UNIFIED_WEBHOOK_ID` | | Australia | `https://api-au.unified.to/webhook/greenhouse/UNIFIED_WEBHOOK_ID` | Use: - The `created` subscription ID for Greenhouse creation triggers - The `updated` subscription ID for Greenhouse update triggers - The `deleted` subscription ID for Greenhouse deletion triggers > **Important** > > Do not enter your application's `hook_url` in Greenhouse. > > > Greenhouse must send its payload to the Unified endpoint shown above. Unified will then normalize the event and forward it to your application. > > --- ## Step 3: Open Greenhouse webhook settings In Greenhouse Recruiting: 1. Select **Configure**. 2. Select **Dev Center**. 3. Select **Web Hooks**. 4. Select **Web Hooks** again. 5. Select the option to create a new webhook. > **Permission required** > > If you cannot access this page, ask a Greenhouse administrator to grant you the **Manage and configure webhooks** developer permission. > > --- ## Step 4: Create the Greenhouse webhooks Greenhouse allows only one trigger per webhook. Create a separate Greenhouse webhook for every trigger you want to receive. For each webhook, enter the following: | Field | Value | | ------------------------- | ---------------------------------------------------------------------- | | **Name this webhook** | A descriptive name, such as `Unified – Candidate hired` | | **When** | One of the supported triggers listed below | | **Endpoint URL** | The Unified endpoint for the corresponding Unified event | | **Secret Key** | A unique, randomly generated secret stored securely | | **Error recipient email** | A Greenhouse administrator who should receive delivery failure notices | | **Disabled?** | `No` | | **Advanced settings** | Leave blank unless your organization has additional requirements | ### Created candidate events Point the following Greenhouse triggers to the endpoint containing your Unified `created` webhook ID: - Application created - Prospect created ### Updated candidate events Point the following Greenhouse triggers to the endpoint containing your Unified `updated` webhook ID: - Candidate/Prospect updated - Candidate hired - Candidate/Prospect rejected - Candidate stage change - Candidate unhired - Candidate/Prospect unrejected ### Deleted candidate events Point the following Greenhouse triggers to the endpoint containing your Unified `deleted` webhook ID: - Candidate deleted - Candidate merged > **Important** > > Use the correct Unified endpoint for each trigger group. > > > For example, do not send a **Candidate hired** trigger to the endpoint associated with a Unified `created` subscription. > > --- ## Trigger mapping reference | Greenhouse trigger | Unified event | | ----------------------------- | ------------- | | Application created | `created` | | Prospect created | `created` | | Candidate/Prospect updated | `updated` | | Candidate hired | `updated` | | Candidate/Prospect rejected | `updated` | | Candidate stage change | `updated` | | Candidate unhired | `updated` | | Candidate/Prospect unrejected | `updated` | | Candidate deleted | `deleted` | | Candidate merged | `deleted` | --- ## Step 5: Confirm that each webhook is active When a Greenhouse webhook is created, Greenhouse sends a test ping to the configured endpoint. - If the ping succeeds, Greenhouse activates the webhook. - If the ping fails, Greenhouse saves the webhook in a disabled state. ### Retry a failed webhook 1. Open the webhook in Greenhouse. 2. Confirm that the endpoint contains the correct Unified webhook subscription ID. 3. Set **Disabled?** to **No**. 4. Save the webhook again. --- ## Test the integration Perform a Greenhouse action matching one of the configured triggers. For example, update a test candidate after configuring the **Candidate/Prospect updated** trigger. Confirm that your application receives a Unified webhook payload containing: - `type` set to `NATIVE` - `webhook.object_type` set to `ats_candidate` - `webhook.event` set to the expected Unified event - The normalized candidate record in the `data` array Deleted candidate events contain an object with the deleted candidate's `id`. --- ## Remove the webhooks Deleting a Unified webhook subscription does not delete the manually configured Greenhouse webhooks. To fully remove the integration: 1. Disable or delete each corresponding webhook in Greenhouse. 2. Delete the Unified webhook subscriptions. --- ## Troubleshooting ### The Greenhouse webhook is disabled immediately The initial endpoint ping failed. Check that: - The endpoint starts with `https://`. - The Unified webhook ID is correct. - The endpoint uses the API hostname for the correct Unified region. - The Unified webhook subscription still exists. ### No webhook arrives after a candidate change Check that: - You configured the specific Greenhouse trigger for that action. - The trigger points to the Unified endpoint for the correct `created`, `updated`, or `deleted` subscription. - The Greenhouse webhook is not disabled. - The Greenhouse connection still has permission to retrieve candidates. - The configured error recipient has not received a Greenhouse delivery failure email. ### Duplicate deliveries Check that the same Greenhouse trigger was not created more than once. Greenhouse may also retry failed deliveries, so your application should process webhook deliveries idempotently. --- ## Additional documentation - [Greenhouse: Create a webhook](https://support.greenhouse.io/hc/en-us/articles/360005574531-Create-a-webhook) - [Greenhouse Recruiting webhooks](https://developers.greenhouse.io/webhooks.html) - [Unified: Introduction to webhooks](https://docs.unified.to/reference/webhooks) - [Unified: Create webhook subscription](https://docs.unified.to/unified/webhook/Create_webhook_subscription) For creating the webhook in Unified, see [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks). ## Connect Amazon Seller Central to Unified.to URL: https://docs.unified.to/guides/connect_amazon_seller_central_to_unified # Connect Amazon Seller Central to Unified.to ------ _May 13, 2026_ **What this integration covers** | Area | Unified objects | Typical use | | -------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | **E‑Commerce** | commerce_item, commerce_collection, commerce_inventory, commerce_saleschannel | Catalog, product types, FBA inventory summaries, marketplace participation | | **Accounting** | commerce_account, accounting_salesorder, accounting_contact | Orders and related account/contact style data from SP-API | | **Metadata** | metadata_metadata | Custom / structured metadata mapping where supported | Exact read/write support follows your workspace permissions and the integration's object support matrix in the Unified app. --- **Prerequisites (Amazon side)** 1. **Active Seller Central** account for the marketplaces you care about. 2. **SP-API developer registration** — create and configure a **Selling Partner API** application in the Amazon developer / SP-API flow. Please follow this guide: [https://developer-docs.amazon.com/sp-api/docs/registering-your-application](https://developer-docs.amazon.com/sp-api/docs/registering-your-application) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connect_amazon_seller_central_to_unified-0.png) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connect_amazon_seller_central_to_unified-1.png) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connect_amazon_seller_central_to_unified-2.png) Put the redirect_url as shown here: [https://app.unified.to/integrations/amazonsellercentral?tab=oauth2](https://app.unified.to/integrations/amazonsellercentral?tab=oauth2) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connect_amazon_seller_central_to_unified-3.png) 3. **LWA (Login with Amazon) security profile** linked to that application so you obtain: - **LWA Client ID** - **LWA Client Secret** 1. **Marketplace ID(s)** for each marketplace (e.g. US). Reference: Marketplace IDs. 2. **Correct region** for the SP-API endpoint you will use (see below). **NOTE:** Amazon's SP-API documentation also describes **IAM users/roles and request signing** for many operations. Follow Amazon's current SP-API onboarding for your app (including any **IAM / signing** requirements Amazon still enforces for certain endpoints). If calls fail with auth errors, verify the app's SP-API roles and Amazon's latest auth requirements. --- **Choose the SP-API region (must match your selling region)** When connecting, pick the environment that matches where you sell (this selects **authorize URL** and **SP-API base URL**): | Region | Host | | ----------------- | -------------------------------- | | **North America** | **Seller Central** host : .com | | **Europe** | **Seller Central** host : .co.uk | | **Far East** | **Seller Central** host : .co.jp | --- **Connect in Unified (step-by-step)** 1. **Open Integrations** (or your product's connection UI) and activate **Amazon Seller Central**. 2. **Enter OAuth app credentials** from Amazon (LWA **Client ID** and **Client Secret**) in the fields your Unified workspace uses for this integration (same pattern as other OAuth2 apps). 3. **Set Marketplace ID** in the field Unified uses as the developer / connection key for this integration (**labeled as Marketplace ID** in vendor config). 4. **Select the correct regional URL** (North America vs Europe vs Far East) so authorize and API hosts align with your seller account. 5. **Start OAuth** — you will be sent to **Seller Central** to approve the app (/apps/authorize/consent). --- **Sandbox** Amazon documents an SP-API sandbox: [https://app.unified.to/integrations/amazonsellercentral?tab=partnership](https://app.unified.to/integrations/amazonsellercentral?tab=partnership) Use it if you are testing before production seller data. ## Connecting BambooHR via OAuth 2 URL: https://docs.unified.to/guides/connecting_bamboohr_via_oauth_2 # Connecting BambooHR via OAuth 2 ------ _July 28, 2026_ # BambooHR OAuth Setup Guide ## Overview First, register an application in the BambooHR Developer Portal to obtain OAuth credentials. Next, configure those credentials in Unified. After the setup is complete, your end users can authorize their own BambooHR accounts through your embedded Unified connection flow. --- # Part A — Set Up Your App in the BambooHR Developer Portal > **One-time setup** ## 1. Open the BambooHR Developer Portal Go to: [https://developers.bamboohr.com/home/applications](https://developers.bamboohr.com/home/applications) Sign in to your BambooHR Developer Portal account. ## 2. Create a New Application ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connecting_bamboohr_via_oauth_2-0.png) 1. Click **Add application** in the top-right corner. 2. Enter an **Application Name**, for example: `unified` 3. Click **Create Application**. ## 3. Collect Your Application Details After creating the application, note the following values: - **Application Key** - **Application ID** You will need the **Application Key** when configuring the integration in Unified. ## 4. Copy Your OAuth Credentials Under **App Credentials**, copy the following: - **Client ID** - **Client Secret** Use the eye icon to reveal the Client Secret. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connecting_bamboohr_via_oauth_2-1.png) You can use **Regenerate** to create a new Client Secret when required. > **Important:** Treat the Client Secret like a password. If you regenerate it, you must also update the Client Secret in Unified. ## 5. Add the Redirect URI Under **Redirect URIs**, add the OAuth callback URL provided by Unified. ## 6. Select the Required Scopes Go to: **Application Scopes → Select Scopes** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connecting_bamboohr_via_oauth_2-2.png) For each available category, such as Employee, Company, Hiring, Time Off, and Reports, choose the appropriate permission level: - **No Access** - **Read** - **Read / Write** Configure the following scopes as required by your implementation: ```plain text email openid company:info company_file company_file.write employee employee.write employee:assets employee:assets.write employee:compensation employee:compensation.write employee:contact employee:contact.write employee:custom_fields employee:custom_fields.write employee:custom_fields_encrypted employee:custom_fields_encrypted.write employee:demographic employee:demographic.write employee:dependent employee:dependent.write employee:dependent:ssn employee:dependent:ssn.write employee:education employee:education.write employee:emergency_contacts employee:emergency_contacts.write employee:file employee:file.write employee:identification employee:identification.write employee:job employee:job.write employee:management employee:management.write employee:name employee:name.write employee:payroll employee:payroll.write employee:photo employee:photo.write employee:providers employee:providers.write employee:providers:payroll employee:providers:payroll.write employee:vaccination employee:vaccination.write employee_verifications employee_verifications.write goal goal.write onboarding onboarding.write performance:assessments performance:assessments.write performance:feedback performance:feedback.write performance:one_on_ones performance:one_on_ones.write sensitive_employee:address sensitive_employee:address.write sensitive_employee:creditcards sensitive_employee:creditcards.write sensitive_employee:protected_info sensitive_employee:protected_info.write tasks tasks.write hiring:applications hiring:applications.write hiring:job_openings hiring:job_openings.write offline_access user user.write report report.write time_off time_off.write time_off:requests time_off:requests.write ``` --- # Part B — Configure BambooHR in Unified ## 7. Configure the BambooHR Integration In the Unified dashboard, go to: **Integrations → BambooHR → Authorization** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connecting_bamboohr_via_oauth_2-3.png) Configure the following fields: - Set the authorization method to **OAuth 2** - Paste the **OAuth 2 Client ID** from Step 4 - Paste the **OAuth 2 Client Secret** from Step 4 - Enter the **Developer API Key** - Use the **Application Key** collected in Step 3 Click **Update** to save the configuration. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connecting_bamboohr_via_oauth_2-4.png) ## 8. Configure the Embedded Component In the Unified dashboard, go to: **Embedded components** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connecting_bamboohr_via_oauth_2-5.png) Configure the following settings: - **Success URL** - **Failure URL** - **Data region**, such as: - US - EU - AU - **Permission Scopes / Categories** Copy the appropriate embed code into your application. Unified provides embed options for: - React - Angular - Vue - Svelte - JavaScript - API-based implementations --- # Part C — End-User Authorization Flow ## 9. Enter the BambooHR Domain In the embedded authorization flow, the end user sees the **Authorize Access** screen. The user enters their **BambooHR Domain** and clicks **Authorize**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connecting_bamboohr_via_oauth_2-6.png) ## 10. Approve Access BambooHR displays a consent screen similar to: > **Share access with unified?** The user reviews the requested scopes and clicks **Allow Access**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connecting_bamboohr_via_oauth_2-7.png) ## 11. Confirm the Connection After authorization is completed, the connection appears in Unified under: **Settings → Connections** The connection should display a **Healthy** status. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/connecting_bamboohr_via_oauth_2-8.png) ## Connecting Google Workspace Integrations with a Service Account URL: https://docs.unified.to/guides/connecting_google_workspace_integrations_with_a_service_account # Connecting Google Workspace Integrations with a Service Account ------ _May 21, 2026_ Unified API supports two ways to authenticate Google integrations: the standard interactive **OAuth2** flow (a user clicks "Allow"), or a **service account** for server-to-server access with no human in the loop. This guide covers the service account path: how to create one, how to grant it access to your data, and what to enter in Unified API. ## **When to use a service account** Use a service account when you want a backend system to access Google data without a user logging in each time — for example, syncing every mailbox in your company, reading a shared Drive, or managing your Workspace directory. For most Workspace data (Gmail, Calendar, Drive files owned by users, etc.) the service account must **impersonate** a user via **domain-wide delegation (DWD)**. For a few APIs (Analytics, Merchant Center, Campaign Manager) you instead just **share** the resource with the service account's email — no delegation needed. --- ## **Step 1 — Create a Google Cloud project** 1. Go to the [Google Cloud Console](https://console.cloud.google.com/). 2. Create a new project (or select an existing one) using the project picker at the top. 3. Note the **Project ID** — you'll see it referenced in the key file later. ## **Step 2 — Enable the APIs you need** 1. Navigate to **APIs & Services → Library**. 2. Search for and **Enable** the API for each integration you'll use: | **Integration** | **API to enable** | | ------------------- | ------------------------------------- | | Google Drive | Google Drive API | | Gmail | Gmail API | | Google Calendar | Google Calendar API | | Google Sheets | Google Sheets API | | Google Docs | Google Docs API | | Google Slides | Google Slides API | | Google Forms | Google Forms API | | Google Tasks | Google Tasks API | | Google Contacts | People API | | Google Meet | Google Meet API + Google Calendar API | | Workspace Directory | Admin SDK API | If an API isn't enabled, calls will fail with a `403 ... API has not been used in project` error. ## **Step 3 — Create the service account** 1. Go to **APIs & Services → Credentials → Create credentials → Service account**. 2. Give it a name (e.g. `unified-api-sync`) and click **Create and continue**. 3. You can skip the optional "grant access" steps for now. Click **Done**. 4. You'll land on the service account list. Note its **email** — it looks like:`unified-api-sync@your-project-id.iam.gserviceaccount.com` ## **Step 4 — Generate a key** 1. Click the service account, then open the **Keys** tab. 2. **Add key → Create new key → JSON → Create.** 3. A `.json` file downloads. **Store it securely** — Google does not let you re-download it. The file looks like this (the two fields you need are highlighted): ```json { "type": "service_account", "project_id": "your-project-id", "private_key_id": "abc123...", "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----\n", ← Service Account Private Key "client_email": "unified-api-sync@your-project-id.iam.gserviceaccount.com", ← Service Account Email "client_id": "11223344...", "token_uri": "https://oauth2.googleapis.com/token", ... } ``` --- ## **Step 5 — Grant the service account access to your data** This is the step that differs by API. Pick the path that matches your integration. ### **Path A — Domain-wide delegation (Workspace user data)** Required for **Gmail, Calendar, Drive (user files), Sheets, Docs, Slides, Forms, Tasks, Contacts, Meet, and Directory**. This lets the service account act _as_ a specific user in your Workspace. 1. On the service account's detail page, note its **Client ID** (the numeric `client_id` from the JSON, also called "Unique ID"). 2. As a Workspace **super admin**, go to the [Admin Console → Security → Access and data control → API controls → Domain-wide delegation](https://admin.google.com/ac/owl/domainwidedelegation). 3. Click **Add new** and enter: - **Client ID**: the service account's numeric Client ID. - **OAuth scopes**: a comma-separated list of the scopes for the APIs you'll use (see the scope table below). 4. **Authorize.** Changes can take a few minutes to propagate. > Only the scopes you authorize here will work. If you add the Drive integration later, you must come back and add the Drive scope. ### **Path B — Resource sharing (no delegation)** For APIs where data is owned by an account/property rather than a user, you skip DWD and just grant the service account's **email** access inside the product: - **Google Analytics** — add the service account email as a user on the GA4 property. - **Merchant Center** — add it as a user in Merchant Center settings. - **Campaign Manager / Display & Video 360** — add it as a user in the platform. (These integrations aren't part of the Workspace set covered here but follow the same key-creation steps.) --- ## **Step 6 — Scopes per integration** When configuring domain-wide delegation (Path A), authorize the matching scope(s). These are exactly the scopes Unified API requests when minting tokens: | **Integration** | **Scope(s) to authorize** | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Google Drive | `https://www.googleapis.com/auth/drive` | | Gmail | `https://mail.google.com/` | | Google Calendar | `https://www.googleapis.com/auth/calendar` | | Google Sheets | `https://www.googleapis.com/auth/spreadsheets` | | Google Docs | `https://www.googleapis.com/auth/documents` | | Google Slides | `https://www.googleapis.com/auth/presentations` | | Google Forms | `https://www.googleapis.com/auth/forms.body`, `https://www.googleapis.com/auth/forms.responses.readonly` | | Google Tasks | `https://www.googleapis.com/auth/tasks` | | Google Contacts | `https://www.googleapis.com/auth/contacts` | | Google Meet | `https://www.googleapis.com/auth/calendar`, `https://www.googleapis.com/auth/meetings.space.readonly` | | Workspace Directory | `https://www.googleapis.com/auth/admin.directory.user`, `https://www.googleapis.com/auth/admin.directory.group`, `https://www.googleapis.com/auth/admin.directory.group.member` | --- ## **Step 7 — Configure the connection in Unified API** When creating the connection, choose the **Service Account** authentication option and fill in three fields: | **Field** | **Value** | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Service Account Email** | The `client_email` from the JSON key file. | | **Service Account Private Key** | The `private_key` from the JSON key file — paste it whole, including the `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----` lines. | | **Subject** | _(Optional)_ The email of the Workspace user to impersonate via domain-wide delegation. **Required for most Workspace data** (e.g. Gmail needs a mailbox owner). Leave blank only if the service account itself owns the data or you've shared resources directly with it. | That's it. Unified API signs a short-lived JWT with your private key, exchanges it with Google for an access token (caching it until it expires), and uses it for all API calls — no further interaction needed. --- ## **Troubleshooting** | **Error** | **Likely cause** | | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `unauthorized_client` | The scope isn't authorized in domain-wide delegation, or the Client ID is wrong. Re-check Step 5A. | | `403 ... has not been used in project` | The API isn't enabled for the project (Step 2). | | `400 invalid_grant` / `Invalid JWT Signature` | The private key was pasted incorrectly (truncated, or newlines lost). Re-copy the full `private_key` value. | | Empty results / `404` for a user's data | Missing or wrong **Subject** — you're querying as the service account itself instead of impersonating the user. | | `403 Not Authorized to access this resource` (Directory) | The impersonated **Subject** must be a Workspace **admin** with rights to the directory data. | ### **Security notes** - The private key grants standing access to your data — store it in a secret manager, never in source control. - Authorize only the scopes you actually use; broad scopes increase blast radius if the key leaks. - Rotate keys periodically (create a new key, update the connection, delete the old key). ## Correct WelcomeKit Scopes for Jungle Integration URL: https://docs.unified.to/guides/correct_welcomekit_scopes_for_jungle_integration # Correct WelcomeKit Scopes for Jungle Integration ------ _April 14, 2026_ This guide outlines the required WelcomeKit scopes per Unified object for the Jungle integration. ## Scope Mapping (by Unified Object) | Unified Object | Required Scope(s) | Partnership (su_*) Needed? | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | | **ATS · Job (**`ats_job`**)** | `su_jobs_r` | Yes | | **ATS · Candidate (**`ats_candidate`**)** | **Read:** `candidates_r` AND `my_candidates_r`
**Write:** `candidates_rw` AND `my_candidates_rw` | No | | **ATS · Document (**`ats_document`**)** | **Read:** `documents_r` AND (`candidates_r` & `my_candidates_r`)
**Write:** `documents_rw` AND (`candidates_rw` & `my_candidates_rw`) | No | | **ATS · Activity (**`ats_activity`**)** | **Read:** `emails_r` AND (`candidates_r` & `my_candidates_r`)
**Write:** `comments_w` AND (`candidates_rw` & `my_candidates_rw`) | No | | **HRIS · Company (**`hris_company`**)** | **Read:** `su_organizations_r` | Yes | | HRIS · Location (hris_location) | Read: `offices_r` & `su_offices_r`
Write: `offices_rw` | Yes | | HRIS · Group (hris_group) | Read: `departments_r` & `departments_rw` & `su_departments_r`
Write: `departments_rw` | Yes | --- ## Important Notes ### Partnership-only scopes - `su_jobs_r` → Required for listing jobs - `su_organizations_r` → Required for listing organizations - `su_offices_r` → Required for listing locations - `su_departments_r` → Required for listing groups These require WelcomeKit partnership approval. ### **Non-partner access (scope overrides)** If you do **not** have WelcomeKit partnership approval, you won't be able to use the partnership-only scopes. Instead, request the standard non-partner scopes by overriding the defaults for this integration. Each Unified permission (like `ats_job_read`) maps to a default set of the provider's raw scopes, and you can replace that default per integration via the integration's scope override setting. Update the `WorkspaceIntegration` object with an `overriden_scopes` map keyed by the Unified permission, where each value is a comma-separated list of the provider's raw scopes: `{ "overriden_scopes": { "ats_job": "jobs_r" } }` With this override, Unified requests `jobs_r` (org-scoped jobs) instead of `su_jobs_r`. At runtime, the connector lists jobs via `GET /jobs?organization_reference=...` using the organization cached on the connection during auth setup, and falls back to `GET /users/current` for organization discovery — so non-partner keys never depend on the `su_*` scopes. For full details, see [Overriding scopes per integration](https://docs.unified.to/concepts/scopes#overriding-scopes-per-integration). --- ### Candidate-dependent scopes The following always require candidate access: - Documents → require candidate read/write scopes - Emails → require candidate read scopes - Comments → require candidate write scopes --- ## How to Get Partnership Access (Jungle / WelcomeKit) To access partnership-only scopes (`su_*`), you will need to be approved by WelcomeKit. ### Steps: 1. **Reach out to WelcomeKit support or your account manager (**[https://help.welcometothejungle.com/en](https://help.welcometothejungle.com/en)) - Request access to partnership scopes - Clearly mention your use case (e.g. job syncing, org-level data access) 2. **Provide integration details** - Your application name - What data you intend to access - Whether access is read-only or includes write operations 3. **Explain your end-user flow** - How users connect their WelcomeKit account - Why elevated (partnership) access is required 4. **Await approval** - Once approved, the scopes will be enabled for your app - You can then include `su_*` scopes in your OAuth flow ## Creating Ads using the Unified Ads API URL: https://docs.unified.to/guides/creating_ads_using_the_unified_ads_api # Creating Ads using the Unified Ads API ------ _March 14, 2026_ # Unified Ads API Overview The Unified API Ads category models advertising structures across Meta, Google, Amazon, Twitter, LinkedIn, and other providers. Understanding how objects relate helps you build the correc flows. ### Core Hierarchy Most integrations follow a hierarchy from organization → campaign → ad group → ad: ```plain text ads_organization (ad account) └── ads_campaign └── ads_group (ad set, line item, etc.) └── ads_ad ``` | Object | Description | Parent(s) | Typical use | | -------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- | | **ads_organization** | The ad account (e.g., Meta ad account, Google Ads customer). Billing and access are scoped here. | — | List accounts, select which account to use | | **ads_campaign** | A campaign groups ad sets under a goal, budget, and schedule. | `organization_id` | Set campaign-level budget, dates, objective | | **ads_group** | An ad set or line item. Holds targeting, bid, and budget. Often called "ad set" (Meta) or "line item" (DV360). | `campaign_id`, `organization_id` | Define who sees ads (targeting), bid strategy, promoted object | | **ads_ad** | The actual ad creative + placement. What users see. | `group_id`, `campaign_id`, `organization_id` | Creative content, destination URL, promoted entity (e.g., tweet, product) | ### Creatives and Insertion Orders | Object | Description | Parent(s) | Notes | | ---------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------ | | **ads_creative** | The creative asset (image, video, copy). Some providers treat it as a separate resource; others embed it in the ad. | `group_id`, `campaign_id`, `organization_id` | Meta: can be shared across ads. Amazon/Twitter: often 1:1 with ad. | | **ads_insertionorder** | DV360-specific. Sits between campaign and line item. | `campaign_id`, `organization_id` | Similar to a purchase order for a campaign. | ### Discovery | Object | Description | Purpose | | --------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- | | **ads_target** | Targeting options (countries, regions, interests, etc.). | Look up valid IDs to use in `targeting` when creating campaigns/groups. | | **ads_promote** | Promoted entities (pages, apps, products, tweets, ad group types). | Look up or pass IDs for `promoted` when creating groups/ads/creatives. | ### Reporting | Object | Description | Purpose | | -------------- | -------------------- | ---------------------------------------------------------------------- | | **ads_report** | Performance metrics. | Fetch impressions, clicks, spend, etc. by org, campaign, group, or ad. | ### Provider Mapping Naming varies by provider: | Unified API | Meta Ads | Google Ads | Amazon Ads | Twitter Ads | LinkedIn | | ---------------- | ----------- | ---------- | ---------- | -------------- | ----------------- | | ads_organization | Ad Account | Customer | Profile | Account | Sponsored Account | | ads_campaign | Campaign | Campaign | Campaign | Campaign | Campaign Group | | ads_group | Ad Set | Ad Group | Ad Group | Line Item | Campaign | | ads_ad | Ad | Ad | Product Ad | Promoted Tweet | Creative | | ads_creative | Ad Creative | Ad | Product Ad | Promoted Tweet | Creative | _Some integrations collapse levels (e.g., LinkedIn's "Campaign" maps to our ads_group)._ ### Creating Ads: Required Parent IDs When creating objects, you typically need parent IDs: - **Create campaign:** `organization_id` - **Create ad group:** `organization_id`, `campaign_id` - **Create ad:** `organization_id`, `campaign_id`, `group_id` - **Create creative:** `organization_id`, `campaign_id`, `group_id` (varies by provider) The `promoted` field is required when creating ad groups or ads that need a promoted entity (e.g., Meta ad set with a page, Twitter promoted tweet with a tweet ID). Use `ads_promote` to discover options, or pass IDs manually when the provider has no list API. --- # Promoted Explained The new `promoted` field specifies promoted entities (pages, apps, products, tweets, ad group types) when creating and managing ads across the Unified API. Ads, Campaigns, and AdGroups have a `promoted` field to specify an ID of an entity to promote. There is also a `ads_promote` endpoint that provides a clean way to query the promoted entities. ```json { "organization_id": "act_123", "campaign_id": "456", "name": "My Ad Group", "promoted": [ { "id": "789", "type": "PAGE_ID" } ] } ``` --- ## The AdsPromoted Object Each promoted entity has a simple structure: | Field | Type | Required | Description | | ------ | ------ | -------- | ----------------------------------------------- | | `id` | string | Yes | The entity ID (e.g., page ID, ASIN, tweet ID) | | `name` | string | No | Display name (optional, used in list responses) | | `type` | string | Yes | One of the supported promoted types (see below) | ### Supported Promoted Types | Type | Integration(s) | Description | | --------------- | -------------------- | ----------------------------------------------------------------- | | `PAGE_ID` | Meta Ads | Facebook Page to promote | | `APP_ID` | Meta Ads | App to promote | | `PIXEL_ID` | Meta Ads | Conversion pixel | | `CATALOG_ID` | Meta Ads | Product catalog | | `STORE_URL` | Meta Ads | Store URL (manual input) | | `PRODUCT_ID` | Amazon Ads | Product ASIN (manual input) | | `TWEET_ID` | Twitter Ads | Tweet to promote | | `AD_GROUP_TYPE` | LinkedIn, Google Ads | Campaign/ad group type (e.g., SPONSORED_UPDATES, SEARCH_STANDARD) | --- ## GET /ads/{connection_id}/promoted Endpoint Use the `ads_promote` list endpoint to discover available promoted entities that the integration supports. **Endpoint:** `GET /ads/{connection_id}/promote` **Query parameters:** | Parameter | Type | Description | | --------- | ------ | ----------------------------------------------------- | | `type` | string | Filter by promoted type (e.g., `PAGE_ID`, `TWEET_ID`) | | `org_id` | string | Organization/account I | | `query` | string | Optional search filter | | `limit` | number | Max results (default 100) | | `offset` | number | Pagination offset | ### Example: List Facebook Pages (Meta Ads) ```bash GET /ads/{connection_id}/promote?type=PAGE_ID ``` Response: ```json [ { "id": "123456789", "name": "My Business Page", "type": "PAGE_ID" }, { "id": "987654321", "name": "Another Page", "type": "PAGE_ID" } ] ``` ### Example: List User Tweets (Twitter Ads) ```bash GET /ads/{connection_id}/promote?type=TWEET_ID ``` ### Example: List Ad Group Types (Google Ads, LinkedIn) ```bash GET /ads/{connection_id}/promote?type=AD_GROUP_TYPE ``` Some integrations will return static options (e.g., `SEARCH_STANDARD`, `DISPLAY_STANDARD` for Google; `SPONSORED_UPDATES`, `TEXT_ADS` for LinkedIn). ### When ads_promoted Returns Empty Some promoted types have no discovery API (e.g., Amazon `PRODUCT_ID`, Meta `STORE_URL`). In those cases, `ads_promote` returns an empty array. You can still create ads by passing `promoted` manually with IDs you already know (e.g., ASINs for Amazon, store URLs for Meta). --- ## Quick Reference: Create Examples ### Meta Ads – Create Ad Group with Page ```json POST /ads/{connection_id}/group { "organization_id": "act_123", "campaign_id": "456", "name": "My Ad Group", "promoted": [{ "id": "789", "type": "PAGE_ID" }] } ``` ### Amazon Ads – Create Product Ad ```json POST /ads/{connection_id}/ad { "organization_id": "profile_123", "campaign_id": "456", "group_id": "789", "name": "Product Ad", "promoted": [{ "id": "B08N5WRWNW", "type": "PRODUCT_ID" }] } ``` ### Twitter Ads – Create Promoted Tweet ```json POST /ads/{connection_id}/ad { "organization_id": "account_123", "group_id": "line_item_456", "name": "Promoted Tweet", "promoted": [{ "id": "1234567890", "type": "TWEET_ID" }] } ``` ### LinkedIn – Create Ad Group (Campaign) ```json POST /ads/{connection_id}/group { "organization_id": "123", "campaign_id": "456", "name": "Sponsored Updates Campaign", "promoted": [{ "id": "SPONSORED_UPDATES", "type": "AD_GROUP_TYPE" }] } ``` ### Google Ads – Create Ad Group ```json POST /ads/{connection_id}/group { "organization_id": "123", "campaign_id": "456", "name": "Search Ad Group", "promoted": [{ "id": "SEARCH_STANDARD", "type": "AD_GROUP_TYPE" }] } ``` --- ## Using Targeting Targeting lets you define who sees your ads (geography, demographics, interests, etc.). Use the `ads_target` endpoint to look up valid targeting IDs, then pass them in the `targeting` field when creating campaigns, ad groups, or ads. ### The ads_target Endpoint Look up targeting options (countries, regions, cities, interests, etc.) before creating ad objects. **Endpoint:** `GET /ads/{connection_id}/target` **Query parameters:** | Parameter | Type | Required | Description | | --------- | ------ | -------- | ------------------------------------------------- | | `type` | string | Yes | Targeting type (see supported types below) | | `query` | string | Yes* | Search term (*required for most types) | | `org_id` | string | No | Organization/account ID (required for some types) | | `limit` | number | No | Max results (default 100) | | `offset` | number | No | Pagination offset | **Supported** **`type`** **values:** `countries`, `regions`, `cities`, `zips`, `us_dmas`, `locales`, `interests`, `behaviors`, `topics`, `user_lists`, `age_ranges`, `genders` _Support varies by integration. Meta Ads supports most types; others (e.g., Amazon, Twitter) may support only_ _`countries`_ _and_ _`locales`__._ ### Example: Look Up Countries ```bash GET /ads/{connection_id}/target?type=countries&query=united ``` Response: ```json [ { "id": "US", "name": "United States", "value": "US", "type": "countries" }, { "id": "GB", "name": "United Kingdom", "value": "GB", "type": "countries" } ] ``` ### Example: Look Up Regions (e.g., US States) ```bash GET /ads/{connection_id}/target?type=regions&query=california ``` Response: ```json [ { "id": "3843", "name": "California", "value": "3843", "type": "regions" } ] ``` ### Example: Look Up Interests (Meta Ads) ```bash GET /ads/{connection_id}/target?type=interests&query=technology ``` ### Using Targeting IDs When Creating Ads Pass the `id` (or `value`) from `ads_target` responses into the `targeting` object. The structure matches the targeting type: - **Countries:** `targeting.geographic.countries` — array of ISO country codes (e.g., `["US", "CA"]`) - **Regions:** `targeting.geographic.regions` — array of `{ id, name? }` (use `id` from ads_target) - **Cities:** `targeting.geographic.cities` — array of `{ id, name?, radius?, radius_unit? }` - **Demographics:** `targeting.demographic` — age and gender (no lookup needed; use values directly) --- ## Targeting Examples ### Geo-Targeting: Countries and Regions First, look up IDs: ```bash # Get country codes GET /ads/{connection_id}/target?type=countries&query=united # Get regions (e.g., states) — for regions, query often includes country context GET /ads/{connection_id}/target?type=regions&query=california ``` Then create an ad group with geographic targeting: ```json POST /ads/{connection_id}/group { "organization_id": "act_123", "campaign_id": "456", "name": "US & California Ad Group", "promoted": [{ "id": "789", "type": "PAGE_ID" }], "targeting": { "geographic": { "countries": ["US"], "regions": [ { "id": "3843", "name": "California" } ] } } } ``` ### Geo-Targeting: Cities with Radius ```json { "targeting": { "geographic": { "countries": ["US"], "cities": [ { "id": "2420379", "name": "San Francisco", "radius": 25, "radius_unit": "MILES" } ] } } } ``` ### Demographic Targeting Demographic targeting uses simple values; no `ads_target` lookup is needed: ```json { "targeting": { "demographic": { "age_min": 25, "age_max": 54, "male": true, "female": true } } } ``` - `age_min` / `age_max`: 18–65 typically - `male` / `female`: `true` to include, `false` or omit to exclude ### Combined Geo + Demographic Targeting ```json POST /ads/{connection_id}/group { "organization_id": "act_123", "campaign_id": "456", "name": "US Adults 25-54", "promoted": [{ "id": "789", "type": "PAGE_ID" }], "targeting": { "geographic": { "countries": ["US"] }, "demographic": { "age_min": 25, "age_max": 54, "male": true, "female": true } } } ``` ### Audience Targeting (Interests, Custom Audiences) For interests and custom audiences, look up IDs first: ```bash GET /ads/{connection_id}/target?type=interests&query=technology GET /ads/{connection_id}/target?type=user_lists&query=my&org_id=act_123 ``` Then use the `id` values in `targeting.audience`: ```json { "targeting": { "audience": { "interests": [ { "id": "600313926646746", "name": "Technology" } ], "custom_audiences": [ { "id": "12345678", "name": "My Custom Audience" } ] } } } ``` --- ## Creating Walmart Items via Unified URL: https://docs.unified.to/guides/creating_walmart_items_via_unified # Creating Walmart Items via Unified ------ _July 31, 2025_ Unified allows you to create structured product listings on **Walmart Marketplace** via our Unified data model `commerce_item`. This guide explains the **critical fields** you must understand and provide correctly: --- ### 🧩 Key Concepts | Field | Description | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | This specifies the **Walmart subcategory** your item belongs to. It determines what metadata is required. Must be one of the supported 76 categories (see below). | | `global_code` | This is your product's **GTIN** (Global Trade Item Number), a required identifier for Walmart. It must be a **valid 12–14 digit string**. | | `metadata` | A dynamic array of key-value attributes that depends on the selected `type`. This is how you provide structured product details like color, material, warranty, etc. | --- ### 🧠 How Metadata Works Each `type` (subcategory) has a corresponding schema of fields allowed by Walmart. For example: - `animal_accessories` allows fields like `animalType`, `petSize`, `isFoldable`, `colorCategory`, etc. - `computers` allows fields like `processorType`, `screenSize`, `ramMemory`, `operatingSystem`, etc. **List the metadata fields from Unified** using this API ```typescript GET /metadata_metadata?type=commerce_item [ { "id": "shortDescription", "name": "shortDescription", "slug": "shortDescription", "format": "TEXT", "original_format": "string", "object_type": "commerce_item" }, { "id": "minimumRecommendedAge", "name": "minimumRecommendedAge", "slug": "minimumRecommendedAge", "format": "MEASUREMENT", "original_format": "{\"measure\":\"number\",\"unit\":[\"months\",\"years\"]}", "object_type": "commerce_item" }, { "id": "smallPartsWarnings", "name": "Small Parts Warning Code", "slug": "smallPartsWarnings", "format": "MULTIPLE_SELECT", "original_format": "string[]", "options": [ "0 - No warning applicable", "5 - Choking hazard is a marble", "4 - Choking hazard balloon", "6 - Choking hazard contains a marble", "2 - Choking hazard contains small ball", "3 - Choking hazard contains small parts", "1 - Choking hazard is a small ball" ], "object_type": "commerce_item" }, ] ``` You can filter these results by inspecting the supported pagination options. --- ### Supported `type` Values for Walmart Items (commerce_item) - `Skateboard Risers` - `Baby Play Yards` - `Power Hedge Trimmers` - `Eyeglass Cases` - `Microphone Splitters` - `Electric Buffet Servers` - `Radio Control Vehicle Servos` - `Sticky Notes` - `Matcha Tea Bowls & Whisks` - `Doughs` - `Taffy Candy` - `Hair Dryers` - `Base Layers` - `Emergency Lighting` - `Bidet Toilet Seats` - `Purse Making Supplies` - `Grill Grid Lifters` - `Hunting Scents` - `Automotive Headlight Restorer` - `Ear Thermometer Replacement Lens Filters` - `Glassware & Drinkware` - `Precious Metals` - `Lab Chromatography Paper` - `Plant Terrariums` - `Microphone Cables` - `Glucose Supplements` - `Snow Skis` - `Perineal Cleansers` - `Soda Maker Kits` - `Camera Accessory Bundles` - `Tetherball Equipment` - `Paint Reducers & Retarders` - `Pork Rinds` - `Portable Basketball Systems` - `Plate Joiners` - `Other Weight Loss Supplies` - `Powder Candy` - `Outdoor Kitchen Serving & Storage Carts` - `Unitards & Leotards` - `Lab Sample Bags` - `Fishing Rod & Reel Combos` - `Adhesive Tapes` - `Track & Field Markers` - `Fruit & Vegetable Corers` - `Automotive Fuel Tank Caps` - `Drawing Boards` - `Hand Dryers` - `Pregnancy Tests` - `Cat Litter Mats` - `Facial Wipes` - `Exercise Weights` - `Sewing Pins` - `Other Makeup Brushes & Tools` - `Other Shaving Supplies` - `Sugar Packet Holders` - `Honey Dippers` - `Artificial Topiaries` - `Cupcake Making Kits` - `Mobility Walker Bags, Baskets & Carriers` - `Faucet Water Filters` - `Urinary Tract Infection Test Strips` - `Baptism Gowns & Outfits` - `Function Generators` - `FM Transmitters` - `Home Brewing Refractometers` - `Saddle Bags` - `Track & Field Competitor Numbers` - `Fuel Distributors` - `Nicotine Patches` - `Griddles & Grill Pans` - `Planter & Stand Sets` - `Other Pet Health Supplies` - `Scooter Decks` - `Knitting & Crochet Stitch Counters` - `Camera Tool Kits` - `Eyelash Combs` - `Blouses & Tops` - `Knitting & Crochet Stitch Markers` - `Hand Tool Punches` - `Climbing Rappel Devices` - `Kitchen Towels` - `Homogenizer Accessories` - `Attendance Time Cards` - `Digital Camera Parts` - `Planners & Appointment Book Refills` - `Herbal Supplements` - `Hand-Sewing Needles` - `Weight Bars` - `Photography Fixers` - `Massage Table Carrying Cases` - `Grill Covers` - `Other Skating Supplies` - `Maple Syrups` - `Pizza Stones & Pans` - `Manual Letter Openers` - `Water Guns` - `Manual Can Openers` - `Electronic Touchpads` - `String Trimmer Replacement Parts` - `Baby Bodysuits & One-Pieces` - `Walking Cane & Crutch Tips` - `Cutting Mats` - `Throwing Knives` - `Skins for Tablets` - `Serving Utensils & Sets` - `Photographic Art` - `Soap Making Kits` - `Athletic Shirts & Tops` - `Ticket Rolls` - `Fireplace Bellows` - `Travel Plug Adapters` - `Vacuum Sealers` - `Small Animal Habitat Decor` - `Vacuum Tubes` - `Stroller Connectors` - `Ceiling Fans` - `Other Dancing Supplies` - `Model Train Locomotives` - `Electronic Cigarette Starter Kits` - `Bicycle Baskets` - `Golf Visors` - `Bicycle Storage Hooks` - `Handheld Video Games` - `Cocktail Mixers` - `Follow-Focus Levers` - `Embossing Tools` - `Incentive Charts` - `Pressure Gauges` - `Art Paints & Pigment Powders` - `Replacement Water Filters` - `Moisture Meters` - `Other Pet Grooming Supplies` - `Shopping Cart & High Chair Covers` - `Nursing Pillows` - `Disposable Cutlery Sets` - `Heat Guns` - `Construction Flashing` - `Automotive Aerodynamics Kits` - `Fire Retardants` - `Gun Snakes` - `Snow Removal Rakes & Hand Pushers` - `Costume Wigs` - `Outdoor Decorative Stones` - `Lip Stains` - `Workwear Uniform Badges` - `Gut Health Tests` - `Carbon Monoxide Alarms` - `Cable Covers` - `Lathe Cylindrical Rollers` - `Hamburger Patty Makers` - `Printing Press Accessories` - `Automotive Light Covers & Guards` - `Overbed Tables` - `Artwork Cases & Portfolios` - `Disposable Storage Bags` - `Football Helmets` - `Automatic Transmission Filters` - `Live Trees` - `Umpire Leg Guards` - `Line Conditioners` - `Cup & Saucer Sets` - `Rain Barrels` - `S` - `,` - `s Tapes` - `Indoor Fireplaces` - `Headphone Cases` - `Battery Testers` - `Floor Hockey Carts` - `T-Shirts` - `Dunun Drums` - `Speed Reducers` - `Percussion Mallets` - `Pallet Covers` - `Telescope Cases` - `Automotive Grilles` - `Fencing Sport Epee Parts` - `Emergency Food Kits` - `Sports Corner Flags` - `Bird Nests & Nesting Material` - `Boxing Rings` - `Paper Sorters` - `Lollipop & Treat Sticks` - `Cabin Air Filters` - `Pool Covers` - `Video Game Dance Pads` - `Football Sleds & Chutes` - `Clay & Dough Extruders & Presses` - `Baseball Gloves & Mitts` - `Yeasts & Leaveners` - `Skins for Aerial Drones` - `Replacement Faucet Water Filters` - `Other Electronic Components & Accessories` - `Lab Coats` - `Laser Detectors` - `Baseball Sliding Shorts` - `Hunting Game Finders` - `Rain & Snow Gauges` - `Shapewear Bodysuits` - `Toiletry Kits & Bags` - `Photo Enlarger Heads` - `Athletic Outfit Sets` - `Combustion Analyzers` - `Personal Organizer Refills & Accessories` - `Harmonic Exciters` - `Billiard Table Felt` - `Video Game Console Parts` - `Ham Radio Transceivers` - `Baby Juices` - `Hot Dog Machines` - `Pipe Inspection Cameras` - `Misting Systems` - `Sleeping Bag Liners` - `Kids Gardening Tools` - `Camera Film Winders` - `Other Billiard Supplies` - `Ergonomic Backrests` - `Balloon Pumps` - `Lump Charcoals` - `Pilates Barrels` - `Surfing Leashes` - `Dart Shafts` - `Makeup Blotting Paper` - `Eye Masks` - `Welding Helmets` - `Electronic Coils` - `Energy Drinks` - `Push-Up Stands` - `Photography Stop Baths` - `Handbag Hook Hangers` - `Network Security Devices` - `Stitch Holders` - `Bicycle Pegs` - `Outdoor Daybeds` - `Party Favors` - `Canning Kits` - `Molding & Casting Materials & Kits` - `Musical Instrument Bodies` - `Other Games` - `Electrolyte Solutions` - `Fruit & Wine Crushers & Destemmers` - `Power Inverters` - `Jewelry Jump Rings & Split Rings` - `Table Tennis Paddles` - `SIM Card Backup Devices` - `Tie Pins` - `Billiard Tables` - `Archery Broadheads` - `Vehicle Windshields` - `Jock Straps` - `Pet Live Animals` - `DC-to-DC Converters` - `Transport Drums` - `Vinyl Figures` - `Entertainment Centers` - `Fireplace Tools` - `Serving Forks` - `Nut Splitters` - `Blasters & Foam Play` - `Garden Border Edging` - `Gun Holsters & Holders` - `Spice Racks & Organizers` - `Stamps & Stamp Sets` - `Udu Drums` - `Reception Chairs & Seating` - `Pepper & Peppercorns` - `Golf Drivers` - `CC Cream` - `Yoga Socks` - `Vacuum Cleaner Attachments` - `Planter Liners` - `Pie & Pastry Fillings` - `Bed Skirts` - `Dart Carrying Cases & Wallets` - `Pizza Ovens` - `Crepe Spreaders` - `Hex Tools` - `Stretch Film & Shrink Wrap` - `Massage Headrests` - `Electronics Carrying Cases` - `Dust Collectors` - `Decorative Stones` - `Yoga Straps` - `Espresso Machines` - `Lab Face Masks & Shields` - `Guitar Effects Pedals` - `Hockey Hip Pads` - `Pressure Cookers & Canners` - `Diabetic Foot Care` - `Nail Top Coat` - `Wax Molding Materials` - `Dock Guards` - `Decoupage Tools & Sets` - `Rugby Balls` - `Bra Back Adapters` - `Other Backyard Wildlife Supplies` - `Food Peelers` - `Modular Synthesizers` - `Mirror Balls` - `Cricket Wickets` - `Snatch Rigging Blocks` - `Rowing Shells` - `Staple Gun Staples` - `Golf Rangefinders` - `Waterbed Liners` - `Beer Glasses` - `KVM Switches` - `Panel Carriers` - `Sanding Frames` - `First Aid Tape` - `Hand Pruners & Loppers` - `Reusable Training Pants` - `Outdoor Spas` - `Air Hockey Pucks` - `Cat Trees` - `Surgical Gowns` - `Baseball Protective Screens` - `Artificial Plants` - `Basting Brushes` - `Archery Arrows` - `Calibration Weights` - `Laptop Computers` - `Chalkboard Chalk` - `Nasal Aspirators` - `Instant Coffee` - `Vanity Tables & Table & Bench Sets` - `Printer Drums` - `Automotive Body Paint` - `Paraffin Treatment Hand & Foot Mitts` - `Stovetop Espresso Pots` - `Robotic Lawn Mowers` - `Phone Grips` - `Hammock Chairs` - `Home Brewing Kegs & Keg Supplies` - `Dishwasher Rack Adjusters` - `Sculpting Tools & Kits` - `Mattocks & Pickaxes` - `Watch & Clock Hands` - `Umpire Bags` - `Other Lighting Accessories` - `Lab Shaker Accessories` - `Print Servers` - `Home Brewing Beer Bottle Trees` - `Football Hand Warmers` - `Spectrum Analyzers` - `Hair Perms & Texturizers` - `Lab Glassware Washers` - `Trailer Weight Distributing Systems` - `Digital Cameras` - `Drum & Pail Mixers` - `Porch Swings` - `Gun Choke Tubes` - `Pet Milk Replacers` - `Fabric Paints` - `Other Kiteboarding Accessories` - `Paper Cups` - `Bottle Pourers` - `Golf Divot Tools` - `Aquarium Cleaning Tools` - `Fishing Vest & Packs` - `Die-Cut Cartridges` - `Kiteboard Control Bars` - `Welding Tips` - `Food Tongs` - `Corn & Callus Remover Cushions` - `Cocktail Shakers` - `Inspection Mirrors` - `Fruit Snacks` - `Kayak Storage Racks` - `Cycling Vests` - `Field Hockey Goggles` - `Breathalyzer Alcohol Monitors` - `Dry Erase Markers` - `Racquet Sport Stringing Machines` - `Gun Swivels` - `Power Tool Chargers` - `Home Brewing Sanitization Kits` - `Ice Cream, Sorbet & Frozen Yogurt` - `Underwater Cameras` - `Hair Color Stain Shields` - `Roof Gutter Accessories` - `Bicycle Brake Rotors` - `Repinique Drums` - `Camera Film Backs & Holders` - `Automotive Cigarette Lighters & Plugs` - `Meal Kits` - `Lathe Cole Jaws` - `PBX Phone Systems` - `Face Serums` - `Exercise Treadmills` - `Lawn Sweepers` - `Sunglasses Replacement Lenses` - `Pipe Fitting Reducers` - `Microphone Pop Filters` - `MiniDisc Players` - `Audio/Video Cables` - `Hand Sanitizers` - `Nail Pullers` - `Traditional Salwar Kameez` - `Other Air Tool Accessories` - `Cymbal Pads` - `Automotive Winch Mounting Systems` - `Laptop Bags` - `Contour Makeup Set` - `Puzzle Cubes` - `Plant Covers` - `Empty Paint Cans` - `Tableware Knives` - `Storage Sheds` - `Window & Door Frames` - `Paper Plates` - `Calculator Accessories` - `Shaving Sets` - `Microphone Preamplifiers` - `Baby Feeding Chairs` - `Rain Ponchos` - `Aquarium Thermometers` - `Mini Helmets` - `RV Awnings` - `Lawn & Garden Sprayers` - `Car CD Changers` - `Selfie Sticks` - `Calibration Standard Rods` - `Lab Chromatography TLC Developing Tanks` - `Poultry Treats` - `Mechanical Pencil Refills` - `Dip Stands` - `Drinkware Accessories` - `Artist Manikins` - `Leathercraft Kits` - `Metalworking Spring Plungers` - `Marquee Signs` - `Craft Dyes` - `Wakesurf Boards` - `Pool Safety Supplies` - `Pedal Boats` - `Snack Crackers` - `Jewelry Cleaning Machines` - `Party Supply Sets` - `Other Decking & Fencing Hardware` - `Motorcycle Glasses` - `Art Smocks` - `Archery Nocks` - `Diving Regulators` - `Camera Flash Snoots` - `Martial Arts Weapon Stands` - `Automotive Wiper Nozzles` - `Tea Cozies` - `Stadium Seats & Cushions` - `Bear Protection` - `Flagpole Brackets & Mounts` - `Patio Umbrella Base Weights` - `Stretch Film & Shrink Wrap Dispensers` - `Punching Bag Hangers` - `Track Lighting Sets` - `Wheel Chocks & Stops` - `Itching & Rash Treatments` - `Automotive Rims` - `Tire Pressure Gauges` - `Hair Crimping Irons` - `Baker` - `s Helmets` - `Curtains & Valances` - `Aquarium Starter Kits` - `Body Muds` - `Body Piercing Retainers` - `Hockey Chest Protectors` - `Diving Boards` - `Small Engines` - `Pet Sofa & Chair Protectors` - `Tent Accessories` - `Pedal Covers` - `Appliance Covers` - `Vegetable Dips` - `Hockey Shin Pads` - `Acne Creams` - `Mobility Aid Steps` - `Quilting Frames` - `Water Heaters` - `Sound Pressure Level Meters` - `Glider Rocking Chairs` - `Punching Bags` - `Nail Ridge Filler` - `Yoga Gloves` - `Jewelry Stringing Materials` - `Dishwasher Rack Rollers` - `Other Hair Removal Supplies` - `Agility Ladders` - `Watch Winders` - `Blender Pitchers` - `Hand Tampers` - `Dehumidifier Accessories` - `Punch Bowl Sets` - `Mini Projectors` - `Drinkware Sets` - `Evaporated Milks` - `Spoon Rests` - `Juggling Toys` - `Mixed Spices & Seasonings` - `Bicycle Child Seats` - `Water Coolant Systems` - `Jai Alai Sets` - `Surveillance Camera Lenses` - `Chain Hoists` - `Snack Pudding & Gelatin` - `Hair Curling Wands` - `Spa Cover Lifts` - `Track Lighting Heads` - `Photo Enlargers` - `Photo Enlarger Lenses` - `Plant Cages` - `DJ Mixers` - `Condiment Servers` - `Electric Kettles` - `Glass Raw Materials` - `Radio Antennas` - `Bicycle Front & Rear Racks` - `Body Oils` - `ATV Tires` - `RAM Memory` - `Pet Toothpaste` - `Dressing Aid Sticks` - `Coconut Knives` - `Sports Pole Holders & Bases` - `Dock Steps` - `Bath Toy Storage` - `Martial Arts Belts` - `Currency Bands & Straps` - `Outdoor Playsets` - `Video Switchers` - `Brake System Replacement Parts & Hardware` - `Tennis Shorts` - `Facial Treatments` - `Athletic Compression Socks` - `Brake Booster Filters` - `Other Knitting & Crochet Supplies` - `Martini Glasses` - `Personal Organizers` - `pH Meters` - `Parchment Paper` - `Other Gift Wrapping Supplies` - `Leathercraft Accessories` - `Rod Hockey` - `House Numbers & Letters` - `Toaster Pastries` - `Anti-Chafing Creams` - `Sunshine Recorders` - `Makeup Sets` - `Baby Jumping Exercisers` - `Replacement Appliance Knobs` - `Hair Removal Waxing Strips` - `Toaster Ovens` - `Electric Crepe Makers` - `Kiteboard Control Lines` - `Camera Cleaning Equipment` - `Body Piercing Jewelry` - `Weatherproofing Pipe Coverings` - `Plant Misters` - `Rosin Bags` - `Plastic Cups` - `Baby Bouncer & Rocker Chairs` - `Washing Machines` - `Ground Coffee` - `Gun Mounts` - `Remote Controls` - `Medicine Dosing Containers` - `Chainsaw Chains` - `Climbing Slings` - `Basketball Storage` - `Writing Notebooks & Sketch Books` - `Thermometer Probe Covers` - `Wall Calendars` - `Power Tool Batteries` - `Corded Phones` - `Desk Lamps` - `Network-Attached Storage Servers` - `Slatwall Panels` - `Golf Push & Pull Carts` - `Media Storage Cabinets` - `Stovetop Waffle Irons` - `Pet Tie-Outs` - `Toilet Paper Holders` - `Boating Heads` - `Movie Cameras` - `Vanity Lights` - `Plaques & Signs` - `Decorative Bottles` - `Art Paint Sponges` - `Kayak Carts` - `Radar Detectors` - `Outdoor Bar Stools` - `Piggy Banks & Money Jars` - `Fog Machines` - `Food Storage Jars & Containers` - `Pet ID Tags` - `Salad Serving Utensils` - `Bath Rugs` - `Water Distillers` - `Sewing Bias Tape` - `Racquet Vibration Dampeners` - `Live Shrubs` - `Cable Tie Guns` - `Floating Shelves` - `Empty Camping Stove Fuel Bottles` - `Toy Billiards` - `Water Diverters` - `Powdered Baking Cocoa` - `Card Shufflers` - `Tool Sets` - `Paper Holders & Dispensers` - `Bed-in-a-Bag` - `Rail Planters` - `Glass Markers & Stemware Charms` - `Poison Ingestion Treatments` - `Sewing Baskets` - `Decorative Boxes` - `Play Tents` - `Oke Daiko Drums` - `One-Piece Swimsuits` - `Bicycle Stands` - `Photography Flash Heads` - `Yogurt Makers` - `Field Hockey Goalie Hand Protectors` - `Window Shades` - `Camera Battery Grips` - `Hunting Game Hoists & Gambrels` - `Sunglass Visor Clips` - `Christmas Trees` - `Edge & Corner Guards` - `Golf Carts` - `Screen Houses` - `Tent Poles` - `Fish Cleaning Tables` - `Bicycle Saddle Covers` - `Plant Seeds` - `Geographic Globes` - `Jewelry Making Chain` - `Figurines & Knick-Knacks` - `Binder Insert Strips` - `Hair Clipper Blades` - `Nutrition Drinks` - `Musical Bow Cases` - `Glue Guns` - `Breast Tape` - `Automotive Lift Supports` - `Acoustic Guitars` - `Therapeutic Light Boxes` - `Stovetop Espresso Pot Replacement Gaskets & Filters` - `Beverage Tubs` - `Lifeguard Swimsuits` - `Other Backpacking & Camping Supplies` - `Napkin Rings` - `Staple Removers` - `Gravy Boats, Stands & Sets` - `Bath Pillows` - `Easter Eggs` - `Cut Flowers` - `Traditional Abayas` - `Air Guns` - `Electric Fence Insulators` - `SIM Cards` - `Oven Mitts` - `Gun Barrels` - `Automotive Skid Plates` - `Sport Starter Pistols` - `Personal Misters` - `Telescope Eyepiece & Filter Sets` - `Cell Phone Radiation Guards` - `Snowmobile Riser Blocks` - `Football Target Nets` - `Memory Card Adapters` - `Mobility Walkers` - `Ice Buckets` - `Skins for Vape Pens` - `Electrical Ballasts` - `Bicycle Frames` - `Cheese Wax` - `Whole Bean Coffee` - `Tennis Rebounders` - `Plaster Hawks` - `Dance Shoes` - `Pizza Crusts` - `Other Fireplaces Accessories` - `Epoxy Coatings` - `Automotive Trailers` - `Gift Wrap Paper` - `Material Handling Securing Straps` - `Disposable Cameras` - `Small Animal Exercise Wheels` - `Hall Trees` - `Cash Boxes` - `Sprinkler Valves` - `Renewable Energy Controllers` - `Impact Drivers` - `Home Brewing Grain & Hop Bags` - `ATV Cargo Storage` - `Bicycle Derailleurs` - `Darkroom Squeegees` - `Lens Supports` - `Roller Stands` - `Breath Fresheners` - `Leaf & Plant Cleaner` - `Cricket Balls` - `Automotive Bumpers` - `Other Baby Safety Supplies` - `Confetti Poppers` - `Swimsuit Sets` - `Manual Pencil Sharpeners` - `Toilet Bowls` - `Art & Blueprint Tubes` - `Air Fryer Liners` - `Other Bicycle Parts` - `Gymnastics Hand Grips` - `Garlic Presses` - `GPS Software` - `Banana Hangers` - `Animal Grooming Scissors` - `Dining Tables` - `DAT Recorders` - `Decorative Bowls` - `Dustpans & Dustpan & Brush Sets` - `Racquet Sport Racquets` - `Lap Guitars` - `Construction Siding` - `Baking Cups` - `Hair Extensions` - `Starting Fluid` - `Chemical Solvents` - `Pet Training Pad Trays` - `Fishing Hook Sharpeners` - `Other Construction Heating & Cooling Supplies` - `Air Press Coffee Makers` - `Playground Merry-Go-Rounds` - `Clothes Iron Rests` - `Coffee Drippers` - `Microscope Mechanical Stages` - `Makeup Palettes` - `Fish Poachers & Bakers` - `Power Screwdrivers` - `Other Bowling Supplies` - `Hardware Bolts` - `Gymnastics Rings` - `Other Occupational Health` - `Automotive Glow Plugs` - `Gun Stocks` - `Ice Fishing Spearing Equipment` - `Light Timers` - `Plant Pots & Planters` - `Camera Rain Covers` - `Table Place Cards` - `Door Closers` - `Commercial Refrigerators` - `Home Brewing Beer Bottle Caps` - `Badge & ID Holders` - `Interdental Brushes` - `Facial Self-Tanners` - `Archery Finger Tabs` - `Hand Drills` - `Cooking Sauces & Marinades` - `Conductor` --- ## Walmart Category Metadata Field Mapping Use this table to understand what metadata fields are allowed for each `type` when creating items via Unified. Please note that these metadata fields are optional, while some categories do need specific metadata fields [file](https://s3.us-east-2.amazonaws.com/unified-article-images/creating_walmart_items_via_unified-file-0-walmart_schema_fields.md) --- ### Sample Payload ```json { "name": "Wireless Earphones with Advanced Features", "slug": "09876543210987", "global_code": "06146190200012", "type": "Exterior Automotive Accessories", "description": "Totally wireless earphones are built to revolutionize your workouts. The adjustable, secure-fit earhooks are customizable for extended comfort and stability. A reinforced design for sweat and water resistance lets you take it to the next level. Each earbud has full volume and track controls and up to 9 hours of listening time to fuel your training with powerful, balanced sound.", "media": [ { "url": "https://i5.walmartimages.com/seo/onn-Wireless-Bluetooth-on-Ear-Headphones-Black-New_07de2d93-505b-4902-8366-388a3f30e3b4.9375c5690645733ec080eef3f4895f7b.jpeg" } ], "vendor_name": "Walmart", "variants": [ { "sku": "09876543210987", "weight": 0.57, "prices": [ { "price": 249.99 } ] } ], "metadata": { "productName": "Wireless Earphones with Advanced Features", "brand": "Walmart", "keyFeatures": [ "Designed to keep up with your workouts", "Reinforced design for sweat and water resistance with adjustable, secure-fit earhooks for added comfort and stability.", "Stay connected from a distance with Bluetooth technology, these wireless earphones deliver extended range and fewer dropouts.", "Powers through long hours of training" ], "countPerPack": 1, "multipackQuantity": 1, "isProp65WarningRequired": "No", "condition": "New", "has_written_warranty": "Yes - Warranty Text", "netContent": { "productNetContentMeasure": 1, "productNetContentUnit": "Each" }, "productSecondaryImageURL": [ "https://i5.walmartimages.com/seo/onn-Wireless-Bluetooth-on-Ear-Headphones-Black-New_07de2d93-505b-4902-8366-388a3f30e3b4.9375c5690645733ec080eef3f4895f7b.jpeg" ], "assembledProductLength": { "measure": 4.33, "unit": "in" }, "assembledProductHeight": { "measure": 4.33, "unit": "in" }, "assembledProductWeight": { "measure": 0.57, "unit": "lb" }, "assembledProductWidth": { "measure": 3.15, "unit": "in" }, "prop65WarningText": "No", "manufacturer": "Walmart", "manufacturerPartNumber": "WM123456", "count": 1, "warrantyText": "1 Year", "color": "Blue", "aaiaBrandID": "WMTB", "vehicle_fitment_type": "Specific" }, "options": {} } ``` **NOTE**: If item creation **fails** or takes **too long** to process, the **`feed ID`** and any **`error details`** will be returned in the error response. ⚠️ **Important Note on Reading** **`commerce_item`** **Data via Walmart's GET and LIST APIs** When using Walmart's **GET** or **LIST** endpoints to retrieve `commerce_item` records (i.e., your catalog items), **you will only receive a subset of the full item**. This limitation is imposed by Walmart's public Marketplace APIs and is not configurable. ### What's Missing? - Fields defined in the **`Visible`** section of your original item feed (e.g., `animalLifestage`, `assembledProductWeight`, `features`, `fabricContent`, etc.) will **not be returned** in full. - Walmart truncates or omits many **category-specific structured attributes**. ### Why This Happens: Walmart's GET/LIST endpoints are designed for high-level overviews or simple listing needs, not for reconstructing detailed item payloads. They surface: - Basic identifiers (e.g., SKU, productName) - Core logistics info (e.g., price, inventory, brand) - A _limited set_ of visible attributes ## End-Users, Integrations, and Connections URL: https://docs.unified.to/guides/end_users_integrations_and_connections # End-Users, Integrations, and Connections ------ _April 18, 2024_ This guide explains how connections work at [Unified.to](https://unified.to/) and shows you different ways to create connections for your application. You'll learn about the relationship between your end-users, connections, and integrations, as well as how to get started quickly with test connections. ## Overview At Unified.to, there are three key concepts to understand: 1. **Integrations** - These are the bridges between your application and third-party platforms (a.k.a SaaS apps). These are pre-built for you! 2. **Connections** - These are the authorized links between your end-users and specific integrations 3. **End-users** - These are your application's users whose data you will access from their accounts i.e. HubSpots, Salesforce, Shopify, and so on. ![Users_connections_and_integrations_%2810%29.png](https://s3.us-east-2.amazonaws.com/unified-article-images/end_users_integrations_and_connections-0.png) ## Understanding integrations Your application communicates with a SaaS application through an integration. ### Activating an integration in the sandbox environment 1. Navigate to **Integrations** at [app.unified.to/integrations](https://app.unified.to/integrations) 2. Search for the integration you want to use 3. Click on the integration card to view its details 1. For sandbox environment integrations, you don't need to enter any real credentials here. 4. Click **Activate** All data is synthetic in the sandbox environment, meaning you don't need to sign up for any third-party accounts while you're testing the Unified.to platform. [Learn more about the sandbox environment →](https://docs.unified.to/concepts/sandbox) ### Activating an integration in production environments To activate an integration in production environments, you'll need developer credentials from the platform you're integrating with. ### Developer credentials When activating an integration, you'll be asked to provide credentials like API keys or OAuth 2.0 client IDs and secrets. These credentials should come from **your developer account** with the platform, not your end-users' accounts. Here's how it works: 1. You register your application with the platform (e.g., create a HubSpot developer account and register your app) 2. You receive developer credentials that identify your application 3. You enter these credentials when activating the integration on Unified.to 4. Your end-users will later authorize your application to access their accounts, with or without using their own credentials (depends on the type of auth flow) For example, if you're building an interview scheduling platform that needs to access your clients' Zoho CRM data: - You use **your** Zoho developer account credentials to activate the integration - Your **end-users** will authorize access to their Zoho CRM accounts when creating connections by consenting to give you access to their data ## Understanding connections Your end-users are connected to integrations through connections_._ A connection is an authorized link between one of your end-users and a specific integration. For example, if your application needs to access a user's HubSpot account, you'll need: 1. The HubSpot integration to be activated in your workspace 2. A connection between that specific user and HubSpot 3. The correct scope configuration for what data you need to access Each user needs their own connection to each integration you want to use. This ensures proper authorization and data isolation between users. ### About scopes Scopes determine what data your application can access in your end-users' accounts. You need to: 1. Configure the correct scopes in your developer account with the platform 2. Select the corresponding Unified.to scopes when setting up your integration For example, if you need to read CRM contacts, you would request the `crm_contact_read` scope. Unified.to has its own set of scopes (called Unified scopes) which have been mapped to the corresponding scopes for every single integration that needs them. When requesting scopes, you only need to pass in Unified scopes in addition to enabling those scopes in your developer account. [Learn more about configuring scopes →](https://docs.unified.to/concepts/scopes) ## How to create connections There are three main ways to create connections in Unified.to, each suited for different scenarios: ### 1. Development and testing: Create test connections in the sandbox environment This is the fastest way to get started and test integrations: 1. Switch to the **Sandbox** environment in the Unified.to app 2. Go to the **Connections** page 3. Click **Create test connection** 4. Select any activated integration you want to test Test connections are created instantly with mock credentials, perfect for development and testing. ### 2. Production ready: Use the Authorization embedded component This is the recommended approach for production-ready applications: 1. Navigate to the [**Embedded Authorization**](https://app.unified.to/embed) page 2. Configure your callback settings, permission scopes, and display options 3. Copy the provided code snippet for your preferred tool (React, Angular, Vue, vanilla JavaScript, or an API call) 4. Add the code to your application 5. The Authorization component will be rendered on your app. When users click an integration's logo, they'll be guided through the authorization process Example code for React: ```javascript import UnifiedDirectory from '@unified-api/react-directory'; function App() { return ( ); } ``` ### 3. Custom implementation: Build your own authorization flow For greater control over the user experience: 1. Fetch available integrations using the Unified API 2. Create authorization URLs for selected integrations 3. Handle authorization callbacks 4. Manage connection IDs This approach requires slightly more implementation work but offers more flexibility. You can find a [complete tutorial for this approach in our documentation](https://docs.unified.to/tutorials/customize-auth-flow). ## Best practices - **Always test in Sandbox first**: Use test connections to validate your integration logic - **Store connection IDs securely**: Never store connection IDs in client-side storage in production - **Handle authorization failures gracefully**: Provide clear feedback when connection attempts fail. Users will be redirected to the failure URL you specified during configuration. [Learn more about handling failure callbacks here](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections#handle-errors-during-connection-creation). - **Monitor connection health**: Set up [webhook notifications](https://app.unified.to/settings/workspace) for connection status changes - **Re-authorize instead of recreating**: When a connection becomes unhealthy, ask the end-user to [re-authorize the existing connection](https://docs.unified.to/concepts/embedded-components#re-authorize-an-existing-connection). It keeps the same connection ID, so you don't end up with duplicate connections or need to update stored IDs and webhooks - **Configure scopes correctly**: Request only the permissions your application needs - **Use separate environments**: Keep your development and production environments separate ## Troubleshooting Common issues and solutions: ### Connection creation fails - Verify your developer credentials are correct - Check that required scopes are configured both in your platform developer account and Unified.to - Ensure redirect URLs are properly set up in your platform developer settings ### Authorization flow issues - Confirm your integration is activated - Verify environment settings (Sandbox vs Production) - Check that scopes match between your platform developer account and Unified.to ### A working connection becomes unhealthy - Check the [API Call Logs](https://app.unified.to/logs) for the connection to find the error (usually `401` or `403`) - Fix the cause (for example, restore your app's access or correct the scopes) - [Re-authorize the existing connection](https://docs.unified.to/concepts/embedded-components#re-authorize-an-existing-connection) rather than creating a new one. It keeps the connection's current permissions unless you pass `scopes`, so include `scopes` if you corrected them For more guidance, see: [How to troubleshoot unhealthy connections](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections) ## Next steps - Try creating your first test connection in the sandbox environment - Explore our [Quickstart guide](https://docs.unified.to/quick-start) for a hands-on example - Read our detailed guide on [understanding and configuring scopes](https://docs.unified.to/concepts/scopes) ## Enriching Unified Objects with Custom Metadata URL: https://docs.unified.to/guides/enriching_unified_objects_with_custom_metadata # Enriching Unified Objects with Custom Metadata ------ _July 23, 2025_ ## Overview The metadata_metadata object is a powerful addition to the Unified data model. It enables the definition of **metadata schemas (custom fields)** that can be attached to various unified objects—such as `commerce_item`, `ats_job`, `hris_employee`, and more. This document explains its structure, how to use it effectively, and best practices for managing dynamic metadata across models. --- ## Purpose `metadata_metadata` serves as a **schema registry** for custom fields, allowing users to enrich objects with reusable, structured metadata. These definitions dictate the format and structure of metadata values that live inside the `metadata` field of supported Unified objects. --- ## Object Structure ```typescript interface IMetadataMetadata { id?: string; created_at?: (string | Date | number); updated_at?: (string | Date | number); name: string; // Required slug?: string; format?: string; // One of the supported formats original_format?: string; options?: string[]; object_type: string; // e.g. 'commerce_item', 'ats_job' objects?: { [objectType: string]: string[] }; // Map of object_type to object IDs } ``` --- ## Key Concepts ### `object_type` This specifies the **unified model** the metadata is associated with. Examples include: - `"commerce_item"` - `"ats_job"` - `"hris_employee"` - Other Unified Objects > When listing metadata_metadata entries, you can filter by **type**, which corresponds to the object_type. --- ### `format` Defines the data type for the metadata field. Supported formats include: - `'TEXT'` - `'NUMBER'` - `'DATE'` - `'BOOLEAN'` - `'FILE'` - `'TEXTAREA'` - `'SINGLE_SELECT'` - `'MULTIPLE_SELECT'` - `'MEASUREMENT'` - `'PRICE'` - `'YES_NO'` - `'CURRENCY'` - `'URL'` --- ### `objects` A mapping of object types to the specific object IDs where this metadata is currently applied. **Example:** ```json { "objects": { "commerce_item": ["item1", "item2"], "ats_job": ["job1"] } } ``` This makes it easy to track which unified records are using each metadata definition. --- ## Usage in Unified Objects Each unified object includes a `metadata` array, where each entry corresponds to a metadata definition from the `metadata_metadata` registry. ### Example: `IAtsJob` ```javascript interface IAtsMetadata { id?: string; slug?: string; value?: unknown; namespace?: string; format?: string; extra_data?: unknown; } interface IAtsJob { ... metadata?: IAtsMetadata[]; ... } ``` --- ## Workflow Example ### Step 1: Create a `metadata_metadata` Definition To begin, define the metadata schema by providing **only** the following required fields: - `name`: Human-friendly label - `slug`: Unique programmatic identifier (Picked up from name if not given) - `format`: Expected data format (e.g., `"TEXT"`, `"NUMBER"`, etc.) ```json POST /metadata_metadata { "name": "Binding Mount", "slug": "binding_mount", "format": "TEXT" } ``` --- ### Step 2: Review the Created Metadata Once the metadata is created, the full record might look like this (after automatic enrichment): ```json GET /metadata_metadata/1 { "id": "1", "name": "Binding Mount", "slug": "binding_mount", "format": "TEXT", "original_format": "single_line_text_field" } ``` --- ### Step 3: Attach Metadata to a Unified Object ```json PATCH /commerce_item/7 { "metadata": [ { "id": "1", "slug": "binding_mount", "value": "Optimistic" } ] } ``` Make sure the id / slug match the schema created in Step 1. ### Step 4: Review the Metadata Again Once the metadata is used in a Unified object, the full record might be updated to look like this: ```json GET /metadata_metadata/1 { "id": "1", "name": "Binding Mount", "slug": "binding_mount", "format": "TEXT", "original_format": "single_line_text_field", "object_type": "commerce_item", "objects": { "commerce_item": ["7"] } } ``` This shows the metadata definition has been associated with the unified object type `commerce_item` and is linked to object with ID `7`. --- ## CRUDL Support Full **Create, Read, Update, Delete, and List** operations are supported on `metadata_metadata`. This enables: - Schema-driven form builders - Reusable definitions across multiple objects - Consistent data validation and audit trails --- ## Use Cases | Use Case | Example | | --------------------------------- | -------------------------------------- | | Custom field on ATS Job | `"interview_style": "technical panel"` | | Enrich commerce item with specs | `"binding_mount": "Optimistic"` | | Add legal identifiers to employee | `"national_id": "IN123456"` | ## Enterprise-managed authorization (EMA) with a static key URL: https://docs.unified.to/guides/enterprise_managed_authorization_ema_with_a_static_key # Enterprise-managed authorization (EMA) with a static key ------ _September 22, 2026_ Use a key pair that you generate yourself to sign enterprise-managed authorization assertions. Register only the public key on the trusted issuer, with key source Static Key. Keep the private key on your backend. Unified.to verifies each assertion against that one public key. > ⚠️ Paste a PEM that starts with -----BEGIN PUBLIC KEY-----. A PKCS#1 block (-----BEGIN RSA PUBLIC KEY-----) or a private key will fail verification. There is only one registered key, so replacing it stops the previous private key immediately. ## Before you start - You are a workspace admin. - This key source is for assertions you sign. It does not use the workspace secret or a JWKS URL. - Supported algorithms: RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, and ES512. RS256 is the usual choice. HS256 is only for the Workspace secret key source. ## 1. Turn the feature on In the app, open Settings, then Enterprise Auth. Turn Enterprise-Managed Authorization on and save. ![Screenshot_2026-09-22_at_5.03.31_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/enterprise_managed_authorization_ema_with_a_static_key-0.png) On EU or AU, use api-eu.unified.to or api-au.unified.to. The audience and resource claims later in this guide must use that same host. ## 2. Generate a key pair Generate an RSA key on your backend and export the public key in SPKI form. Store `private.pem` where your signing code can read it. Do not send it to Unified.to. ```shell openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem openssl pkey -in private.pem -pubout -out public.pem ``` `public.pem` is the value you will paste as the static public key. It looks like this: ```plain text -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A... -----END PUBLIC KEY----- ``` ## 3. Add a trusted issuer On Enterprise Auth, choose 'Add trusted issuer' and set: - Topology: Brokered (ISV) - Issuer (iss): the exact issuer string you will put in the assertion, for example https://your-app.example - Key source: Static key - Static public key (PEM): the full contents of public.pem, including the BEGIN and END lines - Allowed client IDs: leave blank for a brokered issuer - Default scopes: the categories the token should receive when the assertion has no scope, for example crm.read - Just-in-time identity mapping: on, if you want Unified.to to match the assertion email to a workspace user - Enabled: on ![Screenshot_2026-09-22_at_5.13.56_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/enterprise_managed_authorization_ema_with_a_static_key-1.png) ![Screenshot_2026-09-22_at_5.12.26_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/enterprise_managed_authorization_ema_with_a_static_key-2.png) The same record over the API. Put the PEM in static_public_key as one string, with newline characters: ```shell curl -X POST https://api.unified.to/unified/enterprise-auth/trusted-issuers \ -H "Authorization: Bearer $UNIFIED_WORKSPACE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "isv_brokered", "issuer": "https://your-app.example", "key_source": "static_key", "static_public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...\n-----END PUBLIC KEY-----\n", "default_scopes": ["crm.read"], "jit_enabled": true, "enabled": true }' ``` A Direct (XAA) issuer can also use Static key. It still needs a non-empty Allowed client IDs list, and each assertion must include a client_id from that list. ## 4. Sign an assertion Sign with the private key. The header typ must be oauth-id-jag+jwt. The alg must be one of the algorithms listed above and must match the key (RS256 for the RSA key from step 2). ```javascript const fs = require('fs'); const jwt = require('jsonwebtoken'); const assertion = jwt.sign( { resource: 'https://api.unified.to', email: 'user@your-customer.com', scope: 'crm.read crm.write', jti: 'assertion-' + Date.now(), }, fs.readFileSync('private.pem'), { algorithm: 'RS256', issuer: 'https://your-app.example', audience: 'https://api.unified.to', subject: 'customer-user-1', expiresIn: '5m', header: { alg: 'RS256', typ: 'oauth-id-jag+jwt' }, } ); ``` Required claims: - iss matches the trusted issuer string exactly - aud is the API origin, such as https://api.unified.to - resource is that same origin for REST calls, or that origin plus /mcp for MCP calls. A token for one resource is rejected by the other - sub is the end user id in your app - jti is unique for each redemption. The test panel does not consume jti - scope is space-separated, such as crm.read crm.write. A comma-separated list is treated as one unknown scope and dropped - email is a user who already belongs to the workspace, when just-in-time mapping is on ## 5. Test, then exchange On the issuer row, choose Test and paste the assertion. A passing trace means the signature, issuer, audience, resource, and identity checks succeeded. Test does not issue a token. Exchange the assertion once at the token endpoint. The access_token starts with ema_. Send it as Authorization: Bearer on integration data routes. It can call only the categories in its scopes. It cannot manage the workspace or create API keys. ```shell curl -X POST https://api.unified.to/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ --data-urlencode "assertion=$ASSERTION" ``` ### What comes back The signed JWT is only the assertion. Do not put ema_ in front of it. Unified.to mints a separate opaque access token and returns it already prefixed. ```json { "access_token": "ema_…", "token_type": "Bearer", "expires_in": 3600, "scope": "crm.read crm.write" } ``` Sign a new assertion for every exchange. The same jti is rejected as a replay. The Test button checks the assertion and does not return an access token, and it does not consume jti. > ⚠️ The token endpoint requires the workspace Enterprise Auth switch to be on. Test still runs when that switch is off. A disabled switch returns issuer not trusted. ### Call the API or MCP The resource claim on the assertion chooses where this access token works. One token does not work for both. Send it as Authorization: Bearer followed by the access_token value. - REST API: set resource to https://api.unified.to (or the EU or AU host). The token is accepted only on integration data routes such as GET /storage/{connection_id}/file, and only when the scope matches the category and the method (storage.read for GET, storage.write for writes). - MCP: set resource to that same origin plus /mcp, for example https://api.unified.to/mcp. Send the bearer token to the MCP server. Add ?connection={connection_id} to run the call against one connection in the workspace. ```shell curl https://api.unified.to/storage/$CONNECTION_ID/file \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```shell curl "https://api.unified.to/mcp?connection=$CONNECTION_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }' ``` A REST token sent to MCP is refused, and an MCP token sent to the REST API returns token issued for another resource. These tokens cannot manage the workspace, create API keys, or change enterprise-auth settings. ## 6. Map the user The trusted issuer does not store a user id. Each assertion subject gets an identity row. With just-in-time mapping on, Unified.to sets the workspace user id when email matches a user in the workspace. Otherwise the row stays unmapped. Open Identities, choose Map on that subject, and enter the workspace user id. The identity row is created only after an assertion passes signature verification. ## Rotating the key Generate a new pair, then edit the trusted issuer and replace Static public key with the new public PEM. Assertions signed by the old private key fail as soon as you save. To rotate without a gap, switch the issuer to key source JWKS and publish both keys at jwks_uri. ## Enterprise-managed authorization (EMA) with a workspace secret URL: https://docs.unified.to/guides/enterprise_managed_authorization_ema_with_a_workspace_secret # Enterprise-managed authorization (EMA) with a workspace secret ------ _September 22, 2026_ Use your workspace signing secret to sign enterprise-managed authorization assertions. This is the brokered setup: your app signs a short-lived JWT, and Unified.to checks it with the workspace jwt_secret. Your customers do not configure Unified.to in their identity provider. > ⚠️ The secret shown by Copy on the API keys page is not the signing key. That value is a JWT used for webhook signatures. Assertions must be signed with jwt_secret, a separate hex string on the workspace. ## Before you start - You are a workspace admin. - Enterprise-managed authorization is for brokered issuers. Direct (XAA) issuers use a JWKS URL and a client ID allowlist, not the workspace secret. - Regenerating the workspace secret creates `jwt_secret`. It also rotates the webhook signing secret, so existing webhook signature checks must be updated. ## 1. Turn the feature on In the app, open Settings, then Enterprise Auth. Turn Enterprise-Managed Authorization on and save. ![Screenshot_2026-09-22_at_5.03.31_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/enterprise_managed_authorization_ema_with_a_workspace_secret-0.png) On EU or AU, use `api-eu.unified.to` or `api-au.unified.to`. The audience and resource claims later in this guide must use that same host. ## 2. Add a trusted issuer On Enterprise Auth, choose Add trusted issuer and set: - Topology: `Brokered (ISV)` - Issuer (`iss`): the exact issuer string you will put in the assertion, for example `https://your-app.example` - Key source: `Workspace secret` - Allowed client IDs: leave blank - Default scopes: the categories the token should receive when the assertion has no scope, for example `crm.read` - Just-in-time identity mapping: on, if you want Unified.to to match the assertion email to a workspace user - Enabled: on ![Screenshot_2026-10-01_at_11.02.09_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/enterprise_managed_authorization_ema_with_a_workspace_secret-1.png) ![Screenshot_2026-09-22_at_5.12.26_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/enterprise_managed_authorization_ema_with_a_workspace_secret-2.png) The same record over the API: ```shell curl -X POST https://api.unified.to/unified/enterprise-auth/trusted-issuers \ -H "Authorization: Bearer $UNIFIED_WORKSPACE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "isv_brokered", "issuer": "https://your-app.example", "key_source": "workspace_secret", "default_scopes": ["crm.read"], "jit_enabled": true, "enabled": true }' ``` ## 3. Sign an assertion Sign with HS256 and `jwt_secret`. The header `typ` must be `oauth-id-jag+jwt`. A plain JWT type is rejected. ```javascript const jwt = require('jsonwebtoken'); const assertion = jwt.sign( { resource: 'https://api.unified.to', email: 'user@your-customer.com', scope: 'crm.read crm.write', jti: 'assertion-' + Date.now(), }, process.env.WORKSPACE_JWT_SECRET, { algorithm: 'HS256', issuer: 'https://your-app.example', audience: 'https://api.unified.to', subject: 'customer-user-1', expiresIn: '5m', header: { alg: 'HS256', typ: 'oauth-id-jag+jwt' }, } ); ``` Required claims: - `iss` matches the trusted issuer string exactly - `aud` is the API origin, such as https://api.unified.to - resource is that same origin for REST calls, or that origin plus /mcp for MCP calls. A token for one resource is rejected by the other - sub is the end user id in your app - `jti` is unique for each redemption. The test panel does not consume jti - scope is space-separated, such as `crm.read crm.write`. A comma-separated list is treated as one unknown scope and dropped - email is a user who already belongs to the workspace, when just-in-time mapping is on ## 4. Test, then exchange On the issuer row, choose Test and paste the assertion. A passing trace means the signature, issuer, audience, resource, and identity checks succeeded. Test does not issue a token. Exchange the assertion once at the token endpoint. The access_token starts with ema_. Send it as Authorization: Bearer on integration data routes. It can call only the categories in its scopes. It cannot manage the workspace or create API keys. ```shell curl -X POST https://api.unified.to/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ --data-urlencode "assertion=$ASSERTION" ``` ### What comes back The signed JWT is only the assertion. Do not put ema_ in front of it. Unified.to mints a separate opaque access token and returns it already prefixed. ```json { "access_token": "ema_…", "token_type": "Bearer", "expires_in": 3600, "scope": "crm.read crm.write" } ``` Sign a new assertion for every exchange. The same jti is rejected as a replay. The Test button checks the assertion and does not return an access token, and it does not consume jti. > ⚠️ The token endpoint requires the workspace Enterprise Auth switch to be on. Test still runs when that switch is off. A disabled switch returns issuer not trusted. ### Call the API or MCP The resource claim on the assertion chooses where this access token works. One token does not work for both. Send it as Authorization: Bearer followed by the access_token value. - REST API: set resource to https://api.unified.to (or the EU or AU host). The token is accepted only on integration data routes such as GET /storage/{connection_id}/file, and only when the scope matches the category and the method (storage.read for GET, storage.write for writes). - MCP: set resource to that same origin plus /mcp, for example https://api.unified.to/mcp. Send the bearer token to the MCP server. Add ?connection={connection_id} to run the call against one connection in the workspace. ```shell curl https://api.unified.to/storage/$CONNECTION_ID/file \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` ```shell curl "https://api.unified.to/mcp?connection=$CONNECTION_ID" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }' ``` A REST token sent to MCP is refused, and an MCP token sent to the REST API returns token issued for another resource. These tokens cannot manage the workspace, create API keys, or change enterprise-auth settings. ## 5. Map the user The trusted issuer does not store a user id. Each assertion subject gets an identity row. With just-in-time mapping on, Unified.to sets the workspace user id when email matches a user in the workspace. Otherwise the row stays unmapped. Open Identities, choose Map on that subject, and enter the workspace user id. The identity row is created only after an assertion passes signature verification. ## Enterprise-managed authorization (EMA) with Microsoft Entra ID URL: https://docs.unified.to/guides/enterprise_managed_authorization_ema_with_microsoft_entra_id # Enterprise-managed authorization (EMA) with Microsoft Entra ID ------ _September 24, 2026_ Register a Microsoft Entra ID tenant as a trusted issuer so Entra signs the enterprise-managed authorization assertion. Unified.to checks that signature against the tenant's keys and returns an access token for the end user. Add one trusted issuer per Entra tenant. > ⚠️ Direct mode accepts an ID-JAG only. The JWT header typ must be oauth-id-jag+jwt. A normal Entra access token or ID token is rejected. Entra does not generally issue that grant yet. If you only have ordinary Entra sign-in, use the brokered setup at the end of this guide. In that setup Entra is the login directory, and your own issuer is the trusted issuer. ## Before you start - You are a workspace admin. - You can open the Entra admin center for the customer tenant. - You have the tenant (directory) id and the application (client) id of the app that will request tokens. These are different ids. - The issuer string is the Entra v2 issuer, including /v2.0. https://sts.windows.net// does not match. ## 1. Turn the feature on In the app, open Settings, then Enterprise Auth. Turn Enterprise-Managed Authorization on and save. On EU or AU, use api-eu.unified.to or api-au.unified.to. The audience and resource claims later in this guide must use that same host. The examples below use the US host. ## 2. Copy the ids from Entra In the Entra admin center, open the tenant and the app registration that will request tokens for Unified.to. - **Tenant id** — Entra ID, Overview. This is the directory id. It goes in the issuer URL. - **Application (client) id** — the requesting app. It goes in Allowed client IDs. A direct issuer with an empty list is rejected. The issuer you will register is: ```plain text https://login.microsoftonline.com//v2.0 ``` ## 3. Add a trusted issuer On Enterprise Auth, choose 'Add trusted issuer' and set: - Topology: Direct (XAA) - Issuer (iss): https://login.microsoftonline.com//v2.0 - Key source: JWKS - JWKS URL: leave blank. Unified.to loads the keys from the tenant's OpenID configuration. To set it yourself, use https://login.microsoftonline.com//discovery/v2.0/keys - Allowed client IDs: the Entra application (client) id. Required for a direct issuer. - Default scopes: the categories the token should receive when the assertion has no scope, for example crm.read - Environment: optional. Set it when tokens from this tenant must stay in one connection environment, such as Production - Just-in-time identity mapping: on, if you want Unified.to to match the assertion email to a workspace user - Enabled: on The same record over the API: ```shell curl -X POST https://api.unified.to/unified/enterprise-auth/trusted-issuers \ -H "Authorization: Bearer $UNIFIED_WORKSPACE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "direct_xaa", "issuer": "https://login.microsoftonline.com//v2.0", "key_source": "jwks", "client_ids": [""], "default_scopes": ["crm.read"], "jit_enabled": true }' ``` Repeat this for each Entra tenant. The tenant id is part of iss, so two tenants cannot share one row. ## 4. What Entra must put on the assertion Entra signs the JWT. Your app redeems that JWT. It does not re-sign it. The header typ must be oauth-id-jag+jwt. - iss matches the trusted issuer string exactly, including /v2.0 - aud is the API origin, such as https://api.unified.to - resource is that same origin for REST calls, or that origin plus /mcp for MCP calls. A token for one resource is rejected by the other - sub is the end user. Entra's oid is the stable subject - client_id is the application id in Allowed client IDs - jti is unique for each redemption. The test panel does not consume jti - scope is space-separated, such as crm.read crm.write. A comma-separated list is treated as one unknown scope and dropped - email is a user who already belongs to the workspace, when just-in-time mapping is on. Unified.to does not read preferred_username or upn ## 5. Test, then exchange On the issuer row, choose Test and paste the assertion. A passing trace means the signature, issuer, audience, resource, client id, and identity checks succeeded. Test does not issue a token. Exchange the assertion once at the token endpoint. The access_token starts with ema_. Send it as Authorization: Bearer on integration data routes. It can call only the categories in its scopes. It cannot manage the workspace, create API keys, or change enterprise-auth settings. ```shell curl -X POST https://api.unified.to/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ --data-urlencode "assertion=$ASSERTION_JWT" \ --data-urlencode "scope=crm.read" ``` ### What comes back The signed JWT is only the assertion. Do not put ema_ in front of it. Unified.to mints a separate opaque access token and returns it already prefixed. There is no refresh token. When it expires, redeem a new assertion. ```json { "access_token": "ema_…", "token_type": "Bearer", "expires_in": 3600, "scope": "crm.read" } ``` > ⚠️ The token endpoint requires the workspace Enterprise Auth switch to be on. Test still runs when that switch is off. A disabled switch returns issuer not trusted. ### Call the API or MCP The resource claim on the assertion chooses where this access token works. One token does not work for both. Send it as Authorization: Bearer followed by the access_token value. - REST API: set resource to https://api.unified.to (or the EU or AU host). The token is accepted only on integration data routes such as GET /crm/{connection_id}/contact, and only when the scope matches the category and the method. - MCP: set resource to that same origin plus /mcp, for example https://api.unified.to/mcp. Send the bearer token to the MCP server. A REST token sent to MCP is refused, and an MCP token sent to the REST API returns token issued for another resource. ## 6. Map the user The trusted issuer does not store a user id. Each assertion subject gets an identity row. With just-in-time mapping on, Unified.to sets the workspace user id when the email claim matches a user in the workspace. Otherwise open the identity and set the workspace user yourself. No match and no just-in-time mapping returns identity not provisioned. ## When Entra cannot issue an ID-JAG Sign the user in with ordinary Entra sign-in, then register your own issuer as the trusted issuer. Topology is Brokered (ISV). Key source is JWKS (or Static key, or Workspace secret). Issuer (iss) is your issuer, not the Entra tenant. Put the Entra oid in sub and the user's email in the email claim. aud, resource, and a fresh jti are the same as above. A brokered assertion may omit typ. A direct assertion may not. ```shell curl -X POST https://api.unified.to/unified/enterprise-auth/trusted-issuers \ -H "Authorization: Bearer $UNIFIED_WORKSPACE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "mode": "isv_brokered", "issuer": "https://your-issuer.example.com", "key_source": "jwks", "jwks_uri": "https://your-issuer.example.com/.well-known/jwks.json", "default_scopes": ["crm.read"], "jit_enabled": true }' ``` ## If the exchange fails - **issuer not trusted** — iss is not the v2 issuer, the row is disabled, or Enterprise Auth is off. - **direct_xaa requires typ oauth-id-jag+jwt** — Entra sent an access token or ID token, not an ID-JAG. Use the brokered setup, or wait until Entra issues that grant. - **resource mismatch** — resource is not this region's API host, or you set /mcp when the call is REST. - **client_id not allowed** — the assertion client_id is not in Allowed client IDs. - **identity not provisioned** — no email match, or just-in-time mapping is off and the subject is unmapped. - **assertion replayed** — that jti was already exchanged. Sign a new one. - **insufficient_scope** — the token's scopes do not cover that category and method. ## Extended observability — pushing API logs to ClickHouse URL: https://docs.unified.to/guides/extended_observability_pushing_api_logs_to_clickhouse # Extended observability — pushing API logs to ClickHouse ------ _September 18, 2026_ You can send API call logs from [Unified.to](https://unified.to/) directly to your own ClickHouse instance. This is the same class of logs we support for [Datadog](https://unified.to/blog/extended_observability_pushing_api_logs_to_your_datadog_instance) and [Grafana / Loki](https://unified.to/blog/extended_observability_pushing_api_logs_to_grafana_loki); you choose where they go in your workspace settings. ClickHouse is commonly used for high-volume analytics and log-style querying with SQL. Unified writes each API-call log as a row into a table you choose in your cluster, over ClickHouse's HTTP interface. ## Setting it up ### 1. Prepare a ClickHouse database and table Create a destination table in the database your credentials can write to. You choose the table name (for example `unified_api_logs` or `observability.unified_api_logs`) and enter it in workspace settings. A minimal schema that accepts Unified's inserts (replace the table name with yours): ```sql CREATE TABLE IF NOT EXISTS unified_api_logs ( `id` String, `created_at` DateTime, `connection_id` String, `workspace_id` String, `integration_type` String, `external_xref` String, `name` String, `path` String, `size` UInt32, `status` String, `error` String, `ip_address` String, `type` String, `method` String, `environment` String, `is_billable` Bool ) ENGINE = MergeTree ORDER BY (workspace_id, created_at); ``` Notes: - Use ClickHouse Cloud, self-hosted ClickHouse, or any compatible hosted service. - Grant the user permission to `INSERT` into your chosen table. - Table names may be `table` or `database.table`, using letters, numbers, and underscores. - You can add extra columns, indexes, or TTL policies for retention on your side. Unified only requires that the columns above can accept the values we send. - Prefer HTTPS for the HTTP interface URL (ClickHouse Cloud typically exposes something like `https://:8443`). ### 2. Collect connection details You will need: | Setting | Workspace field | Description | | -------- | --------------------- | ------------------------------------------------------------------------------------ | | HTTP URL | `clickhouse_url` | Base URL for the ClickHouse HTTP interface (include protocol and port when required) | | Username | `clickhouse_username` | Optional; if omitted, Unified uses `default` | | Password | `clickhouse_password` | Optional; may be empty depending on your cluster | | Table | `clickhouse_table` | Destination table (`my_logs` or `default.my_logs`) | ### 3. Enter values in workspace settings 1. Open **Workspace settings** (workspace admins only), or update the workspace via the Workspace API. 2. Under **External logging**, choose **Clickhouse**. 3. Enter the URL, username, password, and table name. In the dashboard, external logging is one destination at a time (None, Datadog, Grafana, or ClickHouse). ### 4. Confirm logs are flowing 1. Make a sample call to the Unified API (for example, list an object on a connection), or trigger a webhook delivery. 2. In ClickHouse, query **your** table, for example: ```sql SELECT created_at, type, method, path, integration_type, status, workspace_id, connection_id FROM unified_api_logs ORDER BY created_at DESC LIMIT 20; ``` You should see recent rows with route `path`, `integration_type`, `status`, and related API-call fields. ## Why ClickHouse matters for [Unified.to](https://unified.to/) users By sending your logs directly to your own ClickHouse cluster, you get immediate visibility into what's happening in your environment. This allows you to quickly debug issues, get notified about errors, and more. Currently, we support both inbound API calls and webhook calls. However, we're constantly improving our product, and we're looking forward to supporting more log types in the future. So, please send us feedback so we can better understand what you need. ## About [Unified.to](https://unified.to/) If you're not familiar with Unified API, it's a single API that you can use to read and write data to third-party integrations using our unified data model. This makes it easy to integrate with multiple systems and eliminates the need for separate API calls for each integration. We hope this new feature makes your life easier and your work more efficient! ## Related articles - [Extended Observability - Pushing API Logs to your Datadog Instance](https://unified.to/blog/extended_observability_pushing_api_logs_to_your_datadog_instance) - [Extended observability — pushing API logs to Grafana / Loki](https://unified.to/blog/extended_observability_pushing_api_logs_to_grafana_loki) ## Extended observability — pushing API logs to Grafana / Loki URL: https://docs.unified.to/guides/extended_observability_pushing_api_logs_to_grafana_loki # Extended observability — pushing API logs to Grafana / Loki ------ _April 27, 2023_ We're excited to add another option for external observability: you can send **API call logs** from [Unified.to](https://unified.to/) directly to a **Grafana Loki**–compatible endpoint. directly to a **Grafana Loki**–compatible endpoint. This is the same class of logs we already support for [Datadog](https://docs.unified.to/guides/extended_observability_pushing_api_logs_to_your_datadog_instance); you choose where they go in your [workspace settings](https://app.unified.to/settings/workspace). [**Grafana Cloud**](https://grafana.com/products/cloud/) and [**Grafana Loki**](https://grafana.com/oss/loki/) are commonly used for log aggregation and querying. Unified delivers logs using Loki's standard HTTP push API. Setting it up is easy! Here's how: - **Prepare your endpoint and credentials** - In [**Grafana Cloud**](https://grafana.com/docs/grafana-cloud/), open your stack and note the **logs / Loki** ingest base URL (no path) — often `https://logs-prod-XX.grafana.net` (your region may differ). If you use **self-hosted Loki**, use that base URL instead. - Create or copy a **logs token** (or the username + token pair your provider documents for Basic auth). **Set** **`grafana_site`** **explicitly** for EU/regional Grafana Cloud stacks or self-hosted Loki; if you leave it blank, our API falls back to a default US Grafana Cloud logs host, which may not match your region. - **Enter values in workspace settings** - Open [Workspace settings](https://app.unified.to/settings/workspace) (or use the Workspace API) and choose **Grafana** under external logging. Configure: - **Loki base URL** (`grafana_site`): Base URL only (for example [https://logs-prod-XX.grafana.net](https://logs-prod-xx.grafana.net/)) - **Authentication: Basic** (Grafana Cloud: user/instance id + token), **Bearer** (if your gateway expects a bearer token), or **Legacy** (single field — see below). - **Token / API key** (`grafana_apikey`): Your secret or legacy `user:token` string, depending on mode. - **Confirm logs are flowing** - Make a sample call to the Unified API (for example, list an object on a connection). In **Grafana Explore** (or any Loki client), run LogQL such as `{job="unified-api"}`. Log lines include JSON with the route **`path`**, `integration_type`, `status`, and other API-call fields. ## Why does Grafana / Loki matter for [Unified.to](https://unified.to/) users? By sending your logs directly to your own Loki stack or Grafana Cloud, you get immediate visibility into what's happening in your environment. This allows you to quickly debug issues, get notified about errors, and more. Currently, we support both inbound API calls and webhook calls. However, we're constantly improving our product, and we're looking forward to supporting more log types in the future. So, please [send us feedback](https://unified.to/contact) so we can better understand what you need. ## About Unified.to If you're not familiar with Unified API, it's a single API that you can use to read and write data to third-party integrations using our unified data model. This makes it easy to integrate with multiple systems and eliminates the need for separate API calls for each integration. We hope this new feature makes your life easier and your work more efficient! [Get started with Unified.to](https://app.unified.to/) ## Extended Observability - Pushing API Logs to your Datadog Instance URL: https://docs.unified.to/guides/extended_observability_pushing_api_logs_to_your_datadog_instance # Extended Observability - Pushing API Logs to your Datadog Instance ------ _April 27, 2023_ We're excited to announce the latest addition to increase observability for our customers — the ability to send API call logs within [Unified.to](https://unified.to/) directly to your Datadog instance. [Datadog](https://www.datadoghq.com/) is a cloud-based monitoring and analytics platform that provides real-time insights into the performance of IT infrastructure, applications, and logs. Setting it up is easy! Here's how: 1. Get your Datadog API key: To start sending logs to Datadog, you need to obtain your API key. Here's a [detailed article](https://docs.datadoghq.com/account_management/api-app-keys/) that walks you through the process. 2. Once you have your API key, simply paste it into your Unified.to [workspace settings](https://app.unified.to/settings/workspace). 3. Finally, make a sample API call to Unified API to confirm that logs are being sent to your Datadog instance. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/extended_observability_pushing_api_logs_to_your_datadog_instance-0.png) ## Why does Datadog matter for Unified.to users? By sending your logs directly to Datadog, you get immediate visibility into what's happening in your environment. This allows you to quickly debug issues, get notified about errors, and more. Currently, we support both inbound API calls and webhook calls. However, we're constantly improving our product, and we're looking forward to supporting more log types in the future. So, please [send us feedback](https://unified.to/contact) so we can better understand what you need. ## About Unified.to If you're not familiar with Unified API, it's a single API that you can use to read and write data to third-party integrations using our unified data model. This makes it easy to integrate with multiple systems and eliminates the need for separate API calls for each integration. We hope this new feature makes your life easier and your work more efficient! [Get started with Unified.to](https://app.unified.to/) ## Facebook Messenger Webhook Setup URL: https://docs.unified.to/guides/facebook_messenger_webhook_setup # Facebook Messenger Webhook Setup ------ _August 24, 2026_ Messenger delivers events to a single **app-level** callback URL. Configure it once in the Meta App Dashboard; each connected Page is then subscribed programmatically by this integration. ## App-level configuration (once per Meta App) 1. In the Meta App Dashboard, add the **Messenger** product. 2. Under **Messenger → Settings → Webhooks** (or **App → Webhooks**, object **Page**), set: - **Callback URL**: `https://api.unified.to/webhook/workspace/facebookmessenger?workspace_id=` - **Verify Token**: any non-empty string. The integration echoes `hub.challenge`; token equality is not enforced. 3. Subscribe the Page object to these fields: `messages`, `messaging_postbacks`, `message_reactions`. 4. Ensure the connection's OAuth scopes include `pages_messaging`, `pages_manage_metadata`, and `pages_read_engagement`. ## Routing - `messages` (non-echo) → `messaging_message` (created) **and** `messaging_event` (MESSAGE_RECEIVED) - `messaging_postbacks` / quick replies → `messaging_event` (BUTTON_CLICK) - `message_reactions` → `messaging_event` (REACTION_ADDED / REACTION_REMOVED) For creating the webhook in Unified, see [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks). ## Fireflies Integration Guide URL: https://docs.unified.to/guides/fireflies_integration_guide # Fireflies Integration Guide ------ _November 5, 2025_ # Fireflies Integration Guide Learn how to connect an end-customer's [**Fireflies.ai**](https://fireflies.ai/) account to your application using [**Unified.to**](https://unified.to/). This guide covers how to enable the integration, embed the authorization component, and verify the connection end-to-end. ## Overview The Fireflies integration allows your app's end users to authorize [Unified.to](https://unified.to/) to access their Fireflies accounts for meeting recordings and user data. Once connected, your application can access the Fireflies API securely through [Unified.to](https://unified.to/) — without your users ever leaving your app. ## Prerequisites Before getting started, ensure you have: - A [**Unified.to**](https://unified.to/) **developer account** and active API credentials - Your end-customer has a [**Fireflies.ai**](https://fireflies.ai/) **paid plan** (API access required) - Access to your app's frontend or backend to embed the Unified.to authorization component so that you can get your end-customer to authorize access to Fireflies - Optional: API Explorer or MCP Server for testing connections Please note that the Fireflies API is only available to users on a **paid Fireflies plan**. Free-tier accounts cannot authorize API access. ## Step-by-Step Setup ### 1. Enable the Fireflies Integration in [Unified.to](https://unified.to/) 1. Log in to your [Unified.to](https://unified.to/) developer dashboard. 2. Navigate to **Integrations**. 3. Locate **Fireflies** in the catalog. 4. Click **Activate Integration**. Once enabled, Fireflies becomes available for your end-customer to authorize within your own app. --- ### 2. Embed the Authorization Component Use the **Embedded Authorization (Authorize Optimization)** component from [Unified.to](https://unified.to/) to create a seamless in-app experience. 1. Add the component to your application UI where users connect integrations. 2. Preview the embedded experience - this is what your customers see when connecting Fireflies. 3. The component displays a button (e.g., 'Authorize Fireflies') and short instructions. --- ### 3. Obtain the Fireflies API Key Your end-customers/users will need their **Fireflies API key** to complete authorization. **Steps for your end user:** 1. Log in to their [**Fireflies.ai**](https://fireflies.ai/) account. 2. Go to **Settings → Integrations**. 3. Locate **API Key** and click **Copy**. 4. Return to your app's authorization screen and paste it into the field provided. --- ### 4. Authorize Access 1. In the authorization window, paste the Fireflies API key. 2. Click **Authorize**. 3. [Unified.to](https://unified.to/) will automatically: - Validate the API key - Connect to Fireflies - Run a **test connection** If successful, [Unified.to](https://unified.to/) creates a new **Connection Object** linked to that customer. --- ### 5. Verify the Connection Once the test passes, a new connection record appears in your [Unified.to](https://unified.to/) dashboard. | Field | Description | | ----------------- | ------------------------------------ | | **Connection ID** | Unique identifier for API requests | | **Integration** | [Fireflies.ai](https://fireflies.ai/) | | **Status** | Connected | | **Created At** | Timestamp of creation | Use the **Connection ID** to make authenticated API calls to Fireflies through [Unified.to](https://unified.to/). --- ### 6. Test the Connection (Optional) You can test the integration in two environments: a) Using [Unified.to](https://unified.to/) API Explorer - Open **API Explorer** in your dashboard. - Use your new Connection ID to query endpoints such as: - `/hris/{id}/employee` – list Fireflies users - `/calendar/{id}/recording` – retrieve meeting data b) Using MCP Server (for AI Agents) If your use case involves AI agents, test the connection within your **MCP Server** using the same Connection ID. --- ## Example Use Cases - Retrieve meeting recordings for transcription or analysis - Sync Fireflies user data into your CRM or analytics system - Feed Fireflies meeting data into AI summarization or tagging workflows --- ## Troubleshooting | Issue | Description | Solution | | -------------------------- | -------------------------------- | ------------------------------------------------------- | | **Authorization failed** | Invalid or expired API key | Re-copy the key from Fireflies and retry authorization. | | **Test connection failed** | API access not enabled | Ensure user is on a paid Fireflies plan. | | **No data returned** | Key valid but no accessible data | Check that the Fireflies workspace contains recordings. | *Fireflies must have existing data (e.g., recorded meetings) to return results when testing API endpoints. --- ## Next Steps - Explore the [Unified.to API Reference](https://www.notion.so/api-reference) to interact with `/hris/{id}/employee` and `/calendar/{id}/recording`. - Monitor performance and connection status under **Integration Health** in your dashboard. - Repeat these steps for other integrations or customer environments. --- ## Related Articles - [Embedding the Authorization Component](https://www.notion.so/guides/embedded-authorization) - [Testing Connections via API Explorer](https://www.notion.so/guides/testing-connections) - [Managing Customer Connections](https://www.notion.so/guides/manage-connections) --- _Last updated: November 2025_ ## Getting started with Workable URL: https://docs.unified.to/guides/getting_started_with_workable # Getting started with Workable ------ _January 11, 2024_ To add Workable to your product with [Unified.to](https://unified.to/), activate the Workable integration in your workspace and choose how customers authorize it: a Workable API token plus the account subdomain, or OAuth 2 with credentials that Workable issues to partners. Then embed the Authorization component and store the connection ID each customer creates. Workable is one of 1682 integrations on [Unified.to](https://unified.to/). This guide is for developers and product teams adding Workable ATS and HR data to their product. It covers [Unified.to's Workable integration](https://unified.to/integrations/workable) from credentials to your first API call. At the end of this guide, you will have everything you need to activate a Workable integration and begin creating customer connections in production. ## What do you need before you start? You need a [Unified.to](https://unified.to/) account. [Start a 30-day free trial](https://app.unified.to/login) and follow the onboarding steps. Workable and all pre-built integrations are available on every [Unified.to plan](https://unified.to/pricing). You also need to choose which Workable credential your customers will use. The API-token method needs nothing from Workable except a customer who is a Workable Admin. OAuth 2 needs partner credentials from Workable. This guide assumes you know [Workable](https://www.workable.com/) and basic integration concepts. You don't need to be an expert in integration development or in [Unified.to](https://unified.to/). ## Should your customers use a Workable API token or OAuth 2? Use the API token if you want to start now: each customer creates the token in their own Workable account. Use OAuth 2 once Workable has accepted you as a partner. Your customers then approve access on Workable's sign-in page, and Workable's API docs give OAuth tokens a higher rate limit. [Unified.to](https://unified.to/)'s Workable integration supports both methods. | | API token and subdomain | OAuth 2 | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Who issues the credential | Your customer, in their own Workable account (Admin users only) | Workable issues your client ID and client secret after it approves you as a partner; each customer then approves access | | What your customer provides | The token and their account subdomain | Their account subdomain, then their Workable sign-in and approval of the requested scopes | | What you enter in [Unified.to](https://unified.to/) | Nothing beyond activation | Your Workable client ID and client secret | | Workable rate limit | 10 requests per 10 seconds on account tokens. Calls that carry your Workable client ID get partner limits, and [Unified.to](https://unified.to/) sends it when you've entered one | 50 requests per 10 seconds on OAuth tokens | | Expiry | The customer picks 30 days, 90 days, 6 months, 1 year or 2 years. When it expires, the customer selects **Renew** in Workable, which keeps the same token | Access tokens last 7,200 seconds (2 hours); [Unified.to](https://unified.to/) uses the refresh token to renew them without asking the customer again | Workable facts in this table come from Workable's documentation, as of 21 October 2026. Workable counts requests per account or per OAuth application in 10-second windows, and returns HTTP 429 when a limit is exceeded. ## How does a customer generate a Workable API token? A Workable Admin generates the token from their profile icon under **Settings > Integrations > Apps**, in the **API Access Tokens** section (Workable Help Center, as of 21 October 2026). Workable has no separate API permission, so a user who isn't an Admin can't create one. 1. Select the button to generate a new token, name it and choose an expiry. 2. Select the scopes for the data your product uses. 3. Copy the token. Workable shows it only once; if it's lost, the customer generates a new one. 4. Find the account subdomain in Workable's company profile settings. It is the `` in `https://.workable.com`. The customer enters both values when they connect Workable from your app. A token reaches only the Workable account it was created in, so a customer with two Workable accounts creates two tokens and two connections. Ask customers to select the scopes that match the [Unified.to](https://unified.to/) objects you use. The Workable scope descriptions are from Workable's Help Center, as of 21 October 2026: | Workable scope | What Workable says it covers | [Unified.to](https://unified.to/) objects that use it | | -------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------ | | `r_candidates` | Candidates, activities, offers | Candidate, application, document, activity, scorecard | | `r_jobs` | Jobs, stages, members, events, accounts | Job, application status, interview, company | | `w_candidates` | Creating and changing candidates, comments, copying candidates onto jobs | Writes to candidates, applications, documents and activities | | `r_account` | Departments | HR & Directory group | | `r_employees` | Employee records (Workable flags this scope as sensitive) | HR & Directory employee | Native webhooks also need `r_candidates` (candidates) or `r_employees` (employees). If a customer can't find a scope you asked for, it isn't on Workable's token form: some scopes are only available to partner OAuth apps. When the token expires, the customer selects **Renew** in Workable. The token stays the same, so the connection keeps working. If a token is revoked instead, ask the customer to [re-authorize the same connection](https://docs.unified.to/concepts/embedded-components) with a new token. The connection keeps its ID. ## How do you get Workable OAuth 2 credentials? Workable issues OAuth 2 credentials only to official partners. Once Workable approves you, it gives you a `client_id` and `client_secret` (Workable OAuth documentation, as of 21 October 2026). There is no self-serve app registration. 1. [Apply to Workable's partnership program](https://www.workable.com/partnership-program/apply?utm_source=unified.to&utm_medium=unified.to%20blog&utm_campaign=unifedto_partner). As of 21 October 2026, Workable's 3rd Party Integration Partner tier has no mutual-customer minimum and accepts integrations built on a unified API; its Native Integration Partner tier requires at least 15 mutual customers. 2. When Workable provisions your credentials, give it the [Unified.to](https://unified.to/) redirect URI for your workspace's region. Every authorization must use exactly the redirect URI you gave Workable at provisioning, so register each region you use: - US (every plan): `https://api.unified.to/oauth/code` - EU (Pro and Scale): `https://api-eu.unified.to/oauth/code` - AU (Pro and Scale): `https://api-au.unified.to/oauth/code` 3. Ask Workable for the scopes your product needs. The **OAuth 2** tab on [Unified.to](https://unified.to/)'s Workable activation page lists the redirect URI and the Workable scopes for each [Unified.to](https://unified.to/) object. Request `w_candidates` only if you create or update candidates, applications or documents. 4. In [Unified.to](https://unified.to/), paste the client ID and client secret on the Workable activation page and select **Activate**. With OAuth 2, the customer still enters their account subdomain. Workable access tokens last 7,200 seconds, and [Unified.to](https://unified.to/) renews them with the refresh token, which Workable replaces on every refresh. If a customer revokes access in Workable, ask them to [re-authorize the same connection](https://docs.unified.to/concepts/embedded-components). For help with a Workable partnership, email [hello@unified.to](mailto:hello@unified.to) or ask on [Discord](https://discord.gg/85z7HF7JbD). ## How do you activate Workable in [Unified.to](https://unified.to/)? Sign in to [app.unified.to](https://app.unified.to/), open **Integrations**, and search for Workable. You can also find it under the **ATS** or **HR & Directory** category. ![Screenshot_2026-10-21_at_9.48.50_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/getting_started_with_workable-0.png) Select **Workable**. On the authorization page, choose the categories (ATS, HR & Directory) and the authorization methods your customers can use: API token, OAuth 2 or both. For OAuth 2, enter your Workable client ID and client secret. Then select **Activate**. ![Screenshot_2026-10-21_at_9.49.54_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/getting_started_with_workable-1.png) ![Screenshot_2026-10-21_at_9.50.22_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/getting_started_with_workable-2.png) Activation applies to the environment selected in [app.unified.to](https://app.unified.to/). Activate Workable in each environment you use, such as Sandbox and Production, with that environment's credentials. As of 19 October 2026, activated integrations can't be copied between environments. The **Feature Support** tab shows the objects and fields [Unified.to](https://unified.to/)'s Workable integration supports. The same objects, methods and fields are listed publicly on the [Workable integration page](https://docs.unified.to/integrations/workable). ![Screenshot_2026-10-21_at_9.50.48_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/getting_started_with_workable-3.png) ## What can you read and write with a Workable connection? As of 19 October 2026, [Unified.to](https://unified.to/)'s Workable integration reads candidates, applications, jobs, documents, activities, interviews, scorecards, stages and companies in ATS, and employees and departments in HR & Directory. It writes candidates, applications, documents and activities. | [Unified.to](https://unified.to/) object | Read | Write | Change events | | --------------------------------------- | -------------------------- | ---------------------------- | ---------------------------------------------- | | Candidate | List, Get | Create (needs a job), Update | Native and virtual webhooks (created, updated) | | Application | List, Get | Create, Update | Virtual webhooks | | Job | List, Get | None (read-only) | Virtual webhooks | | Document | List (by application), Get | Create, Update, Remove | None | | Activity | List (by candidate) | Create | None | | Interview | List, Get | None | None | | Scorecard | List (by candidate) | None | Virtual webhooks | | Application status (stages) | List | None | None | | Company | List | None | None | | HR & Directory employee | List, Get | None | Native webhooks | | HR & Directory group (departments) | List, Get | None | None | For Workable endpoints outside these objects, use the Passthrough API (GET, POST, PUT, DELETE). In Workable, a candidate is created on a job, and putting the same person on another job means creating the candidate again on that job, under a new Workable ID. See [Workable: creating candidates and attaching them to jobs](https://docs.unified.to/guides/workable_creating_candidates_and_attaching_them_to_jobs) before you write candidates. Assessment vendors use a separate integration, Workable Assessment (launched 6 February 2026). It is authorized with a Workable partner API key and handles assessment packages and orders. ## How do Workable change events reach your app? Candidate and employee changes can arrive as native webhooks, which [Unified.to](https://unified.to/) forwards as Workable sends them. Applications, jobs and scorecards use virtual webhooks, which check for changes at an interval you set, as often as every minute on paid plans; a check that finds nothing isn't billed. Candidates support both types. As of 20 October 2026, [Unified.to](https://unified.to/) lists created and updated events for Workable objects, not deleted events. Workable's `candidate_created` event feeds [Unified.to](https://unified.to/)'s native candidate created event, and only `candidate_moved` (a stage change) feeds native candidate updated; other candidate changes reach you through virtual webhooks. Create webhooks in [app.unified.to](https://app.unified.to/) or with the [webhooks API](https://docs.unified.to/reference/webhooks). See [virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks) for how checks and retries work. Native Workable webhooks need no setup in the customer's Workable account: [Unified.to](https://unified.to/) subscribes when you create the webhook. The connection must include `r_candidates` for candidate events or `r_employees` for employee events. Without them, webhook creation fails with "Failed to subscribe". ## How do you add Workable to your app? Add the Authorization component to your app, or build your own list of integrations with the API. **Workspace and environments.** When you create a [Unified.to](https://unified.to/) account, [Unified.to](https://unified.to/) creates a workspace for you. A workspace holds environments, such as Sandbox, staging and production, and each environment has its own active integrations and credentials. You can add environments under **Workspace Settings** on any plan ([set environments](https://docs.unified.to/guides/set_environments_for_your_unified_workspace)). **Workspace ID.** The Authorization component needs your workspace ID. Find it under **Settings > API Keys**. **Authorization component.** Open [app.unified.to/embed](https://app.unified.to/embed?tab=Authorization), configure the Authorization component and copy its snippet into your app with your workspace ID. Components are available for React, Angular, Vue, Svelte and plain JavaScript. Select the permission scopes your product needs; if you leave them blank, [Unified.to](https://unified.to/) requests every scope the integration supports. The preview shows the Workable authorization page your customers see. **Your own front end.** To build your own list, call `GET /unified/integration/workspace/{workspace_id}`, which returns all activated integrations in a workspace. Then send customers to the authorization URL from `GET /unified/integration/auth/{workspace_id}/{integration_type}`. ## What happens after a customer connects Workable? After a customer authorizes Workable, [Unified.to](https://unified.to/) redirects them to your success URL. The URL carries `id` (the connection ID), `state`, `nonce`, `type` and `sig`. Before you store anything, check `sig` against your workspace secret ([embedded components](https://docs.unified.to/concepts/embedded-components)). **Important:** pass your own user or account ID in `state`. It comes back on the success URL with the connection ID, so you can store the two together. From then on, call the API with that connection ID, for example `GET /ats/{connection_id}/candidate`. A connection is one customer's authorization of one integration. ## How do you test without a Workable account? Use the Sandbox (synthetic data) environment. Switch the environment in [app.unified.to](https://app.unified.to/) to **Sandbox (synthetic data)**, activate Workable with any credentials, and create a test connection under **Connections > Create test connection**. Calls on that connection return synthetic data and don't reach Workable ([sandbox environment](https://docs.unified.to/concepts/sandbox)). To get mock data on a single request, send the header `X-Mock: 1` with an empty `Authorization` header ([mock server](https://docs.unified.to/reference/mock)). ## Which SDKs can you use? SDKs are available for TypeScript, Python, PHP, Java, Go, C# and Ruby. [Learn more](https://docs.unified.to/reference/sdks). ## Where to go next - [Workable: creating candidates and attaching them to jobs](https://docs.unified.to/guides/workable_creating_candidates_and_attaching_them_to_jobs) - [Build a candidate sourcing or job board app](https://docs.unified.to/guides/how_to_build_a_candidate_sourcing_or_job_board_app_with_unified) - [Build a candidate assessment product](https://docs.unified.to/guides/how_to_build_a_candidate_assessment_product_with_unified) - [Workable integration page](https://docs.unified.to/integrations/workable) Have a question? Complete [this form](https://unified.to/contact) or chat with us on [Discord](https://discord.gg/85z7HF7JbD). ## Handling Delegated vs. Application Scopes in Microsoft Integrations URL: https://docs.unified.to/guides/handling_delegated_vs_application_scopes_in_microsoft_integrations # Handling Delegated vs. Application Scopes in Microsoft Integrations ------ _December 9, 2025_ Microsoft Graph offers two fundamentally different permission models — **Delegated** and **Application** scopes. Understanding how these scopes behave is important when connecting Microsoft integrations through Unified. Some Microsoft APIs only support delegated permissions, while others require strictly application-level permissions. Mixing them in a single OAuth flow will cause failures. In Unified, you can create separate Microsoft connections so you can cleanly target the scopes required for the endpoints you plan to use. This guide explains the difference between the permission types, why certain Unified endpoints require one or the other, and how to configure your Unified connections correctly. ## **1. Delegated vs. Application Permissions** ### **Delegated Permissions** Delegated permissions are used when a **signed-in Microsoft user** is present in the OAuth flow. Use delegated permissions when: - The API needs to act **on behalf of a user** - You want to access data the user normally has permission to view - The Microsoft Graph endpoint explicitly supports delegated scopes ### **Application Permissions** Application permissions are used when **no user is logged in**, and the app accesses Microsoft Graph directly as itself. Use application permissions when: - A Microsoft endpoint **does not support delegated scopes** - The API involves system-level or tenant-wide data - The OAuth token must come from the **client_credentials** flow Examples of Unified endpoints that only support application scopes: - **`uc_call`** (MS Teams call records API) - Microsoft Graph requires `CallRecords.Read.All` as an **application permission**, not delegated. --- ## **2. Why You Cannot Mix Delegated and Application Scopes** Microsoft Graph enforces strict separation between permission types: - Delegated scopes must be requested during a **user login** OAuth flow - Application scopes must be requested using **client credentials** - A single OAuth authorization cannot request both delegated and application permissions If you request an application-only permission in a user sign-in flow, the request fails. For example, `OnlineMeetings.Read.All` has no delegated form, so in a user sign-in Microsoft treats it as a scope that doesn't exist and returns `AADSTS650053`, which Microsoft's error reference describes as: "The application '{name}' asked for scope '{scope}' that doesn't exist on the resource '{resource}'." No token is issued, and the Unified connection fails to authenticate. Because of this, selecting both scope types for the same Unified connection will not work. --- ## **3. How to configure a Microsoft Teams connection** Microsoft Teams uses delegated permissions for Messaging, HRIS and Calendar, and application permissions for call records (`uc_call`). Before your end users can authorize a Microsoft Teams connection, register an app in Microsoft Azure and add the permissions for the objects you plan to use. 1. **Register the app.** Register an application in Azure and get its Client ID and Client Secret ([registration guide](https://docs.unified.to/guides/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365)). 2. **Add delegated permissions.** In the app, open **API Permissions**, click **Add a permission**, select **Microsoft Graph**, then **Delegated permissions**, and add the permissions for the Messaging, HRIS and Calendar objects you need. 3. **Add application permissions for call records.** Repeat the steps with **Application permissions** and add `CallRecords.Read.All`, `OnlineMeetingRecording.Read.All`, `OnlineMeetingTranscript.Read.All`, `CallRecordings.Read.All` and `CallTranscripts.Read.All`. Application permissions need admin consent in the customer's tenant. 4. **Create the connection.** In Unified, enter the Client ID and Client Secret and click **Activate**. **Call records use a separate connection.** Messaging, HRIS and Calendar run on a connection your end user authorizes by signing in. For call records, Unified's Microsoft Teams integration also supports an app-only (organization-level) connection created with your app's Client ID, Client Secret and the customer's Tenant ID (as of 8 October 2026), after a tenant admin grants consent for the application permissions above. **Use your own Azure app in production.** Unified's own credentials are for testing; in production, the consent screen your customers see should be your app's. The full steps, with screenshots, are in [How to create a Connection in Microsoft Teams](https://docs.unified.to/guides/how_to_create_a_connection_in_microsoft_teams). **Bot-based messaging.** Microsoft Teams (bot) is a separate Unified integration for channels, messages and message events. It uses delegated permissions only, and the bot has to be packaged and installed in the right team, channel or chat before API calls work; see [How to Set Up a Microsoft Teams Bot with Unified](https://docs.unified.to/guides/how_to_set_up_a_microsoft_teams_bot_with_unified). ## HiBob & Unified: connection and time off guide URL: https://docs.unified.to/guides/hibob_and_unified_connection_and_time_off_guide # HiBob & Unified: connection and time off guide ------ _April 22, 2026_ This guide explains how to connect **HiBob** to **Unified** for HRIS use cases, which credentials to use, and how **time off**works in our implementation, including: - `hris_employee` time off fields - the `hris_timeoff` object **Official HiBob API reference:** [https://apidocs.hibob.com/docs](https://apidocs.hibob.com/docs) --- ## Creating credentials in HiBob 1. In HiBob, go to **Settings → Integrations → Automation → Service Users** 2. Create or select a **service user** intended for API access 3. Copy the **Service User ID** 4. Generate or copy the **Service User Token** 5. Paste: - the **ID** into the first credential field in Unified - the **Token** into the second credential field in Unified **Note:** Store the token securely and rotate it if exposed. --- ## hris_timeoff – Required Permissions Before accessing time off data, ensure the **service user has the correct permissions**. ### Visibility Rules Time off policies can be: - Public - Public with custom name - Private - Private requests are only visible if permission is granted - Responses include a `visibility` field: - `Public` - `Private` - `Custom name` ### Required Permissions ### Get Requests - **People's Data → Time off → See who's out today → See who's out** ### Private Requests - **People's Data → Time off → See who's out today → See who's out because of a private policy or policies with a custom name** - Some endpoints require: - `includePrivate: true` ### Pending Requests - **People's Data → Time off → Requests → Create, edit all fields, and cancel people's requests that haven't been approved yet** - Some endpoints require: - `includePending: true` ### Get Request by ID - **People's Data → Time off → Requests → Create, edit, and cancel people's requests that haven't been approved yet** ### Request Attachments - **People's Data → Time off → Requests → Upload, edit and view attachments in people's requests** ### Policies & Policy Types - **Features → Time off → Settings → Manage company's time off settings** ### Create Request Requires BOTH: - **Features → Time off → Settings → Manage company's time off settings** - **People's Data → Time off → Requests → Create, edit, and cancel people's requests that haven't been approved yet** ### Time Off Balance - **People's Data → Time off → Balance → See selected people's time off and sick leave balances** - **People's Data → Time off → Balance → Adjust selected people's time off balances and bank their overtime** --- ## hris_employee – Required Permissions To access time off balances and policies: ### Time Off Balance - **People's Data → Time off → Balance → See selected people's time off and sick leave balances** - **People's Data → Time off → Balance → Adjust selected people's time off balances and bank their overtime** ### Policies & Policy Types - **Features → Time off → Settings → Manage company's time off settings** ## How long are logs retained URL: https://docs.unified.to/guides/how_long_are_logs_retained # How long are logs retained ------ _April 2, 2024_ [Unified.to](https://unified.to/) keeps two types of logs. **API call logs** record every API request, including the IP address, timestamp, HTTP method, path, connection ID and any errors. They never include customer data or payloads. **Operating logs** record system events and are retained for 15 days. API call log retention depends on your plan: | Plan | API call log retention | | ---------- | ---------------------- | | Test | 30 days | | Grow | 60 days | | Pro | 60 days | | Scale | 365 days | | Enterprise | Custom | ### Sending logs to your own tools On the Test, Pro and Scale plans, you can send API call logs to Datadog, Grafana or ClickHouse for custom retention. Configure this in the [Unified.to](https://unified.to/) dashboard under **Settings > Workspace > External Logging**. See: - [Pushing API logs to your Datadog instance](https://docs.unified.to/guides/extended_observability_pushing_api_logs_to_your_datadog_instance) - [Pushing API logs to Grafana Loki](https://docs.unified.to/guides/extended_observability_pushing_api_logs_to_grafana_loki) ### Backing up your API call logs To retain a record of all of your API calls, we suggest that you make a backup copy of your API call log on a regular basis. You can do this by using our [Unified.to Admin API](https://docs.unified.to/unified/apicall/Returns_API_Calls). For example, if you use our Node SDK, you could perform a backup using the `listUnifiedApicalls()` method: ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const sdk = new UnifiedTo({ security: { jwt: '', }, }); const results = await sdk.unified.listUnifiedApicalls({ }); ``` ## How to access employees and users URL: https://docs.unified.to/guides/how_to_access_employees_and_users # How to access employees and users ------ _June 25, 2024_ How do you get information on a customer account's users and employees? [Unified.to](https://unified.to/) returns a customer account's users and employees through the HR & Directory API's Employee endpoints, on HR integrations and on many CRM, ticketing, accounting and collaboration integrations that list employees, such as HubSpot, Zendesk, QuickBooks and Microsoft Teams. This is achieved using the HR [Get Employee](https://docs.unified.to/hris/employee/Retrieve_an_employee) and [List Employees](https://docs.unified.to/hris/employee/List_all_employees) API endpoints. ```javascript GET /hris/{connection_id}/employee GET /hris/{connection_id}/employee/{id} ``` The Employee endpoints are available on most of [Unified.to](https://unified.to/)'s 698 [HR & Directory integrations](https://docs.unified.to/hris/integrations). ### Employee/User IDs in all unified data models Here is a list of different objects across all our unified API categories that link back to an HR Employee via their IDs. Note: We use the terms 'employee' and 'user' interchangeably. | Category | Data Model | Field | Description | | ----------- | ---------------------------------------------------------- | --------------------------- | --------------------------------------------------------------- | | ATS | [Activity](https://docs.unified.to/ats/activity/model) | user_ids | The recruiters associated with this activity | | ATS | [Candidate](https://docs.unified.to/ats/candidate/model) | user_id | The recruiter or hiring manager associated with this candidate | | ATS | [Document](https://docs.unified.to/ats/document/model) | user_id | The user who created this document | | ATS | [Interview](https://docs.unified.to/ats/interview/model) | user_ids | The employees that were in this interview | | ATS | [Job](https://docs.unified.to/ats/job/model) | recruiter_ids | | | ATS | [Job](https://docs.unified.to/ats/job/model) | hiring_manager_ids | | | ATS | [Job](https://docs.unified.to/ats/job/model) | user_id | The job owner | | ATS | [Scorecard](https://docs.unified.to/ats/scorecard/model) | interviewer_id | | | ATS | [Company](https://docs.unified.to/ats/company/model) | recruiter_ids | | | Call Center | [Call](https://docs.unified.to/uc/call/model) | user_id | The agent | | CRM | [Company](https://docs.unified.to/crm/company/model) | user_id | The salesperson that is responsible for this company/account | | CRM | [Contact](https://docs.unified.to/crm/contact/model) | user_id | The salesperson that is responsible for this contact | | CRM | [Deal](https://docs.unified.to/crm/deal/model) | user_id | The salesperson that is responsible for this deal/opportunity | | CRM | [Event](https://docs.unified.to/crm/event/model) | user_id | | | CRM | [Lead](https://docs.unified.to/crm/lead/model) | user_id | The salesperson that is responsible for this lead | | CRM | [Lead](https://docs.unified.to/crm/lead/model) | creator_user_id | The user that created this lead | | HR | [Employee](https://docs.unified.to/hris/employee/model) | manager_id | This user's reporting manager | | HR | [Group](https://docs.unified.to/hris/group/model) | user_ids | The group's members | | HR | [Group](https://docs.unified.to/hris/group/model) | manager_ids | The group's managers | | HR | [Payslip](https://docs.unified.to/hris/payslip/model) | user_id | The employee that this pay slip pertains to | | HR | [Timeoff](https://docs.unified.to/hris/timeoff/model) | user_id | The employee that this time off request pertains to | | HR | [Timeoff](https://docs.unified.to/hris/timeoff/model) | approver_user_id | The employee that approved this time off request | | KMS | [Space](https://docs.unified.to/kms/space/model) | user_id | The creator of this space | | KMS | [Page](https://docs.unified.to/kms/page/model) | user_id | The creator of this page | | Messaging | [Message](https://docs.unified.to/messaging/message/model) | author_member.user_id | | | Messaging | [Message](https://docs.unified.to/messaging/message/model) | destination_members.user_id | | | Messaging | [Message](https://docs.unified.to/messaging/message/model) | hidden_members.user_id | | | Messaging | [Message](https://docs.unified.to/messaging/message/model) | mentioned_members.user_id | | | Storage | [File](https://docs.unified.to/storage/file/model) | user_id | The owner of this file/folder | | Tasks | [Project](https://docs.unified.to/task/project/model) | user_ids | The users that belong to this project | | Tasks | [Task](https://docs.unified.to/task/task/model) | assigned_user_ids | | | Tasks | [Task](https://docs.unified.to/task/task/model) | creator_user_id | | | Tasks | [Task](https://docs.unified.to/task/task/model) | follower_user_ids | | | Ticketing | [Note](https://docs.unified.to/ticketing/note/model) | user_id | The user who created this note | | Ticketing | [Ticket](https://docs.unified.to/ticketing/ticket/model) | user_id | The user who created this ticket | Some uses of the Employee endpoints outside HR: **Onboarding:** read new employees from the HR system and add them to your task-management or training product, so new hires have access from their first day. **Assigning Leads and Deals:** Assign newly created leads and/or deals to a specific sales-rep using their user ID **Notify Hiring Managers:** When adding a new candidate and their job application, notify the hiring manager by retrieving their information and emailing them By integrating employee data across multiple SaaS platforms, organizations can improve operational efficiency, enhance data accuracy, and provide a better employee experience. ## Resources - - [Overview of our unified HR API documentation ](https://docs.unified.to/hris/overview) - [Full list of supported HR integrations ](https://unified.to/hris) ## How to add API support for the Create Activity in Crelate URL: https://docs.unified.to/guides/how_to_add_api_support_for_the_create_activity_in_crelate # How to add API support for the Create Activity in Crelate ------ _September 25, 2025_ To create activities in Crelate through [Unified.to](https://unified.to/)'s ATS Activity endpoint, each Crelate activity type you write must be available on the records you attach it to. A Crelate administrator sets this in each activity type's Available On setting. To set it: 1. Select Settings, then Activities under Basic Setup. 2. Select the specific Activity Type you want to configure 3. In Available On, choose where the activity type can be used: Contacts, Companies or Opportunities (as of 21 October 2026). Candidates are Crelate contacts. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_add_api_support_for_the_create_activity_in_crelate-0.png) The settings shown in the image above will enable the maximum number of API features. Only Crelate administrators can change activity types. To connect Crelate, you need a Crelate API key: see [How to get your Crelate API key](https://docs.unified.to/guides/how_to_get_your_crelate_api_key_step_by_step_guide). For the activity fields [Unified.to](https://unified.to/) writes, see the [Crelate integration page](https://docs.unified.to/integrations/crelate). ## How to Associate a Connection ID with Your End-User URL: https://docs.unified.to/guides/how_to_associate_a_connection_id_with_your_end_user # How to Associate a Connection ID with Your End-User ------ _April 4, 2024_ This guide explains how to associate connections you create through Unified.to with end-users in your own application. When your end-users authorize access to third-party applications through Unified.to, you need a way to keep track of which connections belong to which users in your application. This guide covers two ways to accomplish this, with recommendations on when to use each approach. If you prefer to learn through video, check out our guide on YouTube: ## Before you begin This guide assumes you have: - A basic understanding of [end-users, integrations, and connections](https://unified.to/blog/end_users_integrations_and_connections) - Familiarity with OAuth 2.0 authorization flows ## Method 1: Use the state parameter (recommended) The state parameter provides a secure way to maintain context throughout the OAuth flow by passing data between your authorization request and the callback. The state parameter _does not_ change during authorization. Whatever value you put into the state parameter in the authorization URL is the same value that will be extracted from the success URL. 1. Create a state object with your user's ID, passing in additional security measures like a hashing signature. For example: ```javascript const stateObject = { user_id: "xyz789", nonce: generateRandomString(), // Add randomness for security timestamp: Date.now(), // Optional: Add timestamp for expiry checking sig: generateSignature() // Optional: Add signature for verification }; ``` 2. Encode the state object as a base64-string: ```javascript const encodedState = Buffer.from(JSON.stringify(stateObject)).toString('base64'); ``` 3. Add the encoded state to your authorization URL ```javascript const authUrl = `https://api.unified.to/unified/integration/auth/${workspace_id}/${integration}?state=${encodedState}`; ``` 4. Handle the callback after successful authorization. The connection ID is included in the URL as `id`. For example: ```javascript // In your callback handler function handleCallback(req, res) { // Get connection ID from the callback URL const connectionId = req.query.id; // Decode and verify the state const decodedState = JSON.parse(Buffer.from(req.query.state, 'base64').toString()); // Verify the state is valid (check signature, nonce, timestamp if used) if (verifyState(decodedState)) { // Associate the connection ID with the user ID in your database await saveUserConnection(decodedState.user_id, connectionId); } } ``` ## Method 2: Use the external ID parameter This method uses the connection's built-in **External ID** field (`external_xref`) to store your user association on the connection itself. 1. Add the `external_xref` parameter to your authorization URL ```javascript const authUrl = `https://api.unified.to/unified/integration/auth/${workspace_id}/${integration}?external_xref=${user_id}`; ``` 2. Once a connection is created, that external ID is stored on the connection as `external_xref`. You can now query connections using the external ID: ```javascript const options = { method: 'GET', url: 'https://api.unified.to/unified/connection', headers: { 'authorization': `Bearer ${unified_api_key}` }, params: { external_xref: user_id } }; ``` ## Keeping the association when a connection is re-authorized If a connection becomes unhealthy (for example, the end-user revoked access or their credentials expired), [re-authorize the existing connection](https://docs.unified.to/concepts/embedded-components#re-authorize-an-existing-connection) instead of creating a new one. Re-authorizing keeps the same connection ID, so the association you saved with either method stays valid: - The success URL returns the **same** `id`, and your `state` value is returned as usual. - The connection's `external_xref` is kept. If you include `external_xref` on the re-authorization URL, it replaces the stored value, so leave it off unless you intend to change it. ## Best practices for implementation 1. **Always validate the state parameter** - Check signatures if used - Verify nonces haven't been reused 2. **Securely store connection associations** - Use a database to map user IDs to connection IDs - Consider encrypting sensitive data 3. **Handle error cases** - Missing parameters - Failed authorization attempts 4. **Re-authorize instead of recreating** - Re-authorizing an unhealthy connection keeps its ID, so you don't need to update your stored association ## How to build a Candidate Assessment product with URL: https://docs.unified.to/guides/how_to_build_a_candidate_assessment_product_with # How to build a Candidate Assessment product with ------ _October 23, 2023_ There are two ways to build a candidate assessment product on your customers' ATS. In the first, your product reads applications through [Unified.to](https://unified.to/)'s ATS API, starts an assessment when an application reaches a status you choose, and writes the result back to the candidate record. It needs no ATS partnership and works on any ATS integration that returns applications. In the second, your packages are listed inside the ATS's own assessment module and recruiters order them there; [Unified.to](https://unified.to/)'s Assessment API handles that route on ATSes with an assessment module (see [How to Offer Assessment Packages and Receive Orders](https://unified.to/blog/how_to_offer_assessment_packages_and_receive_orders_with_unified_assessment_api)). This guide covers the first route. The same data can feed AI screening or semantic resume matching; see the RAG section at the end. ## Who is this guide for? It's for teams with a candidate assessment product (skills tests, background checks, structured interviews) who need to read new applications from their customers' ATS and return results to it. - Read new applications from your customers' ATS (applicant tracking system) so your product can assess those candidates. - Retrieve resumes and job descriptions to power RAG-based candidate ranking and semantic matching. [Unified.to](https://unified.to/)'s ATS API covers 164 ATS integrations, including [Greenhouse](https://unified.to/integrations/greenhouse), [Lever](https://unified.to/integrations/lever), [SmartRecruiters](https://unified.to/integrations/smartrecruiters) and [SAP SuccessFactors](https://unified.to/integrations/successfactors) ([full list](https://unified.to/ats)). ## How to add ATS integrations to your product Before we start, be sure to first read: [Getting Started with Unified.to](https://unified.to/blog/start_here_getting_started_with_unified) 1. **Get an initial list of applications** 1. When the customer authorizes an ATS connection, [read the initial set of applications for a job](https://docs.unified.to/ats/application/List_all_applications) (filtering by `job_id` and `updated_gte` where the integration supports them) ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; import { AtsApplication, AtsApplicationStatus } from '@unified-api/typescript-sdk/sdk/models/shared'; const sdk = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY!, }, }); export async function getApplications(connectionId: string, trigger_status: AtsApplicationStatus, jobId: string, updatedGte?: string) { const applications: AtsApplication[] = []; const limit = 100; let offset = 0; while (true) { // listAtsApplications returns the array of applications directly const apps = await sdk.ats.listAtsApplications({ updatedGte, // ISO-8601 string, e.g. '2026-10-01T00:00:00Z' jobId, offset, limit, connectionId, }); applications.push(...apps.filter((application) => application.jobId === jobId && application.status === trigger_status)); // job_id is honoured server-side on some integrations only if (apps.length === limit) { offset += limit; } else { break; } } return applications; } ``` 2. **Set up to get updated applications** 1. To receive new and updated applications, [create a webhook](https://docs.unified.to/unified/webhook/Create_webhook_subscription) for that `connection_id`. Application webhooks are native or virtual depending on the integration; virtual webhooks check at an interval you set, as often as every minute on paid plans, and a check that finds nothing isn't billed. Check the integration page for which applies. ```typescript import { createHmac, timingSafeEqual } from 'crypto'; import { UnifiedTo } from '@unified-api/typescript-sdk'; import { AtsApplicationStatus, Webhook, WebhookEvent, WebhookObjectType, WebhookType } from '@unified-api/typescript-sdk/sdk/models/shared'; const sdk = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY!, }, }); // The payload arrives as JSON with snake_case field names interface IncomingWebhook { id: string; created_at: string; updated_at: string; workspace_id: string; connection_id: string; hook_url: string; object_type: string; interval: number; checked_at: string; integration_type: string; environment: string; event: 'created' | 'updated' | 'deleted'; runs: string[]; fields: string; webhook_type: WebhookType; is_healthy: boolean; page_max_limit: number; } interface IncomingApplication { id: string; candidate_id?: string; job_id?: string; status?: AtsApplicationStatus; original_status?: string; updated_at?: string; } interface IWebhookData { data: T[]; // The data array contains objects of the webhook's object type (eg. ATS applications) webhook: IncomingWebhook; // The webhook object nonce: string; // random string sig256: string; // base64(HMAC-SHA256(workspace.secret, JSON.stringify(data) + nonce)) external_xref?: string; // the external_xref you set on the connection type: 'INITIAL-PARTIAL' | 'INITIAL-COMPLETE' | 'VIRTUAL' | 'NATIVE'; } export async function createApplicationsWebhook(connectionId: string, myWebhookUrl: string): Promise { // createUnifiedWebhook returns the Webhook object directly return await sdk.unified.createUnifiedWebhook({ webhook: { hookUrl: myWebhookUrl, objectType: WebhookObjectType.AtsApplication, event: WebhookEvent.Updated, connectionId, }, }); } export async function handleUnifiedWebhook(incoming: IWebhookData, trigger_status: AtsApplicationStatus) { if (incoming.webhook.object_type !== 'ats_application') { return; // not for us } const secret = process.env.WORKSPACE_SECRET; if (!secret) { throw new Error('WORKSPACE_SECRET is not set'); } const computed = Buffer.from( createHmac('sha256', secret) .update(JSON.stringify(incoming.data)) // must match the order and encoding received .update(String(incoming.nonce)) .digest('base64'), 'utf8', ); const provided = Buffer.from(incoming.sig256 ?? '', 'utf8'); if (computed.length !== provided.length || !timingSafeEqual(computed, provided)) { return; // signature mismatch: reject the payload } return incoming.data?.filter((application) => application.status === trigger_status); } ``` 2. Alternatively, create a polling schedule to [get new/updated applications](https://docs.unified.to/ats/application/List_all_applications), which would be similar to the code in 1a) 3. **Filter applications on a specific status** 1. The [application](https://docs.unified.to/ats/application/model) carries a standard `status` (with the vendor's own label in `original_status`), so choose the status that should start the assessment. 2. Application.Status options are `NEW` `REVIEWING` `SCREENING` `SUBMITTED` `FIRST_INTERVIEW` `SECOND_INTERVIEW` `THIRD_INTERVIEW` `BACKGROUND_CHECK` `OFFERED` `ACCEPTED` `HIRED` `REJECTED` `DECLINED` `WITHDRAWN`. Of these, `REVIEWING`, `SCREENING`, `FIRST_INTERVIEW`, `SECOND_INTERVIEW`, or `BACKGROUND_CHECK` should be considered. 4. **Email the candidate with the assessment based on the job** 1. [Read the candidate](https://docs.unified.to/ats/candidate/Retrieve_a_candidate) from the `Application.candidate_id` field ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const sdk = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY!, }, }); export async function readCandidate(connectionId: string, candidateId: string) { return await sdk.ats.getAtsCandidate({ id: candidateId, connectionId, }); } ``` 2. Optional: [read the job](https://docs.unified.to/ats/job/Retrieve_a_job) from the `Application.job_id` field ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const sdk = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY!, }, }); export async function readJob(connectionId: string, jobId: string) { return await sdk.ats.getAtsJob({ id: jobId, connectionId, }); } ``` 3. Determine which test to send to the candidate based on the `Job.id`, `Job.description` or `Job.name` fields 5. **Once the candidate has completed the assessment/test, notify the hiring manager and/or recruiter** 1. [Read the employee](https://docs.unified.to/hris/employee/Retrieve_an_employee) from the `Job.hiring_manager_ids` or `Job.recruiter_ids` fields, on integrations that support the HR employee object on the same connection (Greenhouse and Lever do), and email them from `Employee.emails` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const sdk = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY!, }, }); export async function readEmployee(connectionId: string, employeeId: string) { return await sdk.hris.getHrisEmployee({ id: employeeId, connectionId, }); } ``` 2. Optional: [write a PDF report](https://docs.unified.to/ats/document/Create_a_document) to the application or candidate in the customer's ATS, on integrations that support document creation (Greenhouse and Workable attach to the application; Lever to the candidate) ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; import { AtsDocumentType } from '@unified-api/typescript-sdk/sdk/models/shared'; const sdk = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY!, }, }); // Greenhouse and Workable attach documents by applicationId; Lever, Ashby and SmartRecruiters by candidateId export async function createDocument(connectionId: string, target: { applicationId?: string; candidateId?: string }, documentUrl: string, filename: string, type: AtsDocumentType) { return await sdk.ats.createAtsDocument({ atsDocument: { ...target, type, documentUrl, filename, }, connectionId, }); } ``` 3. Optional: [create an activity](https://docs.unified.to/ats/activity/Create_an_activity) (a note on the candidate or application) on integrations that support it, such as Greenhouse, Lever, Workable and Ashby. Where the integration accepts scorecard creation (SmartRecruiters, Ashby, Workday and SAP SuccessFactors among them; not Greenhouse or Lever, as of 19 October 2026), you can create a scorecard instead. 4. Optional: [update the candidate](https://docs.unified.to/ats/candidate/Update_a_candidate)'s `tags` on integrations where tags are writable (Greenhouse and Lever are two). Read the candidate's current `tags`, add yours, and send the full list, so no existing tags are lost. ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const sdk = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY!, }, }); export async function updateCandidateTags(connectionId: string, candidateId: string, newTags: string[]) { // read the current tags and send the merged list, so no existing tags are lost const candidate = await sdk.ats.getAtsCandidate({ id: candidateId, connectionId, }); return await sdk.ats.updateAtsCandidate({ atsCandidate: { tags: [...new Set([...(candidate.tags ?? []), ...newTags])], }, id: candidateId, connectionId, }); } ``` Write-back targets vary by ATS (as of 19 October 2026): activities (notes, created on 55 integrations) and documents (48) are the widest options, application status is writable on 33 of 125 application integrations, and scorecards can be created on the fewest (12, not Greenhouse or Lever). Check the integration page before you design around one. ### Advanced: Adding RAG-Based Resume Matching Beyond triggering assessments, you can use the same data for retrieval-augmented generation (RAG): 1. Subscribe to application created and updated webhooks so new resumes are embedded as they arrive. 2. Retrieve the candidate's resume (an `ats_document` with `type` `RESUME`) and the job's `description`. 3. Chunk and embed both documents. 4. Store embeddings in your vector database with identifiers like `connection_id`, `candidate_id`, `job_id` and `updated_at`. 5. Retrieve the most relevant resume segments against the job description before generating screening insights or ranking scores. [Unified.to](https://unified.to/) reads the ATS data at request time and returns it in one schema across ATS integrations, with no customer records at rest; embeddings and retrieval stay in your infrastructure. ## Keep learning - [How to add a test connection](https://unified.to/blog/start_here_how_to_add_a_test_connection) - [How your customers add integrations from your application](https://docs.unified.to/quick-start) - [How to generate OAuth 2 credentials](https://unified.to/blog/start_here_how_to_generate_oauth2_credentials) ## How do you start? Connect a test ATS, list its applications and register an application webhook; the steps above are the whole integration. [Start a 30-day free trial](https://app.unified.to/login) or [book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified). ## How to build a candidate sourcing or job board app with Unified.to URL: https://docs.unified.to/guides/how_to_build_a_candidate_sourcing_or_job_board_app_with_unified # How to build a candidate sourcing or job board app with Unified.to ------ _January 4, 2024_ To build a candidate sourcing or job board app with [Unified.to](https://unified.to/)'s ATS API, read your customer's open jobs, then send candidates to the job's public page or create the candidate and application in the ATS, and attach their resume. ## Why build with a unified API? API integrations are necessary for any modern SaaS app, especially for recruitment solutions like candidate sourcing and job boards that require a high volume of integrations to improve product value and support a broader user base. Instead of building each ATS integration separately, developers can use a unified API to launch many at once. Unified APIs simplify integration efforts by offering consistent endpoints and data formats that reduce the complexity of managing numerous APIs. Unified APIs accelerate development cycles, enabling quicker deployment of new features and product updates for your recruitment software. We built Unified.to to make integration development as easy as possible for SaaS developers. ## Prerequisites Before starting, be sure to review our [Getting Started with Unified](https://unified.to/blog/start_here_getting_started_with_unified) article. It will walk you through registering for a free account and our onboarding. Once you have completed the onboarding steps, go ahead and as you like. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_build_a_candidate_sourcing_or_job_board_app_with_unified-0.png) Next, deploy the [Unified.to](https://unified.to/) Embedded Authorization widget in your product's user-interface. Check out our to learn more. ## Overview These are main steps that you will need to support to build your candidate sourcing or job board app: 1. read active jobs to search relevant candidates for 2. apply a candidate to a job Let's go into more detail for each. ## Step 1: Read Jobs Before searching or finding relevant candidates, you'll need to know which jobs your customer wants you to search for. [Unified.to](https://unified.to/) reads jobs from your customer's ATS. To get a list of existing jobs from your end user's ATS, call the List all jobs [API endpoint](https://docs.unified.to/ats/job/List_all_jobs) (`listAtsJobs` in the TypeScript SDK). ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const sdk = new UnifiedTo({ security: { jwt: "", }, }); export async function getJobs(connectionId: string) { return await sdk.ats.listAtsJobs({ connectionId, }); } ``` The connection_id comes in from your end user's authorization of their ATS and is stored on your end. You can watch our for more detail. A `Job` has fields such as `name`, `description`, `compensation`, `addresses` and `remote` that will be relevant to your software. Reference our [API docs](https://docs.unified.to/ats/job/model) for more information. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_build_a_candidate_sourcing_or_job_board_app_with_unified-1.png) Once you have the list of jobs, ask your customer to identify which jobs your app should identify candidates for. ## Step 2: Create an application Once you have a candidate, there are two ways to create a job application. 1. Send the interested candidate to the job's public web page to apply directly. Many integrations return `public_job_urls`, a list of the job's public pages (on Greenhouse it's a slow field: request it with `fields`). The downside to this method is that you lose control of the candidate's experience and not all ATS providers supply the public website. The upside of this method is that it is very easy to implement. 2. Create an application and candidate in the ATS directly using our Unified ATS API. This is easily accomplished by using the `createCandidate` [API endpoint](https://docs.unified.to/ats/candidate/Create_a_candidate) and `createApplication` [API endpoint](https://docs.unified.to/ats/application/Create_an_application). ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; import { AtsCandidate } from '@unified-api/typescript-sdk/dist/sdk/models/shared'; const sdk = new UnifiedTo({ security: { jwt: '', }, }); export async function createCandidate(connectionId: string, atsCandidate: AtsCandidate) { const result = await sdk.ats.createAtsCandidate({ atsCandidate, connectionId, }); return result.atsCandidate?.id; } export async function createApplication(connectionId: string, candidateId: string, jobId: string) { const result = await sdk.ats.createAtsApplication({ atsApplication: { candidateId, jobId, }, connectionId, }); return result.atsApplication?.id; } ``` ## Step 3: uploading a resume/CV/cover letter (optional) You can also upload the candidate's documents. Set the `Document` [object](https://docs.unified.to/ats/document/model) fields your customer's ATS requires: usually `filename`, `type` (such as RESUME), the file as `document_url` or base64 `document_data`, and the record it attaches to. Greenhouse and Workable attach to an `application_id` and take a URL only; Lever attaches to a `candidate_id` and also accepts base64. Some ATS providers may require additional fields to be present. Be sure to check their [feature support page](https://app.unified.to/integrations/greenhouse?tab=support) for more information. ## Step 4: Review the application status (optional) If you need to be informed on the status of the candidate's job application, use the Retrieve an application [API endpoint](https://docs.unified.to/ats/application/Retrieve_an_application), or subscribe to application updated events on integrations that support them (native or virtual webhooks). ## Where to go next Use [Unified.to](https://unified.to/)'s [ATS API](https://unified.to/ats) to support 164 ATS integrations in your sourcing or job-board app with one integration. ## Additional resources - [Unified ATS API](https://unified.to/ats) - [API Documentation for the Unified ATS API](https://docs.unified.to/ats/overview) - [SDKs for Unified.to](https://docs.unified.to/overview/sdks) - - ## How to build a Discord support bot with Unified.to and Langbase URL: https://docs.unified.to/guides/how_to_build_a_discord_support_bot_with_unified_and_langbase # How to build a Discord support bot with Unified.to and Langbase ------ _December 12, 2024_ Want to build an AI-powered support bot that can answer questions with information from an knowledge base? In this guide, we'll show you how to combine Unified.to's data ingestion capabilities with Langbase's AI agent framework to create a powerful support bot. We'll pull content from your customers' knowledge management system (like Notion) through Unified.to, process it with Langbase's Memory Agents, and make it available to a Discord bot, powered by Unified.to's Messaging API, so it can answer support questions. We'll be using React and Typescript in these examples, which you can adapt to fit your framework of choice. ## Overview This guide walks you through how to: - Pull knowledge base content using Unified.to's KMS API - Process and prepare this content using Langbase's Memory Agents - Create an AI agent using Langbase Pipes - Send responses through Discord using Unified.to's Messaging API By combining Unified.to's unified APIs with Langbase's AI capabilities, we get the best of both worlds: easy access to your data sources and powerful AI features without the complexity of building everything from scratch. ## Before you begin Make sure you have: - A [Unified.to](https://app.unified.to/) account - A [Langbase](https://langbase.com/) account - Access to a knowledge management system (e.g., Notion) containing your support content - Unified.to allows you to easily access your customers' data and that's normally what you would use in production, but for the purposes of this guide you can follow along with your own Notion account - A Discord server where you want to deploy your bot > If you don't want to use real data from Notion or Discord, you can also take advantage of Unified.to's [sandbox environment](https://docs.unified.to/concepts/sandbox) and work with synthetic data instead. The following steps assume you already have your Unified.to account with your workspace ID and API key available. If you're new, check out our [Quick Start guide](https://docs.unified.to/quick-start) to get familiar with the platform. ## Set up your connections The first thing you'll do is activate your preferred KMS platform to be the source of data for your bot. We'll use Notion in this guide, but our [KMS API](https://unified.to/kms) supports 33 platforms today and more are being added on a regular basis. **Note:** If you're using the sandbox environment, for the steps below that ask you to enter your OAuth 2 credentials, just enter any random value e.g., `test123`. ### Activate Notion and Discord integrations 1. On app.unified.to, go to the [Integrations page](https://app.unified.to/integrations) 2. Search for and select Notion 3. Choose OAuth 2, enter your credentials (which can be anything if you're using the sandbox environment), and then click **Activate** Repeat steps 1-3 but for Discord. If you want to use real data from Notion and Discord, check out our how-to guides on setting up with them: - [How to set up and configure Notion](https://docs.unified.to/guides/how_to_set_up_and_configure_notion) - [How to get your Discord OAuth 2 credentials and bot token](https://docs.unified.to/guides/how_to_get_your_discord_oauth_2_credentials_and_bot_token) ### (Sandbox environment) Create a test connection from the web app If you're using the sandbox environment, then you can create test connections easily from the web app. 1. On app.unified.to, go to the [Connections](https://app.unified.to/connections) page 2. Click on **Create test connection** at the top right corner of the page (note: this will only show up if your current environment is the sandbox environment) 3. Click on the **Notion** and **Discord** integrations 4. Click **Done** ![Screenshot_2024-12-12_at_10.58.17_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_build_a_discord_support_bot_with_unified_and_langbase-0.png) Skip ahead to the 'Pull content from your knowledge base' step below. ### (Production environments) Add the Authorization component to your app To ask for your customers' permission to access their Notion and Discord accounts, you can add Unified.to's Authorization component to your front-end: 1. Add the following React component to your app. Make sure to replace `WORKSPACE_ID` with your actual workspace ID. ```javascript // TODO: Make sure to run 'npm install @unified-api/react-directory' import UnifiedDirectory from '@unified-api/react-directory'; ``` We also offer pre-built embedded components in Angular, Vue, and Svelte. See other ways to display the Authorization component in your app [here](https://app.unified.to/embed?tab=Authorization). - If your customers' mainly interact with you through message-based platforms, you could even send them the Authorization URL through an AI chat agent instead of a web front-end! The Authorization component displays a list of your integrations. When your user clicks on one of these integrations, the authorization flow will open up in a new tab. Your users will follow the steps to grant you access to their accounts. When that is done, they will be redirected back to your app (the default success URL location). 1. Handle the callback after a user authorizes an integration. The connection ID will be sent back in the success URL - we need to save this to make API calls later on. In this example, we'll save the connection ID to local storage: ```javascript function handleAuthCallback() { const urlParams = new URLSearchParams(window.location.search); const connectionId = urlParams.get('id'); if (connectionId) { console.log('New connection created:', connectionId); // Store this connection ID securely - you'll need it for API calls localStorage.setItem('unifiedConnectionId', connectionId); } } window.addEventListener('load', handleAuthCallback); ``` For this guide, you can be your own test user: 1. Click on the Notion integration in the Authorization component 2. Grant access to your Notion workspace 3. Repeat for Discord 4. Save both connection IDs - you'll need them for the next steps Note: In production, you should associate these connection IDs with your user's account in your database. For more information, see our guide on [How to associate connection IDs with your users](https://docs.unified.to/guides/how_to_associate_a_connection_id_with_your_end_user). ## Pull content from your knowledge base Now that you've got a connection ID, we can start making calls to the Unified API! First, let's write some functions to recursively fetch all content from your KMS: ```javascript async function fetchContent(connectionId: string, apiKey: string) { const spaces = await fetchAllSpaces(connectionId, apiKey); const allContent = []; for (const space of spaces) { const pages = await fetchPagesInSpace(connectionId, apiKey, space.id); allContent.push(...pages); } return allContent; } async function fetchAllSpaces(connectionId: string, apiKey: string) { const response = await fetch( `https://api.unified.to/kms/${connectionId}/space?limit=100`, { headers: { Authorization: `Bearer ${apiKey}`, } } ); if (!response.ok) { throw new Error(`Failed to fetch spaces: ${response.statusText}`); } return response.json(); } async function fetchPagesInSpace(connectionId: string, apiKey: string, spaceId: string) { const response = await fetch( `https://api.unified.to/kms/${connectionId}/page?limit=100&space_id=${spaceId}`, { headers: { Authorization: `Bearer ${apiKey}`, } } ); if (!response.ok) { throw new Error(`Failed to fetch pages: ${response.statusText}`); } return response.json(); } // Usage const content = await fetchContent(CONNECTION_ID, API_KEY); ``` **API reference**: [KMS](https://docs.unified.to/kms/overview) Call `fetchContent` in your app to see the results. You should see a collection of [Pages](https://docs.unified.to/kms/page/model). ## Process the content with Langbase Memory Agents Now that we have our knowledge base content, we need to prepare it for AI consumption. Instead of building a complex RAG pipeline ourselves, we'll use Langbase's [Memory Agents](https://langbase.com/docs/memory) to handle all the heavy lifting - from text processing to embedding generation and storage. First, let's install the Langbase SDK: ```shell npm install langbase ``` Now we can create memory to store our knowledge base: ```typescript import { Langbase } from 'langbase'; const langbase = new Langbase({ apiKey: process.env.LANGBASE_API_KEY!, }); async function createKnowledgeBase() { // Create a new memory const memory = await langbase.memory.create({ name: 'support-bot-knowledge', description: 'Knowledge base for our support bot', embedding_model: 'openai:text-embedding-3-large'// Using OpenAI's latest model }); return memory; } ``` **API reference:** [Create memory](https://langbase.com/docs/sdk/memory/create) Now let's process our Unified.to content and upload it to Langbase: ```typescript async function uploadContentToMemory(pages, memoryName: string) { for (const page of pages) { // Download the page content from Unified.to's download_url const content = await fetch(page.download_url).then(res => res.text()); // Create a buffer from the content const documentBuffer = Buffer.from(content); // Upload to Langbase with metadata await langbase.memory.documents.upload({ memoryName, documentName: page.title, document: documentBuffer, contentType: 'text/markdown', // Adjust based on your content type meta: { source: 'unified_kms', created_at: page.created_at, updated_at: page.updated_at, space_id: page.space_id } }); } } ``` **API reference:** [Upload document](https://langbase.com/docs/sdk/memory/document-upload) Langbase's Memory Agents will automatically: 1. Process the text (Unified.to's data models have already cleaned the data for you!) 2. Split content into optimal chunks 3. Generate embeddings using state-of-the-art models 4. Store and index everything for fast retrieval Once your content is uploaded, you can create a Pipe to handle natural language interactions. Pipes are custom AI agents that can be exposed as APIs, making them perfect for our Discord bot: ```typescript async function createSupportBotPipe(memoryName: string) { const pipe = await langbase.pipe.create({ name: 'Support Bot', type: 'chat', memory: memoryName, description: 'Support bot powered by our knowledge base' }); return pipe; } ``` **API reference**: [Create pipe](https://langbase.com/docs/sdk/pipe/create) This gives you a fully-functional RAG system with just a few API calls. No need to manage embeddings, vector stores, or complex AI infrastructure - Langbase handles all of that for you. ## Set up real-time message monitoring To make your support bot responsive, you'll need to set up webhooks to receive notifications when users send messages in Discord channels. 1. Create a webhook to monitor Discord messages: ```javascript async function createDiscordWebhook(connectionId: string, apiKey: string, hookUrl: string) { const response = await fetch( 'https://api.unified.to/unified/webhook', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId, hook_url: hookUrl, object_type: 'messaging_message', event: 'created' }) } ); if (!response.ok) { throw new Error(`Failed to create webhook: ${response.statusText}`); } return response.json(); } createDiscordWebhook(CONNECTION_ID, API_KEY, 'https://localhost:8000/webhook') ``` **API reference**: [Create a webhook](https://docs.unified.to/unified/webhook/Create_webhook_subscription) 1. Set up an endpoint on your server to handle incoming webhook events. For example: ```javascript app.post('/webhook', async (req, res) => { // Verify webhook signature const { data, webhook, nonce, sig } = req.body; // Handle new messages for (const message of data) { // Ignore messages from the bot itself if (message.author_member.id === BOT_USER_ID) continue; // Process the message and generate a response const response = await generateBotResponse(message.message); // Send the response back to the channel await sendDiscordResponse( DISCORD_CONNECTION_ID, API_KEY, message.channel_id, response ); } res.sendStatus(200); }); ``` 1. Validate incoming webhooks (recommended): ```javascript const crypto = require('crypto'); function verifyWebhookSignature(data, nonce, signature, workspaceSecret) { const hmac = crypto.createHmac('sha1', workspaceSecret); const calculatedSignature = hmac .update(JSON.stringify(data) + nonce) .digest('base64'); return calculatedSignature === signature; } ``` With this setup, your bot can: 1. Receive notifications whenever a new message is sent in the monitored channels 2. Process those messages through your AI pipeline 3. Send responses back to the appropriate channel For more details on webhooks at Unified.to, see our [guide to webhooks](https://docs.unified.to/reference/webhooks). ## Send responses through Discord When you're ready to respond to a user's question, use our Messaging API to send the message back to Discord. Here's an example implementation with error handling and message formatting: ```javascript async function sendDiscordResponse(connectionId: string, apiKey: string, channelId: string, response: string) { const formattedResponse = formatDiscordMessage(response); const apiResponse = await fetch( `https://api.unified.to/messaging/${connectionId}/message`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ channel_id: channelId, message: formattedResponse, message_html: formattedResponse, // Optional: Use for formatted text }) } ); if (!apiResponse.ok) { if (apiResponse.status === 401) { throw new Error('Authentication failed - check your API key'); } else if (apiResponse.status === 404) { throw new Error('Channel not found - verify channel ID'); } throw new Error(`Failed to send message: ${apiResponse.statusText}`); } return apiResponse.json(); } function formatDiscordMessage(response) { // Example formatting for Discord markdown return `**Bot Response:**\n${response}`; } // Example usage with different message types async function sendBotResponses(connectionId, apiKey, channelId) { // Simple text response await sendDiscordResponse( connectionId, apiKey, channelId, 'Here is the answer to your question...' ); // Response with code block await sendDiscordResponse( connectionId, apiKey, channelId, '```javascript\nconst example = "code block";\n```' ); // Response with rich formatting await sendDiscordResponse( connectionId, apiKey, channelId, '**Bold text** and *italic text* with a [link](https://example.com)' ); } ``` **API reference**: [Messaging](https://docs.unified.to/messaging/overview) ## Putting it all together Here's a high-level overview of how to combine all the components into a working support bot: ```typescript import { Langbase } from 'langbase'; async function initializeSupportBot() { // Initialize APIs const langbase = new Langbase({ apiKey: process.env.LANGBASE_API_KEY! }); // Get connection IDs - implement these based on how you manage your users const KMS_CONNECTION_ID = await getKMSConnection(user_id); const DISCORD_CONNECTION_ID = await getDiscordConnection(user_id); const UNIFIED_API_KEY = process.env.UNIFIED_API_KEY; // Step 1: Fetch content from KMS const kmsContent = await fetchContent(KMS_CONNECTION_ID, UNIFIED_API_KEY); // Step 2: Set up Langbase Memory and upload content const memory = await langbase.memory.create({ name: `support-kb-${user_id}`, description: 'Knowledge base for support bot' }); await uploadContentToMemory(kmsContent, memory.name); // Step 3: Create an AI agent using Langbase Pipe const pipe = await langbase.pipe.create({ name: `discord-support-bot`, type: 'chat', memory: memory.name, description: 'Support bot powered by our knowledge base' }); // Step 4: Set up Discord webhook for real-time messages const webhook = await createDiscordWebhook( DISCORD_CONNECTION_ID, UNIFIED_API_KEY, 'https://your-server.com/webhook' ); // Step 5: Handle incoming questions async function handleQuestion(channelId: string, question: string) { try { // Use the Pipe to generate a response const response = await langbase.pipe.chat({ pipe: pipe.name, messages: [{ role: 'user', content: question }] }); // Send the response back to Discord await sendDiscordResponse( DISCORD_CONNECTION_ID, UNIFIED_API_KEY, channelId, response.message ); } catch (error) { console.error('Error handling question:', error); } } return { memory, pipe, webhook, handleQuestion }; } // Usage example async function startBot() { const bot = await initializeSupportBot(); // Your webhook endpoint would call this when receiving messages app.post('/webhook', async (req, res) => { const { data, webhook, nonce, sig } = req.body; // Handle new messages for (const message of data) { // Ignore messages from the bot itself if (message.author_member.id === BOT_USER_ID) continue; await bot.handleQuestion(message.channel_id, message.message); } res.sendStatus(200); }); } ``` This implementation: 1. Initializes both Unified.to and Langbase clients 2. Creates a Memory AGent to store your knowledge base content 3. Sets up a Pipe to handle natural language interactions 4. Configures Discord webhooks for real-time messaging The bot will now: - Automatically receive messages from Discord through webhooks - Process questions using your knowledge base through Langbase Pipes - Send responses back to the appropriate Discord channel All of this happens with minimal infrastructure management on your part - Unified.to handles the integrations while Langbase manages the AI components. ## Conclusion Congratulations! You've built a powerful support bot by combining Unified.to's integration capabilities with Langbase's AI features. In this guide, you learned how to: - Pull knowledge base content from any system using Unified.to's KMS API - Process and embed that content using Langbase Memory Agents - Create an AI agent using Langbase Pipes - Handle real-time messages through Discord using Unified.to's Messaging API and webhooks As an added bonus, this architecture is incredibly flexible. Since you're using Unified.to's APIs, you can easily expand your knowledge sources - want to pull content from Confluence instead of Notion? Or deploy your bot on Slack? It's just a matter of changing the connection - your core logic stays the same. And with Langbase handling the AI components, you can enhance your bot's capabilities without managing complex infrastructure: - Experiment with multiple Memory Agents for different types of content - Customize your Pipe's behaviour through prompt engineering - Scale your bot without worrying about vector store management Happy building! ## How to Build a Fintech Application with Unified's Payments API URL: https://docs.unified.to/guides/how_to_build_a_fintech_application_with_unified_payments_api # How to Build a Fintech Application with Unified's Payments API ------ _September 9, 2025_ _Last Updated: July 2026_ With Unified, you can build fin-tech products that work with your end customers payment providers. With a single integration you can connect payment processors like Stripe, PayPal, GoCardless and more! You can do various things such as creating payments, generating payment links, and reading refund and payout records for reconciliation, all without building custom integrations for each payment provider. Refunds and payouts are read-only in the unified model: you retrieve them to reconcile, and issue them through the payment provider itself. In this guide, we will show you how to create and list payments as well as some related data. For the example we will be using Stripe, but this approach works for any of the payment integrations supported by Unified. [See the full list of supported payment integrations.](https://docs.unified.to/payment/integrations) --- ## Prerequisites - Node.js (v18+) - Unified account with a payment integration enabled (e.g., Stripe) - Unified API key - Your customer's payment processor connection ID --- ## Step 1: Setting up your project Set up your dependencies ```bash mkdir payments-demo cd payments-demo npm init -y npm install @unified-api/typescript-sdk dotenv ``` Add your credentials to `.env`: ```plain text UNIFIED_API_KEY=your_unified_api_key CONNECTION_STRIPE=your_customer_stripe_connection_id ``` --- ## Step 2: Initialize the SDK ```typescript import 'dotenv/config'; import { UnifiedTo } from '@unified-api/typescript-sdk'; const { UNIFIED_API_KEY, CONNECTION_STRIPE } = process.env; const sdk = new UnifiedTo({ security: { jwt: UNIFIED_API_KEY! }, }); ``` --- ## Step 3: How to Get Your Customer's Connection ID Before you can list payments, your customer must authorize your app to access their payment provider via Unified's embedded auth flow. Once authorized, you'll receive a connection ID for each integrations. ## Step 4: Listing Payments ```typescript export async function listPayments(connectionId: string) { const payments = await sdk.payment.listPaymentPayments({ connectionId, limit: 10, }); return payments; // PaymentPayment[] } ``` --- ## Step 5: Creating a Payment Link ```typescript export async function createPaymentLink(connectionId: string, amount: number, currency: string) { const link = await sdk.link.createPaymentLink({ connectionId, paymentLink: { amount, currency, isActive: true, successUrl: "", // Stripe typically requires line items on links lineitems: [ { itemName: "Order #123", unitAmount: amount, unitQuantity: 1, itemSku: "SKU-123", }, ], }, }); return link; // PaymentLink } ``` --- ## Step 6: Listing Refunds ```typescript export async function listRefunds(connectionId: string) { const refunds = await sdk.refund.listPaymentRefunds({ connectionId, limit: 10, }); return refunds; // PaymentRefund[] } ``` --- ## Step 7: Listing Payouts ```typescript export async function listPayouts(connectionId: string) { const payouts = await sdk.payout.listPaymentPayouts({ connectionId, limit: 10, }); return payouts; // PaymentPayout[] } ``` --- ## Step 8: Listing Subscriptions ```typescript export async function listSubscriptions(connectionId: string) { const subs = await sdk.subscription.listPaymentSubscriptions({ connectionId, limit: 10, }); return subs; // PaymentSubscription[] } ``` --- ## Step 9: Example Usage Here's how you might use these functions in your payment workflow: ```typescript async function main() { // 1. Create a payment const payment = await createPayment(CONNECTION_STRIPE!, 1000, "USD"); // 2. List payments const payments = await listPayments(CONNECTION_STRIPE!); // 3. Create a payment link const link = await createPaymentLink(CONNECTION_STRIPE!, 1000, "USD"); // 4. List links const links = await sdk.link.listPaymentLinks({ connectionId: CONNECTION_STRIPE!, limit: 10 }); // 5. List refunds const refunds = await listRefunds(CONNECTION_STRIPE!); // 6. List payouts const payouts = await listPayouts(CONNECTION_STRIPE!); // 7. List subscriptions (creation may not be implemented for some providers) const subscriptions = await listSubscriptions(CONNECTION_STRIPE!); console.log("Payment:", payment); console.log("Payments count:", payments.length); console.log("Payment Link URL:", link.url); console.log("Links count:", links.length); console.log("Refunds count:", refunds.length); console.log("Payouts count:", payouts.length); console.log("Subscriptions count:", subscriptions.length); } main(); ``` --- Happy building! 🎉 ## How to Build an E-Commerce Product Integration with Unified URL: https://docs.unified.to/guides/how_to_build_an_e_commerce_product_integration_with_unified # How to Build an E-Commerce Product Integration with Unified ------ _September 8, 2025_ Unified makes it possible for developers to build ecom products that work with your end customers preferred ecom platforms! With our Unified Commerce API, you can connect to dozens of ecom systems (like Shopify, BigCommerce, WooCommerce and more). This article will give you knowledge on how to create and list products, and to even create product images from the product description using Unified's GenAI integration. ## Requirements - Node.js (v1 - Unified account with ecom integration - Unified API key - Your end customer's e-commerce connection ID - Unified GenAI connection ID ## Step 1: Setting up your project ```bash mkdir ecommerce-demo cd ecommerce-demo npm init -y npm install @unified-api/typescript-sdk dotenv ``` Add your credentials to `.env`: ```shell UNIFIED_API_KEY=your_unified_api_key CONNECTION_SHOPIFY=your_customer_shopify_connection_id CONNECTION_GENAI=your_genai_connection_id ``` --- ## Step 2: Initialize the SDK ```typescript import 'dotenv/config'; import { UnifiedTo } from '@unified-api/typescript-sdk'; const { UNIFIED_API_KEY, CONNECTION_SHOPIFY, CONNECTION_GENAI } = process.env; const sdk = new UnifiedTo({ security: { jwt: UNIFIED_API_KEY! }, }); ``` --- ## Step 3: How to Get Your Customer's Connection ID Before you can create or list products, your customer must authorize your app to access their e-commerce store via Unified's  authentication flow. Once authorized, you'll receive a **connection ID** for that customer's integration (e.g., Shopify). Store this connection ID securely as shown above and use it in all API calls for that customer. See the [full list of supported integrations](https://unified.to/commerce). --- ## Step 4: Generate a Product Image from Description (with GenAI) You can use Unified GenAI to generate a product image based on the product description. ```typescript export async function generateProductImage(description: string) { const prompt = `Generate a high-quality product image for the following product description: ${description}`; const result = await sdk.genai.createGenaiPrompt({ connectionId: CONNECTION_GENAI!, prompt: { messages: [{ role: "USER", content: prompt }], maxTokens: 1024, temperature: 0.7, responseFormat: "image_url", // or whatever the GenAI API expects for image output }, }); return result.choices?.[0]?.message?.content || ""; } ``` _Explanation:_ - This function sends the product description to Unified GenAI and gets back an image URL. - You can use this image URL when creating the product in Shopify (or any other e-commerce platform). --- ## Step 5: Create a Product (with AI-generated Image) Here's how to create a product in Shopify, using the generated image. ```typescript export async function createProduct(connectionId: string, name: string, description: string, price: number) { // 1. Generate product image const imageUrl = await generateProductImage(description); // 2. Create the product const createProductResult = await sdk.commerce.createCommerceItem({ connectionId, commerceItem: { name, description, price, media: imageUrl ? [{ url: imageUrl }] : [], isActive: true, // Add any other fields as needed (e.g., tags, variants, metadata) }, }); return createProductResult; // commerceItem } ``` _Explanation:_ - Generates an image from the description. - Creates a product in Shopify (or any Unified-supported e-commerce platform) with the image and other details. --- ## Step 6: List Products You can list all products for a customer's e-commerce connection: ```typescript export async function listProducts(connectionId: string) { const productsResult = await sdk.commerce.listCommerceItems({ connectionId, limit: 10, }); return productsResult; // commerceItem[] } ``` _Explanation:_ - Fetches the first 10 products for the customer's store. - You can filter, paginate, or sort as needed. --- ## Step 7: Example Usage Here's how you might use these functions in your product workflow: ```typescript async function main() { const name = "AI-Generated T-Shirt"; const description = "A stylish t-shirt with a unique, AI-generated design."; const price = 29.99; // 1. Create the product with an AI-generated image const product = await createProduct(CONNECTION_SHOPIFY!, name, description, price); // 2. List products const products = await listProducts(CONNECTION_SHOPIFY!); console.log("Created product:", product); console.log("All products:", products); } main(); ``` --- ## How to build an invoicing system with Unified.to URL: https://docs.unified.to/guides/how_to_build_an_invoicing_system_with_unified # How to build an invoicing system with Unified.to ------ _January 3, 2024_ When building a SaaS app for invoicing, you'll need to exchange data within your customers' accounting app of choice. In this guide, we'll cover the basics of how to access the data you need for your invoicing app with the help of [Unified.to](https://unified.to/)'s unified accounting API. ## Prerequisites Before starting, be sure to review our [Getting Started with Unified](https://unified.to/blog/start_here_getting_started_with_unified) article. It will walk you through registering for a free account and onboarding. Once you have completed the onboarding steps, go ahead and as you like. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_build_an_invoicing_system_with_unified-0.png) The next step is to deploy the [Unified.to](https://unified.to/) Embedded Authorization widget in your product's user interface (UI). Check out our to learn more. ## Overview Here are the steps that you will need to support to build your invoicing app: 1. create an invoice 2. pay an invoice or cancel an invoice Let's go into more detail for each. ## Step 1: Create an invoice To generate an invoice, gather specific details from your end user, including essential information like the amount, line items/products, due date, and customer details. [Unified.to](https://unified.to/) can help with two of those values: customer and product. **Customers** To get a list of existing customers from your end user's accounting system, simply call the `listCustomers` [API endpoint](https://docs.unified.to/accounting/customer/List_all_customers). ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const sdk = new UnifiedTo({ security: { jwt: '', }, }); export async function getCustomers(connectionId: string) { return await sdk.accounting.listAccountingContacts({ connectionId, }); } ``` The connection_id comes in from your end user's authorization of their accounting app and is stored on your end. You can watch our to learn more. If you need to create a new customer, use our `createCustomer` [API endpoint](https://docs.unified.to/accounting/customer/Create_a_customer). A `Customer` will have specific fields such as `name` , `emails`, `billing_address`, and tax information that will be relevant to your software. See our [API Documentation page](https://docs.unified.to/accounting/customer/model) for more information. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_build_an_invoicing_system_with_unified-1.png) **Products** Much like getting a list of existing customers, it is easy to get a list of existing products in your end user's accounting app using the `listItems` API endpoint. We call a product, an `Item`, and you can find the full data-model on our [API Documentation page](https://docs.unified.to/accounting/item/model). ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const sdk = new UnifiedTo({ security: { jwt: '', }, }); export async function getItems(connectionId: string) { return await sdk.commerce.listCommerceItems({ connectionId, }); } ``` **Chart of accounts & tax rates** Each line item in an invoice will be associated with a customer, item, account and tax rate in your end user's accounting application. You can get a list of accounts by calling our `listAccounts` [API endpoint](https://docs.unified.to/accounting/account/List_all_accounts) which returns a list from their 'Chart of Accounts'. You can also get a list of tax rates by calling our `listTaxrates` [API endpoint](https://docs.unified.to/accounting/taxrate/List_all_taxrates). **Invoices** Once the end user has created an invoice, you will want to send that invoice into their accounting system as well. This is easily accomplished by using the `createInvoice` [API endpoint](https://docs.unified.to/accounting/invoice/Create_a_invoice). ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; import { AccountingInvoice } from '@unified-api/typescript-sdk/dist/sdk/models/shared'; const sdk = new UnifiedTo({ security: { jwt: '', }, }); export async function createInvoice(connectionId: string, invoice: AccountingInvoice) { const result = await sdk.accounting.createAccountingInvoice({ accountingInvoice: invoice, connectionId, }); return result.accountingInvoice?.id; } ``` ## Step 2: Paying invoices Once that invoice is paid, you will need to communicate this to the accounting application. This is accomplished by using the `createPayment` [API endpoint](https://docs.unified.to/accounting/payment/Create_a_payment) which associates a payment with an invoice. At a minimum, a `Payment` will need an `invoice_id` , a `customer_id` and an `amount`. Some integrations may require additional values to be present, so check that [integration's feature support page](https://app.unified.to/integrations/quickbooks?tab=support). For example, Quickbooks also requires a `currency` field. ## Conclusion It's easy to support multiple accounting applications in your invoicing app and allow your end user to seamlessly move data between the two. By using Unitied.to's [Unified Accounting API](https://unified.to/accounting) you can add accounting integrations to your product and support your invoicing use case within days. ## Additional Resources - [Unified Accounting API](https://unified.to/accounting) - [API Documentation for the Unified Accounting API](https://docs.unified.to/accounting/overview) - [SDKs for Unified.to](https://docs.unified.to/overview/sdks) - - ## How to build Enterprise Search using RAG URL: https://docs.unified.to/guides/how_to_build_enterprise_search_using_rag # How to build Enterprise Search using RAG ------ _November 19, 2025_ In this article, we will be going into detail on how to build an enterprise search application as well as a Q&A bot using a RAG pipeline with [Unified.to](https://unified.to/)'s integrations and OpenAI embedding models. [RAG](https://developer.nvidia.com/blog/rag-101-demystifying-retrieval-augmented-generation-pipelines/) or Retrieval-Augmented-Generation is an approach for indexing a large amount of information so it can be searched quickly. This design pattern is very beneficial for enterprise search, Q&A bots, or any agent. A Q&A bot can be used to answer questions across a company's knowledge base, while an enterprise search application can be used to search corporate information from any internal data source. We will be using a few of Unified.to's integrations to access internal company knowledge in [file storage](https://unified.to/storage) (eg. Google Drive) and [knowledge management systems](https://unified.to/kms) (eg. Notion), embed their content using [OpenAI's models](https://unified.to/genai), and then store them in a vector DB and used to answer questions. ## **Prerequisites** - Node.js 19+ - Unified account with integrations enabled for Google Drive and Notion - Unified.to API key and GenAI Connection ID - OpenAI API key (As we are using OpenAI for embedding) - A vector DB (eg. Pinecone, or Chroma. For the demo we are using a in memory vector store) ## **Step 1: Setting your project up** ```typescript mkdir rag-bot cd rag-bot npm init -y npm install dotenv @unified-api/typescript-sdk ``` Add your credentials and keys to the `.env`: ```plain text UNIFIED_API_KEY=your_unified_api_key CONNECTION_GOOGLEDRIVE=your_gdrive_connection_id CONNECTION_NOTION=your_notion_connection_id CONNECTION_GENAI=your_genai_connection_id ``` ## **Step 2: Initialize the SDK** ```typescript import 'dotenv/config'; import { UnifiedTo } from '@unified-api/typescript-sdk'; const { UNIFIED_API_KEY, CONNECTION_GOOGLEDRIVE, CONNECTION_NOTION, CONNECTION_GENAI } = process.env; const sdk = new UnifiedTo({ security: { jwt: UNIFIED_API_KEY! }, }); ``` ## **Step 3: Fetch all the files from Google Drive** We will now use Unified's storage API to retrieve files from a Google Drive. ```typescript async function getAllGoogleDriveFiles(connectionId: string) { const files: any[] = []; let offset = 0; const limit = 100; let hasMore = true; while (hasMore) { const response = await sdk.storage.listStorageFiles({ connectionId, offset, limit, }); pages.push(...(response.files || [])); hasMore = (response.files?.length || 0) === limit; offset += limit; } return files; } const file = await sdk.storage.getStorageFile({ connectionId, id: fileId }); const downloadUrl = file.download_url; const content = await fetch(downloadUrl).then(res => res.text()); return content; } ``` ## **Step 4: Fetch Notion Pages** Now that we have the data from Google Drive, we will use Unified's connection to Notion to fetch data from Notion as well. ```typescript async function getAllNotionPages(connectionId: string) { const pages: any[] = []; let offset = 0; const limit = 100; let hasMore = true; while (hasMore) { const response = await sdk.storage.listStorageFiles({ connectionId, offset, limit, }); files.push(...(response.files || [])); hasMore = (response.files?.length || 0) === limit; offset += limit; } return pages; } async function getNotionPageContent(connectionId: string, pageId: string) { const page = await sdk.kms.getKmsPage({ connectionId, id: pageId, }); const content = await fetch(page.download_url).then(r => r.text()); return content; } ``` ## **Step 5: Building the vector DB** For the purpose of the demo, we will be creating a simple in memory vector store. But for production, you would use a cloud-based vector database like Pinecone, Chroma or Weaviate. ```typescript type Document = { id: string; source: string; content: string; embedding?: number[] }; const vectorDB: Document[] = []; ``` ## **Step 6: Embedding the documents** For the purpose of the example, we will be using OpenAI as the embedding provider in this step. [Unified.to](https://unified.to/)'s Generative AI API supports embeddings on [several integrations](https://unified.to/genai), such as OpenAI, Cohere and Google Gemini, but you can also use one provider's SDK. Please remember to chunk the content properly and not embed the entire content at once. ### Chunking strategies: Chunking large documents effectively is important to maintain context and relevance. Here are a few best practices for chunking: 1. **Semantic Chunking**: Instead of splitting by fixed lengths, break the document into chunks based on natural language boundaries, like paragraphs or sections, ensuring each chunk is coherent and meaningful. 2. **Overlap Between Chunks**: Include a slight overlap between adjacent chunks. This helps maintain continuity and context, especially for complex ideas that span multiple chunks. 3. **Fixed-Length Chunking**: If you use fixed-length chunking, aim for a length that balances between too small (losing context) and too large (losing efficiency). Typically, a few hundred tokens per chunk works well. 4. **Using Document Structure**: If your document has headings, subheadings, or other structural elements, use those to guide the chunking. This ensures that each chunk is logically complete. 5. **Contextual Embeddings**: Sometimes, you can embed the entire document first and then create embeddings for chunks that are derived from the document's embedding, ensuring that each chunk stays contextually relevant. 6. **Pre-processing and Cleaning**: Before chunking, clean up the text by removing unnecessary whitespace, special characters, or any irrelevant information to improve the quality of the embeddings. By following these strategies, you'll maintain the semantic integrity of the document and ensure that the vector database can retrieve meaningful context. There are several popular libraries that help with chunking and embedding with less custom code. A few notable ones include: 1. **LangChain**: This is a popular framework that integrates various language models and vector databases. It provides built-in utilities for chunking, embedding, and storing data in vector stores. 2. **Haystack**: Developed by deepset, Haystack is another framework for building search pipelines. It supports intelligent chunking, document retrieval, and integration with vector databases like FAISS, Milvus, and Pinecone. 3. **Transformers and Datasets from Hugging Face**: Hugging Face offers tools that can help with both embedding and chunking. You can use their Transformers library for embedding and their Datasets library to preprocess and chunk documents. 4. **SentenceTransformers**: This library is great for generating embeddings and can be combined with custom chunking logic to break documents into meaningful pieces before embedding. 5. **LlamaIndex (formerly GPT Index)**: This library is designed to help build large language model applications and provides utilities for chunking, indexing, and querying documents effectively. These libraries often provide a lot of built-in functionality, making it easier to handle complex documents and ensure that your embeddings and vector storage are efficient and meaningful. ```typescript import { Configuration, OpenAIApi } from "openai"; const openai = new OpenAIApi(new Configuration({ apiKey: process.env.OPENAI_API_KEY })); async function embedText(text: string): Promise { const response = await openai.createEmbedding({ model: "text-embedding-3-large", //You can choose to use text-embedding-3-small too input: text, }); return response.data.data[0].embedding; } ``` ## **Step 7: Indexing Everything** Now that we have all the data collected, it is time to index all of it! When you store vectors in a vector database like Pinecone or Weaviate, you can attach `metadata` to each vector. This metadata can include things like a unique ID, the original text, source information, or any other relevant attributes. When you perform a similarity search and get the top matching vectors, the database returns the metadata along with the vectors. That way, you can easily link back to the original content. For instance, when you index a piece of text, you'd store the vector along with metadata like a document ID, the title, or even the source URL. When you retrieve the nearest neighbors, the metadata is returned, and you can use it to identify or fetch the original content from your database or any external source. ```typescript async function indexAllContent() { // Google Drive const gdriveFiles = await getAllGoogleDriveFiles(CONNECTION_GOOGLEDRIVE!); for (const file of gdriveFiles) { const content = await getFileContent(CONNECTION_GOOGLEDRIVE!, file.id); const embedding = await embedText(content); vectorDB.push({ id: file.id, source: "gdrive", content, embedding, filename: file.name, date_created: file.created_at, owner_id: file.user_id, }); } // Notion const notionPages = await getAllNotionPages(CONNECTION_NOTION!); for (const page of notionPages) { const content = await getNotionPageContent(CONNECTION_NOTION!, page.id); const embedding = await embedText(content); vectorDB.push({ id: page.id, source: "notion", content, embedding }); } } ``` ## **Step 8: Finding Answers** Now that we have the data ready, it is time to implement the Bot or enterprise search that will use this data to answer questions from a user. The basic flow would be: 1. Embed the question asked 2. Find similar docs in the Vector DB 3. Send the top `n` amount of docs as context paired with the question to Unified's GenAI endpoint. 4. Return the answer to the user. For the purpose of the example, we will be sending the top 3 most similar docs. ```typescript function cosineSimilarity(a: number[], b: number[]) { const dot = a.reduce((sum, ai, i) => sum + ai * b[i], 0); const normA = Math.sqrt(a.reduce((sum, ai) => sum + ai * ai, 0)); const normB = Math.sqrt(b.reduce((sum, bi) => sum + bi * bi, 0)); return dot / (normA * normB); } async function answerQuestion(question: string) { const questionEmbedding = await embedText(question); // For the purpose of the example we will be sending the top 3 docs const scored = vectorDB.map(doc => ({ ...doc, score: cosineSimilarity(questionEmbedding, doc.embedding!) })); const topDocs = scored.sort((a, b) => b.score - a.score).slice(0, 3); const context = topDocs.map(doc => doc.content).join('\n---\n'); const prompt = ` You are a helpful company knowledge bot. Use the following context to answer the question. Context: ${context}Question: ${question}Answer in a concise and accurate way. `; const result = await sdk.genai.createGenaiPrompt({ connectionId: CONNECTION_GENAI!, prompt: { messages: [{ role: "USER", content: prompt }], maxTokens: 256, temperature: 0.2 } }); return result.choices?.[0]?.message?.content || "No answer generated."; } ``` ## **Step 9: Usage** ```typescript (async () => { await indexAllContent(); const answer = await answerQuestion("How do I request vacation days?"); console.log("Bot answer:", answer); })(); ``` The same flow works across industries: [Unified.to](https://unified.to/) supports 36 categories and 1682 integrations to read end-customer data from. ## How to configure Bullhorn Redirect URI for creating a connection URL: https://docs.unified.to/guides/how_to_configure_bullhorn_redirect_uri_for_creating_a_connection # How to configure Bullhorn Redirect URI for creating a connection ------ _April 8, 2026_ To connect Bullhorn to [Unified.to](https://unified.to/), each of your customers needs Bullhorn REST API credentials (OAuth client ID, client secret, API username and API password) whose redirect URI is set to [Unified.to](https://unified.to/)'s OAuth callback. Bullhorn Support sets this up; it's a one-time request per Bullhorn account. ## Why does Bullhorn need a support request? Bullhorn's REST API credentials aren't self-service. Bullhorn Support creates the OAuth client ID and client secret and registers the redirect URIs they accept (Bullhorn documentation, as of 21 October 2026). A customer can't change the redirect URI in the Bullhorn web interface. ## Steps your customer must complete ### 1. Ask Bullhorn Support for REST API credentials The customer opens a ticket with Bullhorn Support (Bullhorn Resource Center) asking for REST API OAuth credentials for the application, with the redirect URI in step 2. Bullhorn Support returns: - OAuth client ID - OAuth client secret - API username (the customer sets its password) [Unified.to](https://unified.to/)'s Bullhorn connection form asks for all four: OAuth2 Client ID, OAuth2 Client Secret, API Username and API Password. ### 2. Include the redirect URI in the request In the same ticket (or a follow-up, if the credentials already exist), the customer asks Bullhorn Support to set the redirect URI for those credentials and gives: - the API username or application name - the [Unified.to](https://unified.to/) redirect URI for your region (below) Example request: "Please set the redirect URI for our Bullhorn API user to `https://api.unified.to/oauth/code`." Use the redirect URI for the region your [Unified.to](https://unified.to/) workspace is in: | Region | Redirect URI | | ------ | -------------------------------------- | | US | `https://api.unified.to/oauth/code` | | EU | `https://api-eu.unified.to/oauth/code` | | AU | `https://api-au.unified.to/oauth/code` | If your workspace uses a [custom API domain (CNAME)](https://docs.unified.to/guides/how_to_set_up_a_custom_api_url_with_a_cname), available on Pro and Scale in the US and EU regions, use `https://YOUR_CUSTOM_DOMAIN/oauth/code` instead. ### 3. Wait for Bullhorn Support to confirm The connection fails until Bullhorn Support confirms the credentials and the redirect URI are in place. ### 4. Create the connection in [Unified.to](https://unified.to/) Your customer then connects Bullhorn through your [Unified.to](https://unified.to/) authorization flow, entering the OAuth2 Client ID, OAuth2 Client Secret, API Username and API Password. ## What else should you know? - Every customer must request the redirect URI change individually for their own Bullhorn account. - [Unified.to](https://unified.to/) can't set the redirect URI for your customer; only Bullhorn Support can. - This is a one-time setup for each of your customers using Bullhorn. - If the customer creates a new API user in the future, they will need to go through this process again. Once connected, see the [Bullhorn integration page](https://docs.unified.to/integrations/bullhorn) for the ATS, CRM and HR & Directory objects and fields Bullhorn supports. ## How to configure webhooks in HubSpot URL: https://docs.unified.to/guides/how_to_configure_webhooks_in_hubspot # How to configure webhooks in HubSpot ------ _February 23, 2024_ This guide walks you through how to configure webhooks in HubSpot to work with Unified.to. These webhooks allow you to get notifications about new events and changes from HubSpot e.g. new leads, updated deals, or deleted contacts. ## Before you begin - Make sure you have a HubSpot developer account. - Get your client ID, client secret, and developer API key. - Point the **Redirect URL** in your HubSpot app to `api.unified.to/oauth/code` - If you're using the EU data region, use instead`api-eu.unified.to/oauth/code` - Follow our [HubSpot developer guide](https://docs.unified.to/guides/how_to_register_a_hubspot_developer_app_and_get_your_oauth_2_credentials#how-to-register-a-hubspot-developer-app-and-get-your-oauth-2-credentials) to see how to do all of the above. ## Setting up Webhooks in Hubspot Legacy Apps ### Select the appropriate Legacy App to setup the webhook ![Screenshot_2025-11-25_at_12.21.18_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_configure_webhooks_in_hubspot-0.png) ### Set the target URL for the webhook HubSpot uses a single webhook URL per app, which means you need to configure your app to send any events to the Unified.to servers. 1. Navigate to your [HubSpot developer account](https://app.hubspot.com/). 2. Click **Apps.** 3. Select the app you wish to use to access your customers' data. 4. Click **Webhooks.** ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_configure_webhooks_in_hubspot-1.png) 5. Under **Target URL**: 1. If you are using the US data region, copy and paste in: `api.unified.to/webhook/workspace/hubspot` 2. If you using the EU data region, copy and paste in: `api-eu.unified.to/webhook/workspace/hubspot` If your URL is greyed out and you have subscriptions under **Event subscriptions**, then you'll need to unsubscribe from all of them. _WARNING: If these subscriptions and the target URL are used by other applications, this action will break them. In that case, you may want to consider creating a new developer app to use with Unified.to._ 6. You do **not** have to create any subscriptions manually. Unified.to will do that on your behalf. ### Add scopes for your webhooks In order for Unified.to to receive webhook data from HubSpot, you need to add scopes for the events you are interested in as well as a few others. 1. With your app page still open, click **Basic Info** and then click **Auth.** 2. Scroll down to **Scopes**: 1. Add `crm.objects.owners.read`as **Required**. 2. For any events where you want to read data, add them as **Required.** 1. Note: This applies to events that you'll be subscribed to as well as those that you will only call via the API. For example, if you want to create a Contacts webhook and also fetch Companies via the API, you'll need to add both `crm.objects.contacts.read` and `crm.objects.companies.read` as **Required** because Unified.to will pass all of these as required scopes in each auth flow. 3. For any events where you want to write data, add them as **Optional**. 4. For example, if you want to receive notifications about updates to HubSpot companies, you'll need to add `crm.objects.companies.read` as **Required**. If you want to write data to companies, add `crm.objects.companies.write` as **Optional**. 5. To see the full list of HubSpot scopes that Unified.to supports, see: [https://app.unified.to/integrations/hubspot?tab=oauth2](https://app.unified.to/integrations/hubspot?tab=oauth2). 3. The `oauth` scope is also required for all HubSpot integrations. This will be added by default if your app was created after April 2024. ![Screenshot_2024-08-26_at_2.35.36_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_configure_webhooks_in_hubspot-2.png) ### Select permission scopes on Unified.to When prompting your customers to authorize access to their third-party accounts, you will need to select the corresponding scopes in [Unified.to](https://unified.to/) as well. The following steps show you how to do that when using our Embedded Authorization component. 1. On app.unified.to, click on **Settings** and then [**Embedded Authorization**](https://app.unified.to/settings/embed?tab=Authorization)**.** 2. You will see a preview of the Embedded Authorization component on the left and a list of configuration options on the right. Click on **Options**. 3. Scroll down to **Permission scopes** and select **webhook** as well as any other permission scopes that you need e.g. the same scopes you added in HubSpot. You do not need to select `oauth` or `crm.objects.owners.read` on [Unified.to](https://unified.to/) as well; Unified maps these automatically. 1. For example, using the examples from above for reading Contacts and Companies, the scopes in Unified.to would be `crm_contact_read` and `crm_company_read` ![c9a0cf8d-eca3-4b74-8d52-d84a2fb6029f.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_configure_webhooks_in_hubspot-3.png) 2. A mapping of Unified scopes to their HubSpot counterparts can be found [here](https://app.unified.to/integrations/hubspot?tab=oauth2). 3. Tip: Under **Integration categories,** select **CRM** to only see scopes related to CRM data objects. ## Setting up Webhooks in Hubspot Development Platform ### Create Webhook configuration file In your hubspot project folder, create a new folder `webhook` and inside add in the `webhook-hsmeta.json` file. ```javascript project-folder/ └── src/ └── app/ ├── app-hsmeta.json └── webhooks/ └── webhook-hsmeta.json ``` ### **Setup Webhook configuration** Now that you have the webhook config file ready, let's setup the configuration as follows: ```javascript { "uid": "webhooks", "type": "webhooks", "config": { "settings": { "targetUrl": "https://api.unified.to/webhook/workspace/hubspot", "maxConcurrentRequests": 10 }, "subscriptions": { "legacyCrmObjects": [ { "subscriptionType": "deals.propertyChange", "propertyName": "dealstage", "active": true }, { "subscriptionType": "deals.associationChange", "active": true } ] } } } ``` For the `targetUrl` : 1. If you are using the US data region, copy and paste in: `api.unified.to/webhook/workspace/hubspot` 2. If you using the EU data region, copy and paste in: `api-eu.unified.to/webhook/workspace/hubspot` For the `subscriptions` we need to use the `legacyCrmObjects` to define which object we want to get events for. ### Deploy Hubspot Project Once you've setup your webhook configuration, you can now upload the project via: ```javascript hs project upload ``` Once uploaded, your configuration should now show up in your app's webhook list: ![Screenshot_2025-11-25_at_12.34.02_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_configure_webhooks_in_hubspot-4.png) For creating the webhook in Unified, see [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks). ## See also - [Introduction to webhooks](https://docs.unified.to/concepts/webhooks#introduction-to-webhooks) - [Create a webhook subscription](https://docs.unified.to/unified/webhook/Create_webhook_subscription) - [How to set up your scopes in HubSpot](https://docs.unified.to/guides/how_to_set_up_your_scopes_in_hubspot) ## How to configure webhooks in Thrive Learning URL: https://docs.unified.to/guides/how_to_configure_webhooks_in_thrive_learning # How to configure webhooks in Thrive Learning ------ _August 26, 2026_ Instructions on how to configure native webhooks in Thrive Learning: 1. Create Unified webhooks for the objects you want (lms_content, lms_course, lms_student, hris_employee, lms_activity). 2. Thrive webhooks must be configured in the Thrive application. 3. Ask your customer to contact their Thrive admin or implementation team to set up the relevant subscriptions: 1. user_subscription (for students/employees) 2. content_subscription (for content/courses) 3. completion_subscription (for activity) 4. Point each subscription URL at the Unified workspace webhook URL: [https://api.unified.to/webhook/workspace/thrive?workspace_id=YOUR_WORKSPACE_ID&connection_id=YOUR_CONNECTION_ID](https://api.unified.to/webhook/workspace/thrive?workspace_id=YOUR_WORKSPACE_ID&connection_id=YOUR_CONNECTION_ID). Change the Unified API host for other regions (`api-eu.unified.to` for EU, `api-au.unified.to` for Australia). For creating the webhook in Unified, see [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks). ## How to configure webhooks in Toast for Unified URL: https://docs.unified.to/guides/how_to_configure_webhooks_in_toast_for_unified # How to configure webhooks in Toast for Unified ------ _August 12, 2026_ Toast pushes restaurant events to a partner webhook URL. Toast does **not** offer a programmatic subscribe API for these subscriptions; they are created in the Toast Developer Portal and/or with the Toast Integrations team. Point that URL at Unified so your Unified webhooks receive native events. Official Toast docs: - [Webhook basics](https://doc.toasttab.com/doc/devguide/apiWebhookBasics.html) - [Subscriptions](https://doc.toasttab.com/doc/devguide/apiSubscriptions.html) - [Message signing](https://doc.toasttab.com/doc/devguide/apiMessageSigning.html) - [Webhooks reference](https://doc.toasttab.com/doc/devguide/apiWebhooksReference.html) ## Prerequisites 1. A Toast partner / developer account with access to the Toast Developer Portal. 2. At least one Unified **Toast** connection for the restaurant (auth must include the restaurant GUID in `other_auth_info[3]` / `Toast-Restaurant-External-ID`). 3. One or more Unified **native** webhooks on that connection for the object types you care about (created in the Unified dashboard or via the Unified webhooks API). Creating the Unified webhook stores a `toast-{restaurantGuid}` subscription marker so inbound Toast deliveries can be matched. ## 1. Get your Unified workspace webhook URL Unified receives Toast deliveries on the **workspace** webhook endpoint for the `toast` integration: ```plain text https://{api-host}/webhook/workspace/toast?workspace_id={your_workspace_id} ``` Replace: - `{api-host}`: your Unified API host for the region (US / EU / AU), e.g. `api.unified.to` - `{your_workspace_id}`: the Unified workspace that owns the Toast connection(s) Use the same URL for every Toast event category you subscribe to. Unified fans out each delivery to the matching native webhooks for that restaurant. ## 2. Create Toast webhook subscriptions In the **Toast Developer Portal → Webhooks**: 1. Open **Add webhook** (or ask Toast Integrations to create the subscription if portal create is not enabled for your partner account). 2. Set **Webhook URL** to the Unified URL from step 1. 3. Choose an **Event category** (one subscription per category; each category gets its own secret key). 4. Save the subscription and copy the **secret key** shown on the subscription details page. Repeat for each category you need (see mapping below). Toast environments (sandbox vs production) have separate subscriptions and secrets. To remove a subscription later, contact Toast Integrations or delete it in the Developer Portal. Unified `unsubscribe` only clears local matching cache; it cannot delete Toast-side subscriptions. ## 3. (Recommended) Store signing secrets on the connection Toast signs each POST with HMAC-SHA256 and sends `Toast-Signature` (Base64). The secret is **per event category**. If you store secrets on the Toast connection auth metadata, Unified verifies the signature before dispatching: ```json { "toast_webhook_secrets": { "stock": "", "menus": "", "partner": "", "order_updated": "", "channel_order_updated": "", "restaurant_availability": "" } } ``` Put this object under the connection's `auth.meta` (or merge into existing `auth.meta`). If a category has no secret configured, Unified accepts the delivery without signature checks (Toast treats signing as recommended, not required). ## 4. Event category → Unified object mapping | Toast event category | Toast event types | Unified object(s) | Unified webhook event | | --------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------- | | Orders (`eventCategory` = `order_updated` or `channel_order_updated`) | `order_updated`, `channel_order_updated` | `accounting_order`, `accounting_salesorder`, `payment_payment` | `updated` | | `stock` | `in_stock`, `out_of_stock`, `low_quantity` | `commerce_inventory` | `updated` | | `menus` | `menus_updated` | `commerce_item`, `commerce_collection` | `updated` (publish signal — re-list catalog) | | `restaurant_availability` / `restaurant_availability_toggle` | `availability_online`, `availability_offline`, `toggle_availability_*` | `commerce_availability` | `updated` | | `partner` | `partner_added`, `partner_updated`, `partner_removed` | `commerce_location`, `hris_location` | `created` / `updated` / `deleted` | ## 5. Create matching Unified native webhooks For each row you care about in the table above: 1. Create a Unified webhook with `webhook_type: native`, the Toast `connection_id`, the object type, and the event (`created` / `updated` / `deleted`). 2. Point the webhook's delivery URL at **your** application (Unified will POST converted unified objects there when Toast fires). 3. Ensure the Toast connection's restaurant GUID matches the location that will emit events. ## Troubleshooting | Symptom | What to check | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Toast shows failures to the URL | Workspace ID query param; HTTPS; region host | | Toast 2xx but no Unified delivery | Native webhook exists for that object/event; connection restaurant GUID matches `details.restaurantGuid`; subscription marker `toast-{guid}` on webhook meta | | Signature failures | Secret for that **exact** `eventCategory`; secrets differ per category and environment | | Empty menu payloads | Expected — treat as 're-list catalog' | | Partner events never match | Connection must already exist for that `restaurantGuid` | For creating the webhook in Unified, see [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks). ## How to Connect Anthropic to Real-Time SaaS Data with Unified.to MCP Server URL: https://docs.unified.to/guides/how_to_connect_anthropic_to_real_time_saas_data_with_unified_mcp_server # How to Connect Anthropic to Real-Time SaaS Data with Unified.to MCP Server ------ _August 28, 2025_ Anthropic's Claude models can power sophisticated workflows — but most products still need access to live customer data from CRMs, ATSs, HRIS, or accounting systems. That usually means writing brittle glue code, normalizing APIs, and maintaining webhook jobs. With [Unified.to](https://unified.to/)'s MCP server, you can skip that complexity. [Unified.to](https://unified.to/) exposes 1682 SaaS integrations as real-time, callable tools that Claude can use directly — no custom integration logic required. In this guide, we'll walk through how to connect Anthropic to Unified.to MCP so your application can give Claude real-time access to customer SaaS data and actions. ## Authentication Every request to the [Unified.to](https://unified.to/) MCP server must include a token. Send it as a bearer token in the `Authorization: Bearer {token}` header. Passing the token as a URL parameter is deprecated. There are two authentication flows: - **Private (workspace key + connection)** Use your Unified.to workspace API key and add a `connection` parameter. This should never be exposed publicly. - **Public (connection-scoped)** Generate a connection-scoped URL with the Core MCP tool `get_unified_connection_mcp_url` and `mode=user`; it returns a signed token scoped to one connection that never exposes your workspace API key. `mode=api` embeds your workspace API key and must not be shared. ## Integrating with Anthropic API Model names change often; use a current model from your provider's model list. **Manual Tool Orchestration:** 1. Call Unified's `/tools` endpoint and pass the tool list to Claude: ```python import os import anthropic import requests UNIFIED_API_KEY = os.environ["UNIFIED_API_KEY"] CONNECTION_ID = os.environ["CONNECTION_ID"] # Regional host without a path: US https://mcp-api.unified.to, EU https://mcp-api-eu.unified.to, AU https://api-au.unified.to UNIFIED_MCP_URL = os.environ.get("UNIFIED_MCP_URL", "https://mcp-api.unified.to") tools = requests.get( f"{UNIFIED_MCP_URL}/tools", headers={"Authorization": f"Bearer {UNIFIED_API_KEY}"}, params={"type": "anthropic", "connection": CONNECTION_ID}, ).json() client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY messages = [{"role": "user", "content": "List the candidates and then analyse the resumes from their applications."}] while True: resp = client.messages.create( model="claude-sonnet-5-5", max_tokens=4096, tools=tools, messages=messages, ) if resp.stop_reason != "tool_use": break messages.append({"role": "assistant", "content": resp.content}) results = [] for block in resp.content: if block.type == "tool_use": result = requests.post( f"{UNIFIED_MCP_URL}/tools/{block.name}/call", headers={ "Authorization": f"Bearer {UNIFIED_API_KEY}", "Content-Type": "application/json", }, params={"connection": CONNECTION_ID}, json=block.input, ) results.append({"type": "tool_result", "tool_use_id": block.id, "content": result.text}) messages.append({"role": "user", "content": results}) print("".join(b.text for b in resp.content if b.type == "text")) ``` 2. Claude will return a `tool_use` block: ```json [ { "type": "tool_use", "id": "toolu_...", "name": "list_candidates", "input": { "limit": "100" } } ] ``` 3. Call Unified's `/tools/{id}/call` endpoint with the arguments. 4. Append Claude's response as an assistant message, then return the result as a `tool_result` block at the start of the next user message: ```json [ { "type": "tool_result", "tool_use_id": "toolu_...", "content": "..." } ] ``` ## Controlling Tool Access Unified.to MCP gives you granular control over how tools are exposed to Anthropic: - `permissions` → restrict available scopes. - `tools` → allowlist specific tool IDs. - `aliases` → adds extra descriptions to improve tool matching. - `hide_sensitive=true` → strips names, emails and phone numbers from results. - `include_external_tools=true` → expose all vendor API endpoints, not just Unified.to's normalized models. These options help you keep Anthropic outputs predictable and secure in production-grade workflows. ### Sample Snippet ```javascript import Anthropic from '@anthropic-ai/sdk'; // Claude model const model = 'claude-sonnet-5-5'; // Unified connection ID that the MCP server should use const connection = process.env.CONNECTION_ID; // Regional host without a path: US https://mcp-api.unified.to, EU https://mcp-api-eu.unified.to, AU https://api-au.unified.to const UNIFIED_MCP_URL = process.env.UNIFIED_MCP_URL || 'https://mcp-api.unified.to'; // Set to true to expose the vendor's own API endpoints as tools const includeExternal = false; const message = 'List the candidates and then analyse the resumes from their applications.'; // Optional: list of specific tools that you want to use const toolIds = ['get_messaging_message','list_messaging_message'] const params = new URLSearchParams({ connection, include_external_tools: includeExternal ? 'true' : 'false', }); if (toolIds.length > 0) { params.append('tools', toolIds.join(',')); } const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }); const serverUrl = `${UNIFIED_MCP_URL}/mcp?${params.toString()}`; const completion = await anthropic.beta.messages.create({ model, max_tokens: 1024, messages: [ { role: 'user', content: message, }, ], stream: false, mcp_servers: [ { type: 'url', url: serverUrl, name: 'unifiedMCP', // Anthropic calls the MCP server, so send a connection-scoped signed token // (one connection only); the workspace API key stays on your server authorization_token: process.env.UNIFIED_MCP_TOKEN, }, ], tools: [{ type: 'mcp_toolset', mcp_server_name: 'unifiedMCP' }], betas: ['mcp-client-2025-11-20'], }); console.log('response', JSON.stringify(completion, null, 2)); ``` ## Coverage and Infrastructure Unified.to MCP is built for real-world AI use cases: - **22,566 real-time tools** (growing weekly) - **1682 integrations across 36 categories** (ATS, CRM, HRIS, Accounting, Messaging, File Storage, and more) - **No stored customer records**: [Unified.to](https://unified.to/) stores no customer records; logs hold operational metadata only - **Scoped security controls** — permissions, aliases, and PII redaction - **Multi-region deployment** — US, EU, and AU data centers for compliance [Unified.to](https://unified.to/) MCP works with major LLM providers, including **OpenAI, Anthropic, Google Gemini and Cohere.** That means you can build once and connect to any agent client. [Explore the MCP docs](https://docs.unified.to/mcp?utm_source=chatgpt.com) or [book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified?utm_source=chatgpt.com) ## How to Connect Cohere to Real-Time SaaS Data with Unified.to MCP Server URL: https://docs.unified.to/guides/how_to_connect_cohere_to_real_time_saas_data_with_unified_mcp_server # How to Connect Cohere to Real-Time SaaS Data with Unified.to MCP Server ------ _August 28, 2025_ Cohere's LLMs can power rich workflows — but most products still need access to live customer data from CRMs, ATSs, HRIS, or accounting systems. That usually means writing brittle glue code, normalizing APIs, and maintaining webhook jobs. With [Unified.to](https://unified.to/)'s MCP server, you can skip that complexity. [Unified.to](https://unified.to/) exposes 1682 SaaS integrations as real-time, callable tools that Cohere can use directly — no custom integration logic required. In this guide, we'll walk through how to connect Cohere to Unified.to MCP so your application can give Cohere real-time access to customer SaaS data and actions. ## Authentication Every request to the [Unified.to](https://unified.to/) MCP server must include a token. Send it as a bearer token in the `Authorization: Bearer {token}` header. Passing the token as a URL parameter is deprecated. There are two authentication flows: - **Private (workspace key + connection)** Use your Unified.to workspace API key and add a `connection` parameter. This should never be exposed publicly. - **Public (connection-scoped)** Generate a connection-scoped URL with the Core MCP tool `get_unified_connection_mcp_url` and `mode=user`; it returns a signed token scoped to one connection that never exposes your workspace API key. `mode=api` embeds your workspace API key and must not be shared. ## Integrating [Unified.to](https://unified.to/) MCP Server with Cohere Cohere's chat API can directly consume tools defined by [Unified.to](https://unified.to/) MCP. Model names change often; use a current model from your provider's model list. **Manual Tool Orchestration:** 1. Call Unified's `/tools?type=cohere` endpoint and pass the tool list to Cohere: ```python import os import cohere import requests UNIFIED_API_KEY = os.environ["UNIFIED_API_KEY"] CONNECTION_ID = os.environ["CONNECTION_ID"] # Regional host without a path: US https://mcp-api.unified.to, EU https://mcp-api-eu.unified.to, AU https://api-au.unified.to UNIFIED_MCP_URL = os.environ.get("UNIFIED_MCP_URL", "https://mcp-api.unified.to") MODEL = "command-a-plus-05-2026" tools = requests.get( f"{UNIFIED_MCP_URL}/tools", headers={"Authorization": f"Bearer {UNIFIED_API_KEY}"}, params={"type": "cohere", "connection": CONNECTION_ID}, ).json() co = cohere.ClientV2(api_key=os.environ["COHERE_API_KEY"]) messages = [{"role": "user", "content": "Score this candidate for the Software Engineer job."}] response = co.chat(model=MODEL, messages=messages, tools=tools) while response.message.tool_calls: messages.append(response.message) for tc in response.message.tool_calls: result = requests.post( f"{UNIFIED_MCP_URL}/tools/{tc.function.name}/call", headers={ "Authorization": f"Bearer {UNIFIED_API_KEY}", "Content-Type": "application/json", }, params={"connection": CONNECTION_ID}, data=tc.function.arguments or "{}", ) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": [{"type": "document", "document": {"data": result.text}}], }) response = co.chat(model=MODEL, messages=messages, tools=tools) print(response.message.content[0].text) ``` 2. When Cohere requests a tool call, call Unified's `/tools/{id}/call` endpoint. 3. Append the assistant message and a `tool` message with the `tool_call_id` and the result, then call `co.chat` again until Cohere stops requesting tools. ### Example Prompt and Response **Prompt:** ```plain text "Score this candidate for the Software Engineer job." ``` **What happens:** - Your app passes Cohere the tools Unified MCP exposes for the connection. - Cohere requests the tools that fetch the candidate and the job, and your app runs each call through `/tools/{id}/call`. - Cohere scores the candidate from the returned data. - Cohere returns a response like: **Response:** ```plain text Candidate Jane Doe scored 92/100 for the Software Engineer job. Strengths: Python, distributed systems. Recommended for interview. ``` ## Advanced MCP Options Unified.to MCP gives you granular control over how tools are exposed to Cohere: - `permissions` → restrict available scopes. - `tools` → allowlist specific tool IDs. - `aliases` → adds extra descriptions to improve tool matching. - `hide_sensitive=true` → strips names, emails and phone numbers from results. - `include_external_tools=true` → expose all vendor API endpoints, not just Unified.to's normalized models. These options help you keep Cohere outputs predictable and secure in production-grade workflows. ### Sample Snippet ```javascript import { CohereClientV2 } from 'cohere-ai'; // Unified connection ID that the tools should use const connection = process.env.CONNECTION_ID; // Regional host without a path: US https://mcp-api.unified.to, EU https://mcp-api-eu.unified.to, AU https://api-au.unified.to const UNIFIED_MCP_URL = process.env.UNIFIED_MCP_URL || 'https://mcp-api.unified.to'; // Set to true to expose the vendor's own API endpoints as tools const includeExternal = false; const message = 'Score this candidate for the Software Engineer job.'; // Optional: list of specific tools that you want to use const toolIds = ['get_messaging_message','list_messaging_message'] const cohereClient = new CohereClientV2({ token: process.env.COHERE_API_KEY || '', }); const params = new URLSearchParams({ connection, include_external_tools: includeExternal ? 'true' : 'false', }); if (toolIds.length > 0) { params.append('tools', toolIds.join(',')); } // type selects the tool-list format and applies to GET /tools only const toolsParams = new URLSearchParams(params); toolsParams.set('type', 'cohere'); const tools = await fetch(`${UNIFIED_MCP_URL}/tools?${toolsParams.toString()}`, { headers: { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}` }, }); const toolsJson = await tools.json(); const completion = await cohereClient.chat({ model: 'command-a-plus-05-2026', messages: [ { role: 'user', content: message, }, ], tools: toolsJson, }); for (const toolCall of completion?.message?.toolCalls || []) { // call mcp server with toolCallId const toolCallResponse = await fetch(`${UNIFIED_MCP_URL}/tools/${toolCall.function?.name}/call?${params.toString()}`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }, body: toolCall.function?.arguments || '{}', }); const toolCallResponseJson = await toolCallResponse.json(); console.log(JSON.stringify(toolCallResponseJson, null, 2)); } ``` ## Coverage and Infrastructure [Unified.to](https://unified.to/) MCP runs as part of the Unified API: - **22,566 real-time tools** (growing weekly) - **1682 integrations across 36 categories** (ATS, CRM, HRIS, Accounting, Messaging, File Storage, and more) - **No stored customer records**: [Unified.to](https://unified.to/) stores no customer records; logs hold operational metadata only - **Scoped security controls** — permissions, aliases, and PII redaction - **Multi-region deployment** — US, EU, and AU data centers for compliance This ensures your Cohere workflows aren't just demos — they're secure, scalable, and designed for production-grade use cases. ## Next Steps [Unified.to](https://unified.to/) MCP works with major LLM providers, including **OpenAI, Anthropic, Google Gemini and Cohere.** That means you can build once and connect to any agent client. [Explore the MCP docs](https://docs.unified.to/mcp?utm_source=chatgpt.com) or [book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified?utm_source=chatgpt.com) to see it live in action. ## How to Connect Google Gemini to Real-Time SaaS Data with Unified.to MCP Server URL: https://docs.unified.to/guides/how_to_connect_google_gemini_to_real_time_saas_data_with_unified_mcp_server # How to Connect Google Gemini to Real-Time SaaS Data with Unified.to MCP Server ------ _August 28, 2025_ Google Gemini can power powerful workflows — but most products still need access to **live customer data** from CRMs, ATSs, HRIS, or accounting systems. That usually means writing brittle glue code, normalizing APIs, and maintaining webhook jobs. With [**Unified.to**](https://unified.to/)**'s MCP server**, you can skip that complexity. [Unified.to](https://unified.to/) exposes 1682 SaaS integrations as **real-time, callable tools** that Gemini can use directly — no custom integration logic required. In this guide, we'll walk through how to connect Gemini to Unified.to MCP so your application can give Gemini real-time access to customer SaaS data and actions. ## Authentication Every request to the [Unified.to](https://unified.to/) MCP server must include a token. Send it as a bearer token in the `Authorization: Bearer {token}` header. Passing the token as a URL parameter is deprecated. There are two authentication flows: - **Private (workspace key + connection)** Use your Unified.to workspace API key and add a `connection` parameter. This should never be exposed publicly. - **Public (connection-scoped)** Generate a connection-scoped URL with the Core MCP tool `get_unified_connection_mcp_url` and `mode=user`; it returns a signed token scoped to one connection that never exposes your workspace API key. `mode=api` embeds your workspace API key and must not be shared. ## Integrating with Google Gemini API Model names change often; use a current model from your provider's model list. **Manual Tool Orchestration:** 1. Call Unified's `/tools?type=gemini` endpoint and pass the tool list as `function_declarations` to Gemini. 2. Gemini will return a function call request: ```plain text { "functionCall": { "id": "...", "name": "list_candidates", "args": { "limit": "100" } } } ``` 3. Call Unified's `/tools/{id}/call` endpoint. 4. Respond to Gemini with the tool result as a `functionResponse` in your next message: ```json { "role": "user", "parts": [ { "functionResponse": { "id": "...", "name": "list_candidates", "response": { ... } } } ] } ``` ## Controlling Tool Access Unified.to MCP gives you granular control over how tools are exposed to Gemini: - `permissions` → restrict available scopes. - `tools` → allowlist specific tool IDs. - `aliases` → adds extra descriptions to improve tool matching. - `hide_sensitive=true` → strips names, emails and phone numbers from results. - `include_external_tools=true` → expose all vendor API endpoints, not just Unified.to's normalized models. These options help you keep Gemini outputs predictable and secure in production-grade workflows. ### Sample Snippet: ```javascript import { GoogleGenAI, mcpToTool } from '@google/genai'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; // Construct a new GEMINI client via GoogleGenAI // Gemini model const model = 'gemini-3.8-flash'; // Unified connection ID that the MCP server should use const connection = process.env.CONNECTION_ID; // Regional host without a path: US https://mcp-api.unified.to, EU https://mcp-api-eu.unified.to, AU https://api-au.unified.to const UNIFIED_MCP_URL = process.env.UNIFIED_MCP_URL || 'https://mcp-api.unified.to'; const message = 'Score this candidate for the Software Engineer job.'; // Optional: list of specific tools that you want to use const toolIds = ['get_messaging_message','list_messaging_message'] const gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || '', }); // Construct the required params const params = new URLSearchParams({ connection, include_external_tools: 'true', // set to 'false' to hide the vendor's own API endpoints }); if (toolIds.length > 0) { params.append('tools', toolIds.join(',')); } const serverUrl = `${UNIFIED_MCP_URL}/mcp?${params.toString()}`; const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { requestInit: { headers: { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}` }, }, }); const client = new Client({ name: 'unified-mcp', version: '1.0.0', }); await client.connect(transport); const completion = await gemini.models.generateContent({ model, contents: message, config: { tools: [mcpToTool(client)], }, }); console.log(completion.text); await client.close(); ``` ## Coverage and Infrastructure Unified.to MCP is built for real-world AI use cases: - **22,566 real-time tools** (growing weekly) - **1682 integrations across 36 categories** (ATS, CRM, HRIS, Accounting, Messaging, File Storage, and more) - **No stored customer records**: [Unified.to](https://unified.to/) stores no customer records; logs hold operational metadata only - **Scoped security controls** — permissions, aliases, and PII redaction - **Multi-region deployment** — US, EU, and AU data centers for compliance [Unified.to](https://unified.to/) MCP works with major LLM providers, including **OpenAI, Anthropic, Google Gemini and Cohere.** That means you can build once and connect to any agent client. - [Explore the MCP docs](https://docs.unified.to/mcp?utm_source=chatgpt.com) - [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified?utm_source=chatgpt.com) ## How to Connect LLMs to Real-Time SaaS Data with Unified.to MCP Server URL: https://docs.unified.to/guides/how_to_connect_llms_to_real_time_saas_data_with_unified_mcp_server # How to Connect LLMs to Real-Time SaaS Data with Unified.to MCP Server ------ _August 25, 2025_ [Unified's MCP](https://docs.unified.to/mcp) server allows your application to give an LLM API real-time access to your customers' SaaS data and perform actions on that data, without you having to write custom business-logic code for every integration, which can often make things messy. For example, imagine you're building a [candidate assessment workflow](https://docs.unified.to/guides/how_to_build_a_candidate_assessment_product_with_unified), we'd usually call the API to fetch the data, and feed it into the LLM for it to analyze that data. With the Unified MCP server, we can enable OpenAI (or another LLM API) to directly access the data and perform actions making the workflow much simpler and cleaner. ## The Flow Here's how an LLM API connects to Unified's MCP server: - Your app connects to the MCP server using the HTTP endpoint. - You authenticate with a token in the `Authorization: Bearer` header: your workspace API key plus a customer's `connection` ID from your own server, or a connection-scoped signed token when an LLM provider or agent calls the server. - The LLM can then discover and call tools (like 'fetch candidate', 'score candidate', 'update job status') in real time. ## Authentication Send the token in the Authorization header (`Authorization: Bearer {token}`). Don't put it in the URL. Use the Streamable HTTP host for your workspace's region (US `https://mcp-api.unified.to/mcp`, EU `https://mcp-api-eu.unified.to/mcp`, AU `https://api-au.unified.to/mcp`); a credential works only on its own region's host. **LLM providers, agents and third-party clients:** Use a connection-scoped signed token, generated with `get_unified_connection_mcp_url` (mode=user). It works for one connection only, so your workspace API key stays on your server. **Private (Direct LLM API):** From your own server, use your [Unified.to](https://unified.to/) workspace API key as the token, and include a `connection` parameter for the customer's connection ID. _Example:_ ```plain text https://mcp-api.unified.to/mcp?connection= Authorization: Bearer ``` _Note: Keep the workspace API key on your server. Never put it in a URL or a client config._ ## Building the application with an LLM API and MCP If your application is using an LLM API that supports MCP (like OpenAI), you can use the [`mcp-use`](https://github.com/mcp-use/mcp-use) Python package to connect to Unified MCP and access your customer's data and actions. ### Dependencies The full list of dependencies and setup you need to do to get things running. ```bash mkdir unified-mcp-client cd unified-mcp-client python -m venv .venv source .venv/bin/activate pip install mcp-use python-dotenv requests openai touch client.py ``` ### Setting up your secrets Add your Unified API key, your customer's connection ID, and your workspace secret to a `.env` file: ```bash echo "UNIFIED_API_KEY=" >> .env echo "CONNECTION_ID=" >> .env echo "UNIFIED_MCP_TOKEN=" >> .env echo ".env" >> .gitignore ``` # The MCP Client Class Here's an example using the `mcp-use` package: ```python # client.py import os import asyncio from dotenv import load_dotenv from mcp_use import MCPClient load_dotenv() CONNECTION_ID = os.getenv("CONNECTION_ID") UNIFIED_API_KEY = os.getenv("UNIFIED_API_KEY") # stays on your server # Use your region's host: US mcp-api.unified.to, EU mcp-api-eu.unified.to, AU api-au.unified.to MCP_URL = f"https://mcp-api.unified.to/mcp?connection={CONNECTION_ID}" client = MCPClient.from_dict({ "mcpServers": { "unified": { "url": MCP_URL, "headers": {"Authorization": f"Bearer {UNIFIED_API_KEY}"}, } } }) async def main(): await client.create_all_sessions() session = client.get_session("unified") # List available tools tools = await session.list_tools() print("Available tools:", [tool.name for tool in tools]) asyncio.run(main()) ``` ## Integrating with OpenAI (LLM API) Once your MCP client is set up, you can connect it to OpenAI's API, which natively supports remote MCP servers. ### Configure OpenAI to Use Unified MCP When you send a Responses API request, specify the MCP server as a tool source and pass a connection-scoped token in the `authorization` field. Here's a **candidate assessment example**: ```python from openai import OpenAI openai_client = OpenAI() # reads OPENAI_API_KEY response = openai_client.responses.create( model="gpt-4o", tools=[{ "type": "mcp", "server_label": "unified", "server_url": MCP_URL, # no token in the URL # Connection-scoped signed token: works for one connection only "authorization": os.getenv("UNIFIED_MCP_TOKEN"), "require_approval": "never", }], input="Score this candidate for the Software Engineer job.", ) print(response.output_text) ``` **No backend glue code required: OpenAI orchestrates the tool calls via Unified MCP.** ## Integrating with Anthropic API **Manual Tool Orchestration:** 1. Call Unified's `/tools` endpoint and pass the tool list to Claude: ```python resp = client.messages.create( model="claude-3-5-sonnet-20241022", tools=tools, input="list the candidates and then analyse the resumes from their applications", ) ``` 2. Claude will return a `tool_use` block: ```json [ { "type": "tool_use", "id": "toolu_...", "name": "list_candidates", "input": { "limit": "100" } } ] ``` 3. Call Unified's `/tools/{id}/call` endpoint with the arguments. 4. Return the result to Claude as a `tool_result` block in your next message: ```json [ { "type": "tool_result", "tool_use_id": "toolu_...", "content": "..." } ] ``` ## Integrating with Google Gemini API **Manual Tool Orchestration:** 1. Call Unified's `/tools` endpoint and pass the tool list as `function_declarations` to Gemini. 2. Gemini will return a function call request: ```plain text function_call { name: "list_candidates" args { fields { key: "limit" value { string_value: "100" } } } } ``` 3. Call Unified's `/tools/{id}/call` endpoint. 4. Respond to Gemini with the tool result as a `functionResponse` in your next message: ```json { "role": "user", "parts": [ { "functionResponse": { "name": "list_candidates", "response": { ... } } } ] } ``` ## Integrating with Cohere **Manual Tool Orchestration:** 1. Call Unified's `/tools?type=cohere` endpoint and pass the tool list to Cohere: ```python response = co.chat( model="command-a-03-2025", messages=messages, tools=tools ) ``` 2. When Cohere requests a tool call, call Unified's `/tools/{id}/call` endpoint. 3. Pass the tool result back to Cohere in your next message ### Example Prompt and Response **Prompt:** ```plain text "Score this candidate for the Software Engineer job." ``` **What happens:** - Your chosen LLM discovers the available tools from Unified MCP (e.g., `fetch-candidate`, `fetch-job`, `score-candidate`). - The LLM calls `fetch-candidate` and `fetch-job` tools to get the data. - The LLM calls `score-candidate` with the data. - The LLM returns a response like: **Response:** ```plain text Candidate Jane Doe scored 92/100 for the Software Engineer job. Strengths: Python, distributed systems. Recommended for interview. ``` ## How to Connect OpenAI to Real-Time SaaS Data with Unified.to MCP Server URL: https://docs.unified.to/guides/how_to_connect_openai_to_real_time_saas_data_with_unified_mcp_server # How to Connect OpenAI to Real-Time SaaS Data with Unified.to MCP Server ------ _August 28, 2025_ OpenAI's LLMs can power rich workflows — but most products still need access to **live customer data** from CRMs, ATSs, HRIS, or accounting systems. That usually means writing brittle glue code, normalizing APIs, and maintaining webhook jobs. With [Unified.to](https://unified.to/)'s MCP server, you can skip that complexity. [Unified.to](https://unified.to/) exposes 1682 SaaS integrations as real-time, callable tools that OpenAI can use directly — no custom integration logic required. In this guide, we'll walk through how to connect OpenAI to Unified.to MCP so your application can give OpenAI real-time access to customer SaaS data and actions. ## Authentication Every request to the [Unified.to](https://unified.to/) MCP server must include a token. Send it as a bearer token in the `Authorization: Bearer {token}` header. Passing the token as a URL parameter is deprecated. There are two authentication flows: - **Private (workspace key + connection)** Use your Unified.to workspace API key and add a `connection` parameter. This should never be exposed publicly. - **Public (connection-scoped)** Generate a connection-scoped URL with the Core MCP tool `get_unified_connection_mcp_url` and `mode=user`; it returns a signed token scoped to one connection that never exposes your workspace API key. `mode=api` embeds your workspace API key and must not be shared. ## Integrating with OpenAI (LLM API) Once your MCP client is set up, you can connect it to OpenAI's API, which natively supports remote MCP servers. ### Configure OpenAI to Use Unified MCP Model names change often; use a current model from your provider's model list. When you send a Responses API request, add Unified MCP as a remote MCP tool (`"type": "mcp"`). OpenAI calls the MCP server on your behalf, so pass a connection-scoped signed token (works for one connection only) in the `authorization` field and keep your workspace API key on your server. Here's a **candidate assessment example**: ```python import os from openai import OpenAI # Connection-scoped signed token from get_unified_connection_mcp_url (mode=user) UNIFIED_MCP_TOKEN = os.environ["UNIFIED_MCP_TOKEN"] CONNECTION_ID = os.environ["CONNECTION_ID"] # Regional host without a path: US https://mcp-api.unified.to, EU https://mcp-api-eu.unified.to, AU https://api-au.unified.to UNIFIED_MCP_URL = os.environ.get("UNIFIED_MCP_URL", "https://mcp-api.unified.to") client = OpenAI() # reads OPENAI_API_KEY response = client.responses.create( model="gpt-6-astra", tools=[{ "type": "mcp", "server_label": "unified", "server_url": f"{UNIFIED_MCP_URL}/mcp?connection={CONNECTION_ID}", "authorization": UNIFIED_MCP_TOKEN, "require_approval": "never", }], input="Score this candidate for the Software Engineer job.", ) print(response.output_text) ``` OpenAI lists and calls the Unified MCP tools itself, so no backend glue code is required. ## Advanced MCP Options Unified.to MCP gives you granular control over how tools are exposed to OpenAI: - `permissions` → restrict available scopes. - `tools` → allowlist specific tool IDs. - `aliases` → adds extra descriptions to improve tool matching. - `hide_sensitive=true` → strips names, emails and phone numbers from results. - `include_external_tools=true` → expose all vendor API endpoints, not just Unified.to's normalized models. These options help you keep OpenAI outputs predictable and secure in production-grade workflows. ### Sample Snippet ```javascript import OpenAI from 'openai'; // OpenAI model const model = 'gpt-6-astra'; // Unified connection ID that the MCP server should use const connection = process.env.CONNECTION_ID; // Regional host without a path: US https://mcp-api.unified.to, EU https://mcp-api-eu.unified.to, AU https://api-au.unified.to const UNIFIED_MCP_URL = process.env.UNIFIED_MCP_URL || 'https://mcp-api.unified.to'; // Set to true to expose the vendor's own API endpoints as tools const includeExternal = false; const message = 'Score this candidate for the Software Engineer job.'; // Optional: list of specific tools that you want to use const toolIds = ['get_messaging_message','list_messaging_message'] const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY || '', }); const params = new URLSearchParams({ connection, include_external_tools: includeExternal ? 'true' : 'false', }); if (toolIds.length > 0) { params.append('tools', toolIds.join(',')); } const serverUrl = `${UNIFIED_MCP_URL}/mcp?${params.toString()}`; const completion = await openai.responses.create({ model, tools: [ { type: 'mcp', server_label: 'unifiedMCP', server_url: serverUrl, // change url as needed // Connection-scoped signed token (one connection only) authorization: process.env.UNIFIED_MCP_TOKEN, require_approval: 'never', }, ], instructions: `You are a helpful assistant`, input: message, }); console.log(completion.output_text); ``` ## Coverage and Infrastructure [Unified.to](https://unified.to/) MCP runs as part of the Unified API: - **22,566 real-time tools** (growing weekly) - **1682 integrations across 36 categories** (ATS, CRM, HRIS, Accounting, Messaging, File Storage, and more) - **No stored customer records**: [Unified.to](https://unified.to/) stores no customer records; logs hold operational metadata only - **Scoped security controls** — permissions, aliases, and PII redaction - **Multi-region deployment** — US, EU, and AU data centers for compliance This ensures your OpenAI workflows aren't just demos — they're secure, scalable, and designed for production-grade use cases. [Unified.to](https://unified.to/) MCP works with major LLM providers, including **OpenAI, Anthropic, Google Gemini and Cohere.** That means you can build once and connect to any agent client. - [Explore the MCP docs](https://docs.unified.to/mcp?utm_source=chatgpt.com) - [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified?utm_source=chatgpt.com) ## How to Connect Zoom and Zoom Phone URL: https://docs.unified.to/guides/how_to_connect_zoom_and_zoom_phone # How to Connect Zoom and Zoom Phone ------ _July 2, 2026_ This guide walks you through creating a Zoom OAuth app, configuring the right permissions, and connecting your Zoom account. You will need a Zoom account with admin access for some features (user directory, call logs, and recordings). --- ## **What you need** - A [**Zoom Marketplace**](https://marketplace.zoom.us/) developer account - Zoom account admin access (required for directory, group, and Zoom Phone admin scopes) - A Zoom Phone license if you plan to use the Zoom Phone integration - Your Unified workspace (to store credentials and start the connection) > **Note:** Zoom and Zoom Phone are separate integrations in Unified. You can use one OAuth app for both, as long as that app includes the scopes for every feature you need. --- ## **Step 1: Create a Zoom OAuth app** 1. Sign in to the [**Zoom App Marketplace**](https://marketplace.zoom.us/). 2. Go to **Develop** → **Build App**. 3. Choose **General App** and click **Create**. 4. Enter an app name and basic details. On the **Basic Information** page: 1. Open **App Credentials** and note your **Development** Client ID and Client Secret. - Use development credentials while the app is still in testing. - After the app is published, switch to **Production** credentials. 2. Under **OAuth Information**, configure: - **OAuth Redirect URL** — use the exact URL for your Unified region (see below). - **OAuth Allow List** — add the same URL (or the base domain if Zoom accepts it). 3. If **Strict Mode URL** is enabled, the redirect URL must match character-for-character. ### **OAuth redirect URL by region** Add the redirect URL that matches where your Unified workspace is hosted: | **Region** | **Redirect URL** | | ---------- | -------------------------------------- | | US | `https://api.unified.to/oauth/code` | | EU | `https://api-eu.unified.to/oauth/code` | | AU | `https://api-au.unified.to/oauth/code` | > **Important:** Copy the URL exactly. A trailing slash, wrong subdomain, or `http` instead of `https` will cause the connection to fail. --- ## **Step 2: Add the required scopes (permissions)** In your Zoom app, open the **Scopes** page and add the permissions below. Only add scopes for the features you actually use. For each scope, Zoom may ask you to describe why your app needs it. Keep the explanation tied to your use case (e.g. 'Read call history for CRM logging' or 'List meetings for calendar sync'). ### **Required for sign-in (Zoom and Zoom Phone)** Add these for any connection: - `user_profile` - `user_info:read` - `user:read` ### **Zoom — users and groups** If you sync users or groups: | **Feature** | **Scopes to add** | | ------------ | ---------------------------------------------------- | | Read users | `user:read:user:admin`, `user:read:list_users:admin` | | Write users | `user:write:admin` | | Read groups | `group:read:admin`, `group:read:list_groups:admin` | | Write groups | `group:write:admin` | ### **Zoom — meetings and webinars** If you work with meetings or webinars: | **Feature** | **Scopes to add** | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Read meetings | `meeting:read:list_meetings` | | Create / update / delete meetings | `meeting:write:meeting`, `meeting:update:meeting`, `meeting:delete:meeting` | | Read webinars | `webinar:read:list_webinars` | | Manage webinars | `webinar:write:webinar`, `webinar:update:webinar`, `webinar:delete:webinar`, `webinar:write:registrant`, `webinar:update:registrant`, `webinar:delete:registrant`, `webinar:write:panelist`, `webinar:update:panelist`, `webinar:delete:panelist` | ### **Zoom Phone — calls, recordings, and users** If you use the Zoom Phone integration: | **Feature** | **Scopes to add** | | --------------------- | ---------------------------- | | Read Zoom Phone users | `phone:read:user:admin` | | Read call history | `phone:read:call_log:admin` | | Read call recordings | `phone:read:recording:admin` | > **Note:** Zoom Phone scopes require a Zoom Phone license on the account you connect. --- ## **Step 3: Add credentials in Unified** 1. In the Unified dashboard, open **Integrations**. 2. Enable **Zoom** and/or **Zoom Phone**. 3. Enter the **Client ID** and **Client Secret** from your Zoom app (development credentials while testing; production credentials after publish). 4. Save the integration. The Client ID and Client Secret in Unified must match the values shown in the Zoom Marketplace for the same app and environment (Development vs Production). --- ## **Step 4: Connect a Zoom account** 1. Start a new connection for Zoom or Zoom Phone (from the dashboard or your app's auth flow). 2. You will be redirected to Zoom to sign in and approve the requested permissions. 3. After approval, you are redirected back and the connection is created. > **Tip:** The Zoom user who authorizes the connection should be an account admin if you use admin-level scopes (users, groups, call logs, recordings). --- ## **Testing vs published apps** Zoom apps start in **development / testing** mode. This affects who can connect. ### **While the app is in testing** - Only Zoom accounts added as **authorized testers** (or users on the same Zoom account as the app owner) can complete OAuth. - Use the **Development** Client ID and Client Secret in Unified. - For each customer Zoom account that needs to connect **before** the app is published, that account must be added as a tester in the Zoom Marketplace: 1. Open your app in the [**Zoom Marketplace Developer portal**](https://marketplace.zoom.us/develop/applications). 2. Find **Add app for testing** / **Local Test** / **Authorized testers** (wording may vary). 3. Add the customer's Zoom email or account. If a customer sees an error that the app is not authorized, they are usually not on the tester list yet. ### **When the app is published** - Any Zoom customer can connect without being added as a tester. - Switch Unified to the **Production** Client ID and Client Secret from the Zoom app. - Existing connections may need to be **re-authorized** after you switch credentials or publish. > **Note:** Publishing goes through Zoom's review process. Zoom may require security review for certain scopes and can take several business days. Plan ahead if you need many customers to connect on a fixed timeline. --- ## **Checklist before connecting** Use this quick checklist if a connection fails: - [ ] Client ID and Client Secret in Unified match the Zoom app (same environment: Development or Production). - [ ] OAuth Redirect URL in Zoom is exactly `https://api.unified.to/oauth/code` (or the EU/AU URL for your region). - [ ] The same URL is on the OAuth Allow List. - [ ] All scopes your product needs are added in the Zoom app. - [ ] If the app is still in testing, the connecting user's Zoom account is an authorized tester. - [ ] The authorizing user has admin rights when using admin scopes. - [ ] For Zoom Phone: the account has Zoom Phone enabled. --- ## **Troubleshooting** ### **'Invalid redirect URI' or redirect errors** The redirect URL in Zoom does not match what Unified sends. Confirm the exact URL for your region and that Strict Mode is not blocking a valid redirect. ### **'App not authorized' or customer cannot sign in** The app is likely still in testing and the customer's Zoom account is not an authorized tester. Add them in the Zoom Marketplace, or publish the app. ### **Connection works but data is missing** Usually a missing scope. Compare the scopes in your Zoom app with the tables in Step 2 and add any that apply to the features you use. ### **Zoom Phone returns no calls or recordings** - Confirm `phone:read:call_log:admin` and/or `phone:read:recording:admin` are added. - Confirm the connected account has Zoom Phone and admin access to phone data. ### **After publishing, connections stopped working** Update Unified with the **Production** Client ID and Client Secret, then ask affected users to connect again. --- ## **Related Zoom products** Unified also supports **Zoom Calendar** as a separate integration with its own calendar-specific scopes. If you only need Zoom meetings, webinars, users, and Zoom Phone, follow this guide. If you need Zoom's native calendar product, enable the Zoom Calendar integration and add its scopes in the same (or a separate) Zoom OAuth app. For Zoom developer documentation, see [**developers.zoom.us**](https://developers.zoom.us/). ## How to create a Connection In Microsoft Teams URL: https://docs.unified.to/guides/how_to_create_a_connection_in_microsoft_teams # How to create a Connection In Microsoft Teams ------ _June 5, 2026_ Most Microsoft products follow the same standard process for creating a connection, but Microsoft Teams is slightly different. To access Unified Communications (UC) objects such as call records, Microsoft requires **application-level permissions** in addition to delegated permissions. This is necessary because Microsoft does not allow access to certain Teams resources using delegated permissions alone. Before your end-users can authorize a MS Teams connection, you must first register an application in Microsoft Azure and configure the required permissions. ## Step 1: Register Your Microsoft Application Follow the guide below to register your application and obtain your **Client ID** and **Client Secret**: [https://docs.unified.to/guides/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365#how-to-get-your-oauth-2-credentials-for-microsoft-dynamics-365](https://docs.unified.to/guides/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365#how-to-get-your-oauth-2-credentials-for-microsoft-dynamics-365) Once you have completed the application registration, continue with the permission setup steps below. ## Microsoft Teams Permission Types Microsoft Teams uses two types of permissions: 1. **Delegated Permissions** 2. **Application Permissions** ### Resources That Require Delegated Permissions The following categories only require delegated permissions: - Messaging - HRIS - Calendar ### Resources That Require Application Permissions The following category requires application permissions: - Call Records --- ## Step 2: Configure Delegated Permissions 1. Open your registered application in Azure Portal. 2. Navigate to **API Permissions**. 3. Click **Add a permission**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_connection_in_microsoft_teams-0.png) 4. Select **Microsoft Graph**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_connection_in_microsoft_teams-1.png) 5. Select **Delegated permissions**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_connection_in_microsoft_teams-2.png) 6. Search for and select the required delegated permissions. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_connection_in_microsoft_teams-3.png) 7. Click **Add permissions**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_connection_in_microsoft_teams-4.png) --- ## Step 3: Configure Application Permissions (Required for Call Records) If you need access to Call Records, repeat the steps above but select **Application permissions** instead of Delegated permissions. Search for and add the following permissions: ```plain text CallRecords.Read.All OnlineMeetingRecording.Read.All OnlineMeetingTranscript.Read.All CallRecordings.Read.All CallTranscripts.Read.All ``` ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_connection_in_microsoft_teams-5.png) After selecting all required permissions, click **Add permissions**. ## Step 4: Create the Connection After configuring permissions, create your Microsoft Teams connection. If you have not yet obtained your Client ID and Client Secret, follow this guide: [https://docs.unified.to/guides/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365#how-to-get-your-oauth-2-credentials-for-microsoft-dynamics-365](https://docs.unified.to/guides/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365#how-to-get-your-oauth-2-credentials-for-microsoft-dynamics-365) Enter the following credentials: - Client ID - Client Secret ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_connection_in_microsoft_teams-6.png) Click **Activate**. --- ## You're Ready to Go Your Microsoft Teams integration is now configured and ready to use. If you have any questions or encounter any issues during setup, please contact our support team. ## How to create a token-based connection in Highlevel URL: https://docs.unified.to/guides/how_to_create_a_token_based_connection_in_highlevel # How to create a token-based connection in Highlevel ------ _April 23, 2026_ This guide walks you through creating a **HighLevel token-based connection** using a **sub-account (location) API key** in Unified. --- ## ⚠️ Important Context HighLevel operates on two levels: - **Agency level (company)** - **Sub-account level (location)** ✅ _(token-based authentication works only here)_ To access **CRM** and **HRIS** data, you must use a **sub-account (location)** API key. > **Note:** HighLevel offers multiple ways to create connections. To avoid issues, follow every step in this guide carefully. --- ## ✅ Prerequisites - You must have **Admin** or **Owner** access to the sub-account. ## 🔧 Step-by-Step Setup 1. Navigate to Your Sub-Account Go to your **HighLevel sub-account (location)**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_token_based_connection_in_highlevel-0.png) 2. Open Private Integrations Go to **Settings → Private Integrations** _(inside the sub-account)_ ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_token_based_connection_in_highlevel-1.png) 3. Create a New Integration Click **Create Integration** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_token_based_connection_in_highlevel-2.png) 4. Name Your Integration Provide a clear name for your token (e.g., `Unified Integration`) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_token_based_connection_in_highlevel-3.png) 5. Select Required Scopes Choose scopes based on your use case: | Permission | Resource | HighLevel Scopes | | ---------- | ------------- | ------------------------------------------------ | | Read | CRM Contact | `contacts.readonly` | | Write | CRM Contact | `contacts.write` | | Read | CRM Company | `businesses.readonly`, `locations/tags.readonly` | | Write | CRM Company | `businesses.write`, `locations/tags.write` | | Read | CRM Deal | `opportunities.readonly` | | Write | CRM Deal | `opportunities.write` | | Read | HRIS Employee | `users.readonly` | | Write | HRIS Employee | `users.write` | | Read | CRM Lead | `contacts.readonly` | | Write | CRM Lead | `contacts.write` | | Read | CRM Pipeline | `opportunities.readonly` | | Read | CRM Event | `forms.readonly`, `contacts.readonly` | | Write | CRM Event | `contacts.write` | **Reference:** [https://marketplace.gohighlevel.com/docs/Authorization/Scopes](https://marketplace.gohighlevel.com/docs/Authorization/Scopes) 6. Create and Confirm Click **Create, t**hen click **Confirm** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_token_based_connection_in_highlevel-4.png) 7. Copy API Token You will receive an **API Token** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_token_based_connection_in_highlevel-5.png) Store it securely (you won't be able to retrieve it later) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_token_based_connection_in_highlevel-6.png) 8. Get Location ID To find your **Location ID**: ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_token_based_connection_in_highlevel-7.png) Look at the URL of your sub-account Example: ```plain text https://app.gohighlevel.com/v2/location/tG38xt5twyoGISw4o92p/settings/private-integrations/... ``` The value after `/location/` is your **Location ID** → `tG38xt5twyoGISw4o92p` 9. Connect in Unified - Go to: [https://app.unified.to/integrations/highlevel](https://app.unified.to/integrations/highlevel) - Select **API TOKEN** - Click **Activate** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_token_based_connection_in_highlevel-8.png) 10. Create Connection Paste **API Token and Location ID, then c**lick **Create Connection** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_token_based_connection_in_highlevel-9.png) --- ## ✅ Done Your HighLevel token-based connection should now be successfully set up in Unified. --- If it doesn't work, double-check: - You created the token at the **sub-account level** - The correct **scopes** are selected - The **Location ID** matches the sub-account If you have any more doubts or issues, please contact the [Unified.to](https://unified.to/) team for support. ## How to Create a Unified.to Connection to HighLevel URL: https://docs.unified.to/guides/how_to_create_a_unified_connection_to_highlevel # How to Create a Unified.to Connection to HighLevel ------ _March 19, 2026_ This guide explains how to connect an existing HighLevel account to [Unified.to](https://unified.to/). It assumes: - You already have a Unified.to account - You already have a HighLevel account - You have access to a HighLevel Developer (Agency) account --- ## Overview To create a connection between Unified.to and HighLevel, you will: 1. Create or configure a HighLevel OAuth app 2. Collect required credentials (Client ID, Client Secret, etc.) 3. Use Unified.to to create a connection 4. Authorize the connection via OAuth --- ## Step 1: Create a HighLevel OAuth App 1. Log in to your HighLevel Developer (Agency) account [link](https://marketplace.gohighlevel.com/apps) 2. Navigate to the **App Dashboard**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_unified_connection_to_highlevel-0.png) 1. Click **Create App** 2. Fill in the required details: - **App Name**: Any name (e.g., Unified Integration) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_unified_connection_to_highlevel-1.png) - **Redirect URL**: In **Advanced settings → Auth**, set the redirect URL to the value shown on the integration's **OAuth2 Credentials** page in [Unified.to](https://unified.to/). ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_unified_connection_to_highlevel-2.png) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_unified_connection_to_highlevel-3.png) - **Scopes**: Use the scopes listed on the HighLevel app setup page. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_unified_connection_to_highlevel-4.png) In [Unified.to](https://unified.to/), you can also find the required scopes listed below the integration page. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_unified_connection_to_highlevel-5.png) 3. Save the app in HighLevel. --- ## Step 2: Get Credentials After creating the app, go to **Manage → Secrets** and add: ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_unified_connection_to_highlevel-6.png) - **Client ID** - **Client Secret** To find the **Version ID**, open the install link and copy the Version ID from the resulting page. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_unified_connection_to_highlevel-7.png) Keep these secure. You will need them in Unified.to. --- ## Step 3: Create Connection in Unified.to 1. Log in to your Unified.to dashboard 2. Navigate to **Integrations**. 3. Select **HighLevel** as the integration 4. Enter the following details: - **Client ID** (from HighLevel) - **Client Secret** (from HighLevel) - **Version ID** (from HighLevel) 5. Click Activate 6. Go to **Embedded Components**. 7. Click **HighLevel**. --- ## Step 4: Authorize the Connection 1. You will be redirected to HighLevel 2. Log in (if not already logged in) 3. Select the account/location you want to connect 4. Approve the requested permissions Once approved, you will be redirected back to Unified.to --- ## Common Issues & Troubleshooting ### 1. Invalid Redirect URI - Ensure the redirect URL in HighLevel exactly matches the one used by Unified.to ### 2. Unauthorized / 401 Errors - Double-check Client ID and Client Secret - Ensure the OAuth app is active ### 3. Missing Data - Ensure correct scopes are selected in HighLevel - Some data may depend on the selected location/account --- ## Notes - HighLevel uses location-based access. Make sure you select the correct location during authorization - You can create multiple connections for different locations if needed --- If you run into issues, reach out to the Unified.to support team with: - Connection ID - Error logs - Steps to reproduce ## How to Create a Xero Connection in Unified.to URL: https://docs.unified.to/guides/how_to_create_a_xero_connection_in_unified # How to Create a Xero Connection in Unified.to ------ _April 2, 2026_ Follow the steps below to create a Xero application and connect it to Unified.to. ## 1. Sign in to the Xero Developer Portal Go to the Xero Developer portal: [https://developer.xero.com/app/manage/](https://developer.xero.com/app/manage/) Sign in with your Xero account. ## 2. Create a New App From the dashboard, click **Create New App**. Choose the appropriate app type and fill in the required details: - App name - Company or organization name - Privacy policy URL - Terms and conditions URL (if required) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_xero_connection_in_unified-0.png) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_xero_connection_in_unified-1.png) ## 3. Add the Redirect URI While creating the app, add the OAuth 2.0 Redirect URI provided by Unified.to. You can find the correct redirect URI here: [https://app.unified.to/integrations/xero?tab=oauth2](https://app.unified.to/integrations/xero?tab=oauth2) > Important: The redirect URI may differ depending on your Unified.to region. Make sure you copy the exact value shown in your Unified.to dashboard. Examples: - US region - EU region - AU region If the redirect URI does not match exactly, Xero authentication will fail. After adding the redirect URI, click **Create App**. --- ## ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_xero_connection_in_unified-2.png) ## 4. Copy the Client ID and Client Secret After the app is created, open the app and go to the **Configuration** tab. There you will find: - Client ID - Client Secret > Keep the Client Secret secure. Do not share it publicly or commit it to source control. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_xero_connection_in_unified-3.png) ## 5. Add Credentials to Unified.to Open the Xero integration settings page in Unified.to: https://app.unified.to/integrations/xero?tab=auth Paste the following values: - Client ID - Client Secret Click **Activate**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_a_xero_connection_in_unified-4.png) ## 6. Create a Connection Using Embedded Components To create a customer-facing connection flow: 1. Go to **Embedded Components** in Unified.to. 2. Select the **Xero** integration. 3. Launch the embedded authorization flow. 4. Complete the authorization process. This allows your end users to connect their Xero account directly from your application. ## Notes ### Journals Endpoint Availability The `Journals` endpoint is a premium Xero feature. According to Xero's documentation, the Journals API is only available starting with the **Advanced** pricing tier. Customers using lower Xero plans may receive authorization or access errors when calling this endpoint. ### Common Issues - Ensure the redirect URI in Xero exactly matches the one shown in Unified.to. - Make sure you are using the correct region-specific Unified.to redirect URI. - If you regenerate the Client Secret in Xero, you must update it in Unified.to. - Only users with sufficient permissions in Xero can authorize an organization. - If you have previously selected an organization for the same Xero app, Xero may automatically reuse that organization during future authorizations. ### Troubleshooting If authentication fails: - Verify the Client ID and Client Secret are correct. - Double-check the redirect URI. - Re-authorize the connection after saving any changes. - Remove the existing connection and create a new one if necessary. ## How to create and configure webhooks URL: https://docs.unified.to/guides/how_to_create_and_configure_webhooks # How to create and configure webhooks ------ _September 4, 2024_ This guide goes over how to create and configure webhooks with [Unified.to](https://unified.to/). Webhooks deliver events to your server when records change in your customers' accounts (see [webhook vs API](https://unified.to/blog/webhook_vs_api_when_to_use_each_and_how_they_actually_work_together) for the basics). Looking for help with troubleshooting webhook issues? See: [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks). ## Before you begin This guide assumes you have a basic understanding of [webhooks](https://docs.unified.to/reference/webhooks). ## Create a webhook Webhooks and connections are closely related; webhooks can only be created from existing connections and you can create multiple webhooks off a single connection. Webhook support is per integration and per object. If an object doesn't support webhooks on an integration, create returns an error saying so. Check the integration's Feature Support tab in the dashboard before building on a webhook. If you delete a connection, any webhooks that are associated with it will also be deleted. There are two ways to create a webhook: ### Method 1: Use the Unified API Send a POST request to `/unified/webhook`(see below for configuration options). For example: ```javascript POST /unified/webhook?include_all=true { "hook_url": "YOUR_WEBHOOK_URL", "connection_id": "YOUR_CONNECTION_ID", "object_type": "ats_candidate", "event": "updated" } ``` **API reference:** [Create a webhook](https://docs.unified.to/unified/webhook/Create_webhook_subscription) By including the optional parameter `include_all` in the query, the webhook will receive all historical data for the connection (see:[ Get all initial data for a connection](https://docs.unified.to/guides/how_to_create_and_configure_webhooks#get-all-initial-data-for-a-connection)). For the payload your server receives, including the `sig256` signature, `external_xref` and `type` values, see [Webhook payload](https://docs.unified.to/reference/webhooks#webhook-payload). The objects in `data` use the same format as the corresponding API endpoint. The `fields` option, including `raw`, `raw.*` and custom fields, works the same way on webhooks as on API calls, for native and virtual webhooks. ### Method 2: Use the Unified.to app If you prefer to create webhooks through the UI, you can do so at [app.unified.to](https://app.unified.to/). 1. Navigate to **Integrations** > [**Webhooks**](https://app.unified.to/webhooks). 2. Click **New Webhook**. 3. Click on the input and a list of your connections will appear. Select the connection from which you want to create a webhook. 4. Choose how to configure your webhook (see below). 5. Click **Save**. Information about the other fields can be found under their respective tooltips in the UI. ### Webhook configuration options Whether you use the API or our web UI, these are the configuration options you can specify when creating a webhook: 1. **Object:** The data you are interested in e.g. Deal, Company, Application, etc. 2. **Event:** The events you want to subscribe to. Note: The _updated_ event will also be sent when the data object is first _created_. Native webhooks forward deleted events where the vendor sends them. On virtual webhooks, deleted events are reliable only on BambooHR, Dropbox, Google Drive, Gmail, JobAdder, OneDrive, SharePoint, RingCentral, Egnyte and Box. 3. **Type:** Virtual webhooks cover integrations whose APIs don't offer webhooks, or whose webhooks are limited. Where an integration supports both, choose native for immediate updates and virtual to set how often changes are checked and control cost. The type can't be changed after creation (including with PUT); delete and recreate the webhook to switch. For more details, see: [Understanding virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks). 4. **Filter (optional):** Specify any properties you want to filter incoming events by. See: [Use filters to refine webhook events](https://docs.unified.to/guides/how_to_create_and_configure_webhooks#use-filters-to-refine-webhook-events) 5. **Fields (optional):** Comma-separated list of fields to include in the webhook payload. Leave blank to include all fields. The 'raw' field is NOT included by default. If you want to include all fields as well raw, just enter 'raw'. To read more about raw fields, see: [Raw and custom fields](https://docs.unified.to/reference/fields). 6. **Interval (virtual webhooks only):** Specify how often [Unified.to](https://unified.to/) checks the integration for changes. The minimum is 1 minute on paid plans and 60 minutes on the trial. The interval is ignored on native webhooks and doesn't affect the initial sync. ## List your webhooks To view your webhooks, make a GET request to `/unified/webhook` or visit [app.unified.to](https://app.unified.to/) and navigate to Integrations > [**Webhooks**](https://app.unified.to/webhooks). **API reference:** [Get all webhooks](https://docs.unified.to/unified/webhook/Returns_all_registered_webhooks) ## Get all initial data for a connection When you create a webhook, you can choose to fetch all existing data immediately. 1. If you are using the API, add the `include_all` query parameter when creating the webhook. 2. If you are using the UI, check off **Initially sync all data** in the configuration options. Once the initial sync is complete, you will receive a final payload with `type: INITIAL-COMPLETE` and the requested webhook will continue to work as normal. Initial sync works the same way for native and virtual webhooks, wherever the integration supports it. Once it completes, a native webhook receives events as the integration sends them; a virtual webhook checks for changes on its interval. **API reference:** [Create a webhook subscription](https://docs.unified.to/unified/webhook/Create_webhook_subscription) ## A note on retrieving webhook data Our system will POST initial data to your webhook in chunk sizes up to the limit supported by the integration. The limit is shown on the integration's **Feature Support** tab in the dashboard. When performing the initial retrieval, depending on the amount of data being sent, the integration's API's rate-limiting rules, and your server's performance, it could take a very long time. If you do not want to get all of the existing data (for example, you already have it or don't need it), then do not include the `include_all` parameter in the request. After the initial retrieval is completed, whenever updated data is available, your webhook will be called with a list of data objects, up to the max limit. If there are more entries than the limit, then your webhook URL will be POSTed to multiple times. For details about the webhook payload that is sent to your server, [click here](https://docs.unified.to/reference/webhooks#webhook-payload). ## Specify which fields to receive from a webhook If you only want a subset of fields when receiving updated data, you can define them with the `fields` parameter in both the API and the UI. This is useful if you don't want the entire payload being sent to your servers. For more details, see: [Fields](https://docs.unified.to/reference/fields). **API reference:** [Create a webhook subscription](https://docs.unified.to/unified/webhook/Create_webhook_subscription) ## Use filters to refine webhook events _Note: Filters are supported on virtual webhooks and on native webhooks for some integrations. Check the integration's Feature Support tab for which filters apply. For more information, see:_ [_Understanding virtual webhooks_](https://docs.unified.to/concepts/virtual_webhooks) Some webhooks support filters, allowing you to filter the events you receive. For instance, if you're subscribed to Deal events from a CRM, you could filter by `company_id` to only receive events from that specific company. Filter availability varies by integration type and data model. To check which filters are supported for a particular webhook, open the integration's **Feature Support** tab in the dashboard and check the List Options and Virtual Webhook Options sections. Parameters that end in `_id` or `type` can be used as filter options when creating a webhook, helping you tailor your subscriptions to your specific needs. For worked examples in the dashboard and the API, see [How to filter webhook events](https://docs.unified.to/guides/how_to_filter_webhook_events). ## Trigger webhooks manually If you don't want to wait for the next scheduled check while testing a virtual webhook, you can trigger it right away. Manual triggering applies to virtual webhooks only; the API returns 400 for native webhooks. 1. In the web app, navigate to [Webhooks](https://app.unified.to/webhooks). 2. Click the **Trigger** icon next to your webhook: ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_and_configure_webhooks-0.png) This can also be accomplished with the API. **API reference:** [Trigger a webhook](https://docs.unified.to/unified/webhook/Trigger_webhook) **Note:** When you trigger a webhook, we will schedule the next dispatch event to go out right away. This is treated like a regular webhook event. Therefore, if your webhook is listening for update events and no new updates have occurred since the last run, then no new events will be sent to your server. ## Frequently asked questions **Is there a page_max_limit for webhooks?** An integration's `page_max_limit` typically comes directly from the API provider. You can see that limit on the integration's **Feature Support** tab in the dashboard. You don't have to set limits for webhooks; they will use the integration's max limit. **Does changing the interval for virtual webhooks affect the initial retrieval?** Increasing frequency of virtual webhooks has no effect on the initial retrieval of historical data. Each page read is scheduled to be read one after another as soon as possible. **How do I distinguish between an updated event and a created event?** When a new data object is created, it triggers both created and updated events. If you want to track both of these events but also tell them apart, we recommend subscribing to the updated event and then compare `created_at` and `updated_at` for incoming data. If they are the same, that means the data object was newly created. **What happens if the webhook dispatch fails? Is there a retry mechanism in place?** If your endpoint doesn't respond, a native webhook is retried 3 times immediately, then marked unhealthy; the event isn't redelivered unless the integration sends it again. A virtual webhook makes up to 3 immediate attempts 1 second apart, then backs off progressively for up to about two weeks, re-reading the same page from the integration rather than resending a stored payload. Its cursor advances only after a successful delivery. When retries are exhausted, the webhook is marked unhealthy and scheduling stops. There's no way to re-enable an unhealthy webhook: fix your endpoint, then recreate the webhook. Failed delivery attempts aren't billed. Read more about it [here](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks#a-note-on-our-webhook-retry-mechanism). **Why do I receive duplicate webhook events and how should I handle them?** Duplicate webhook events can occur for several reasons: - Some integrations (like HubSpot) trigger separate events for each property change on an object - Rapid changes to objects can trigger multiple events that may arrive out of order due to network conditions To handle duplicates effectively: 1. Always use the object's latest state when updating your database 2. Implement atomic writes to avoid race conditions. Only update records if the incoming data is newer: ```javascript -- Pseudo-code example IF incoming_object.updated_at >= stored_object.updated_at THEN UPDATE record ELSE -- Skip update as we already have newer data END IF ``` ## Vendor-specific native webhook setup Some integrations need native webhooks registered in the vendor's own app first. These guides cover the vendor-side steps: - [Facebook Messenger: webhook setup](https://docs.unified.to/guides/facebook_messenger_webhook_setup) - [Greenhouse: native candidate webhooks](https://docs.unified.to/guides/configure_greenhouse_native_candidate_webhooks) - [HubSpot: configure webhooks](https://docs.unified.to/guides/how_to_configure_webhooks_in_hubspot) - [LinkedIn: set up webhooks](https://docs.unified.to/guides/how_to_set_up_linkedin_webhooks_with_unified) - [QuickBooks Online: native webhooks](https://docs.unified.to/guides/how_to_set_up_native_webhooks_for_quickbooks_online_with_unified) - [Slack: event subscriptions](https://docs.unified.to/guides/how_to_set_up_slack_webhooks_using_event_subscriptions) - [Thrive Learning: configure webhooks](https://docs.unified.to/guides/how_to_configure_webhooks_in_thrive_learning) - [Toast: configure webhooks](https://docs.unified.to/guides/how_to_configure_webhooks_in_toast_for_unified) ## See also - [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks) - [Understanding virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks) - [How to filter webhook events](https://docs.unified.to/guides/how_to_filter_webhook_events) ## How to create Connection with Hubspot URL: https://docs.unified.to/guides/how_to_create_connection_with_hubspot # How to create Connection with Hubspot ------ _December 17, 2025_ Hubspot provides Free Developer Sandbox Account. You can create that from following [link](https://offers.hubspot.com/free-cms-developer-sandbox) . 1. Create an account. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-0.png) 1. Provide name of the account ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-1.png) After account is created it will redirect you to following page:- ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-2.png) 1. Goto Development page from the size menu ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-3.png) 1. Create a project in IDE and install and install using following command ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-4.png) ```javascript npm install -g @hubspot/cli && hs init ``` 1. Create your Personal Access Key. It will be inside the keys menu ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-5.png) 1. Paste that key in your terminal when your hubspot application ask for it. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-6.png) 1. Now we will create a project. run following command in that same project. ```javascript hs get-started ``` ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-7.png) 1. Write Y to upload that project in the hubspot application. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-8.png) 1. Again select Y for following ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-9.png) 1. It will redirect to hubspot page. goto to following page:- Connected Apps→ Manage Location → click the check box → Save. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-10.png) 1. In your project(code) you will see following folder structure. GOTO→ yourproject (account-testing) → app-hsmeta.json ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-11.png) 1. Following will be the scopes and redirect URL. You can remove which you want. However, do remember those removed scopes before creating a connection in unified. Also remember to add redirect URL as follows. ```javascript { "uid": "get_started_app", "type": "app", "config": { "description": "A Unified.to token app for testing", "name": "My Get Started app", "distribution": "private", "auth": { "type": "oauth", "redirectUrls": [ "https://api.unified.to/oauth/code", ], "requiredScopes": [ "oauth", "crm.objects.companies.read", "crm.objects.contacts.read", "crm.objects.deals.read", "crm.objects.owners.read", "crm.pipelines.orders.read", "crm.objects.contacts.write", "crm.pipelines.orders.read", "crm.pipelines.orders.write", "crm.objects.deals.write", "sales-email-read", "content", "tickets", "crm.lists.read", "settings.users.teams.read" ], "optionalScopes": [], "conditionallyRequiredScopes": [] }, "permittedUrls": { "fetch": ["https://api.hubapi.com"], "iframe": [], "img": [] }, "support": { "supportEmail": "support@example.com", "documentationUrl": "https://example.com/docs", "supportUrl": "https://example.com/support", "supportPhone": "+18005555555" } } } ``` 1. You can update those changes using following command in terminal. ```javascript hs project upload ``` 1. When the code is uploaded successfully you can check that by going to following Development→ Project → Select your project. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-12.png) 1. To check your scopes click on the project Component. It will show your app-hsmeta.json ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-13.png) 1. To get the client ID and Client Secret click on the Auth button in your project component. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-14.png) 1. Paste the Client ID and Client Secret in the app.unified.to ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-15.png) 1. To get developer key goto Development→ keys → developer API key. If no keys are present than generate developer api key. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_create_connection_with_hubspot-16.png) 1. Paste that key in the [app.unified.to](https://app.unified.to/) and click activate. 2. Goto Embedded components→ click on hubspot→ select your project→ click on approve. Your Hubspot application is created. ## How to customize portal URLs for Stripe and GoCardless URL: https://docs.unified.to/guides/how_to_customize_portal_urls_for_stripe_and_gocardless # How to customize portal URLs for Stripe and GoCardless ------ _November 13, 2024_ This guide explains how to customize the portal URLs for Stripe and GoCardless contacts to control payment method flows and redirects. Portal URLs allow your customers to manage their payment methods and perform other operations through a dedicated UI. ## Before you begin This guide assumes you have: - A basic understanding of the Unified API - An active integration with either Stripe or GoCardless ## Understanding portal URLs When working with the `accounting_contact` object for Stripe and GoCardless integrations, each contact includes a `portal_url` field. This URL provides access to the contact's portal UI where they can perform various operations, such as managing payment methods. For example, a typical response might look like: ```javascript { "id": 77558, "portal_url": "" } ``` ## Customize the portal URL You can customize the portal URL's behaviour by appending specific query parameters to your API calls. The following parameters are available: 1. `success_url` (optional): Specifies where users should be redirected after completing their action 2. `type` (optional): Specifies the type of action the link is intended for. Available values: - `portal`: Generates a standard portal UI link (default) - `add_payment_method`: Generates a portal UI URL specifically for adding a new payment method ### Add a payment method with custom redirect To create a URL that allows customers to add a payment method and redirects them upon completion: 1. Start with the original portal URL from the contact object: ```javascript https://api.unified.to/accounting/{connection_id}/contact/{contact_id}/portal ``` 2. Append the `success_url` and `type` parameters: ```javascript https://api.unified.to/accounting/{connection_id}/contact/{contact_id}/portal?success_url=YOUR_REDIRECT_URL&type=add_payment_method ``` Note: Both `success_url` and `type` parameters are optional. If `type` is not specified, the system defaults to `type=portal`. ## See also - [Accounting contact object reference](https://docs.unified.to/accounting/contact/model) ## How to filter webhook events URL: https://docs.unified.to/guides/how_to_filter_webhook_events # How to filter webhook events ------ _July 24, 2024_ Webhook filters allow you to only receive data about the events you care about. For instance, if you're subscribed to CRM Deal events from ActiveCampaign, you could filter by `company_id` to receive data only about deals for a specific company. Filters are supported on virtual webhooks and on native webhooks for some integrations. Check the integration's Feature Support tab for which filters apply. Read more about the differences between native and virtual webhooks here: [Understanding virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks) Filter availability varies by integration type and data model. To see which filters are supported for a webhook, open the integration's Feature Support tab in the dashboard. Filter parameters are listed under List Options; the Virtual Webhook Options section shows the options a virtual webhook uses. Parameters that end in `_id` or `type` can be used as filter options when creating a webhook, helping you tailor your subscriptions to your specific needs. Filters an integration doesn't support are accepted and stored, but have no effect on which events you receive. A few integrations require a filter; if one is missing, create returns an error naming the integration and the filter. For example, this is the Feature Support list for CRM Deals by ActiveCampaign: ![CRM_Deal.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_filter_webhook_events-0.png) Notice that this data model supports virtual webhooks as specified under ‘Webhook.' Under ‘List Options', the parameter `company_id` is shown, indicating that it is possible to use this as a filter. When creating webhooks via the [Unified.to](https://unified.to/) web app, you can specify your filter parameters in the webhook creation form: ![Webhook_creation.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_filter_webhook_events-1.png) When creating a webhook programmatically via our API, you can include `filters` in the payload with a string dictionary of values to filter by. For example, in JavaScript: ```javascript const options = { method: 'POST', url: 'https://api.unified.to/unified/webhook', headers: { authorization: 'bearer YOUR_ACCESS_TOKEN' }, data: { filters: { company_id: '12345', }, // other payload data if needed }, }; const results = await axios.request(options); ``` Review our complete guide on the webhook API here: [Create webhook subscriptions](https://docs.unified.to/unified/webhook/Create_webhook_subscription). For setting up webhooks end to end, see [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks). If a webhook stops delivering, see [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks). ## How-to Get a Private Support Channel for Your Unified Integration URL: https://docs.unified.to/guides/how_to_get_a_private_support_channel_for_your_unified_integration # How-to Get a Private Support Channel for Your Unified Integration ------ _May 1, 2026_ [Unified.to](https://unified.to/) offers customers 3 platforms for support: - Slack - Discord - Microsoft Teams # Slack We use Slack Connect to invite our customers into private support channels that we create in our Slack account. Just reach out to your account manager, and they will create a channel for you and invite your product team. This is our preferred method of working with your team to get them to success faster. # Discord Our [Discord server](https://discord.gg/85z7HF7JbD) offers both public and private channels for support. The public channels are used for anyone who is just trying out [Unified.to](https://unified.to/). Once you dedicate your product team's time to integrate Unified.to into your application, please let us know and we will create a private channel for you. # Microsoft Teams For enterprise customers, Unified also offers shared private channels in Microsoft Teams. Please let us know if you cannot use Slack nor Discord (as per your IT policy), and we will be happy yo setup Teams access and a private channel. But, you will need your IT to also setup Teams access to the Unified MS Teams account for your team, by following this guide: This guide is for your **Microsoft Entra (Azure AD) administrator**. It walks through the configuration needed on your tenant so we can collaborate with you in a Microsoft Teams shared channel. ## Background Unified.to wants to invite a user from your organization into a Microsoft Teams **shared channel** (sometimes referred to as a "private cross-org channel"). Unlike standard Teams channels, which require external users to be added as guests in your tenant, shared channels use **Microsoft Entra B2B direct connect**. This lets your user access the channel from inside their own Microsoft Teams client, without switching tenants and without being added to our directory. For B2B direct connect to work, **both tenants must trust each other**. We have already configured our side. This document covers the symmetric configuration required on yours. ## What you'll be enabling You're enabling B2B direct connect with a single specific external tenant — Unified.to. Nothing else changes: - We are not added as guests in your directory. - Your users are not added as guests in ours. - Your existing default cross-tenant access policy is unaffected for every other organization. - The settings can be reverted at any time by removing the entry. ## Prerequisites - A user account in your Entra tenant with one of the following roles: - Global Administrator, **or** - Security Administrator, **or** - Conditional Access Administrator (with Cross-tenant access settings permission) - Your tenant must have Microsoft Entra ID (any edition — Free is sufficient for the basic configuration described here) - A modern browser signed in to your Microsoft 365 admin account ## Our tenant details You'll need these values during the setup: | Field | Value | | ------------------------- | -------------------------------------- | | Organization name | Unified.to | | Primary domain | `unified.to` | | Microsoft Entra tenant ID | `4b8acf48-e5c8-4484-896b-95b1b824ddda` | ## Step-by-step setup The full process takes about five minutes. 1. Open the Microsoft Entra admin center Go to https://entra.microsoft.com and sign in with your admin account. 2. Navigate to Cross-tenant access settings In the left navigation, expand **Entra ID** → **External Identities** → **Cross-tenant access settings**. (If you don't see External Identities directly, click "Show more" or use the search bar at the top: search for "Cross-tenant access settings".) 3. Add Unified.to as an organization 4. Make sure you're on the **Organizational settings** tab (not "Default settings"). 5. Click **+ Add organization**. 6. In the side panel, paste either of the following into the "Tenant ID or domain name" field: - Tenant ID: `4b8acf48-e5c8-4484-896b-95b1b824ddda` - Domain: `unified.to` 7. Microsoft will resolve the entry and display **Unified.to** with our tenant ID. 8. Click **Add**. You should see a confirmation toast: "Successfully added Unified.to tenant to organizational settings." Unified.to now appears in the list with both Inbound access and Outbound access showing **"Inherited from default"**. We'll override that next. ### 4. Configure Inbound access This controls whether Unified.to users can access resources in your tenant via B2B direct connect (such as joining a shared channel that we host). 1. In the 'Unified.to row', click the **Inbound access** link ("Inherited from default"). 2. In the panel that opens, click the **B2B direct connect** tab. 3. Select the **Customize settings** radio. 4. On the **External users and groups** sub-tab: - Access status: **Allow access** - Applies to: **All Unified.to users and groups** 5. Switch to the **Applications** sub-tab: - Access status: **Allow access** - Applies to: **All applications** 6. Click **Save**. The Save button greys out when the save completes. 7. Close the panel (X in the top-right) to return to the Organizational settings list. The Inbound access column for Unified.to should now read **"Configured"**. ### 5. Configure Outbound access This controls whether your users can access resources hosted by Unified.to (such as the shared channel we'll invite them into). 1. In the 'Unified.to row', click the **Outbound access** link ("Inherited from default"). 2. In the panel that opens, click the **B2B direct connect** tab. 3. Select the **Customize settings** radio. 4. On the **Users and groups** sub-tab: - Access status: **Allow access** - Applies to: **All [your organization name] users** 5. Switch to the **External applications** sub-tab: - Access status: **Allow access** - Applies to: **All external applications** 6. Click **Save**. 7. Microsoft will display a consent dialog titled **"Are you sure?"**, explaining that enabling outbound B2B direct connect lets external organizations access limited contact data about your users for the purpose of sending connection requests. Click **Yes** to confirm. 8. Close the panel. The Outbound access column for Unified.to should now read **"Configured"**. ### 6. Verify The Unified.to row in your Organizational settings should now show: | Inbound access | Outbound access | | -------------- | --------------- | | Configured | Configured | Both fields should be hyperlinked. If either still says "Inherited from default", reopen that link and re-save. ## What about Microsoft Teams settings? In most tenants, the Teams admin center already permits shared channel collaboration with external orgs by default. If a user from your side reports they cannot accept our shared-channel invite, your Teams admin should verify: - Microsoft Teams admin center → **Teams** → **External access** → "Allow Teams accounts not managed by an organization" or per-domain allow list (if your org uses an allow list, add `unified.to`). - Microsoft Teams admin center → **Teams** → **Teams policies** → the policy assigned to the affected user has **"Create shared channels"** and **"Invite external users to shared channels"** enabled (or at minimum, **"Join external shared channels"**). These Teams-level settings are usually permissive by default. The Entra cross-tenant configuration above is the part that's typically missing. ## Replication delay Cross-tenant configuration can take up to 15 minutes to propagate across Microsoft's services. If we attempt to invite your user into the shared channel right after you finish, and it fails, please ask us to wait ten minutes and retry before troubleshooting further. ## Letting us know you're done Once Inbound and Outbound both read "Configured" for Unified.to on your side, please let us know. We'll then send the shared-channel invite to the user(s) you specify, and they'll see the invitation appear in their own Microsoft Teams client. ## Reverting later To remove the trust relationship at any time: 1. Microsoft Entra admin center → External Identities → Cross-tenant access settings → Organizational settings. 2. Find the Unified.to row, click the trash icon at the right, and confirm. This restores your default policy for our tenant. Any active shared channels become inaccessible to your users at that point. ## Reference Microsoft's official documentation: - B2B direct connect overview: [https://learn.microsoft.com/entra/external-id/b2b-direct-connect-overview](https://learn.microsoft.com/entra/external-id/b2b-direct-connect-overview) - Configure cross-tenant access settings: [https://learn.microsoft.com/entra/external-id/cross-tenant-access-settings-b2b-direct-connect](https://learn.microsoft.com/entra/external-id/cross-tenant-access-settings-b2b-direct-connect) - Shared channels in Microsoft Teams: [https://learn.microsoft.com/microsoftteams/shared-channels](https://learn.microsoft.com/microsoftteams/shared-channels) ## How to get your 8x8 Connect API Key and Account ID: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_8x8_connect_api_key_and_account_id_step_by_step_guide # How to get your 8x8 Connect API Key and Account ID: Step-by-step guide ------ _July 17, 2025_ 8x8 Connect supports authentication with API Key and Account ID. To connect, you'll need to generate secure API Key and Account ID from your account settings. This short guide shows you how to generate one, whether you're testing in your sandbox or helping customers connect their 8x8 Connect account. ## **API Keys** **Step 1:** View the API Keys Section of the Connect Dashboard. This may be accessed [here](https://connect.8x8.com/messaging/api-keys). ![611133d-image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_8x8_connect_api_key_and_account_id_step_by_step_guide-0.png) **Step 2:** Click the "Create API Key" button. ![6dc9838-image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_8x8_connect_api_key_and_account_id_step_by_step_guide-1.png) **Step 3:** Name the API Key in the Pop Up, this can be any value. We recommend using a memorable name related to the API key's intended purpose such as "HealthCareApp_Production". Once the value is entered in, click **save**. ![a5372f9-image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_8x8_connect_api_key_and_account_id_step_by_step_guide-2.png) **Step 4:** The new API key should now be located in the list, you can perform a partial search at the top for the name. Only the last 6 characters will be shown, click the document button highlighted in red to reveal the entire API key. You can return to this page to retrieve the API key's value at any time. ![9f50dec-image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_8x8_connect_api_key_and_account_id_step_by_step_guide-3.png) ## Account ID and SubAccount ID The **accountId** and **subAccountId** can be found in your 8x8 Connect via **API keys** page.  ![adcd1c81-272d-4b47-9adb-c733f026a2d2.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_8x8_connect_api_key_and_account_id_step_by_step_guide-4.png) Above we can see the Accountid is : **test11_9tT31** with SubaccountId : **test11_9tT31_hq.** Your account may have multiple subaccounts which can be seen if you click the drop down in **Subaccount ID.** You've now created your 8x8 Connect API Key and Account ID - just one of many if you support multiple vendors. Each one adds new auth flows, schema differences, and ongoing maintenance. Unified.to removes that overhead with unified objects that work across 660 APIs. Map once, launch everywhere. Your team ships features instead of debugging vendor-specific edge cases. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how much faster you can launch customer integrations with Unified.to. ## How to get your ADP Workforce Now Oauth2 client ID, Oauth2 client secret, Oauth2 PEM certificate and Oauth2 private key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_adp_workforce_now_oauth2_client_id_oauth2_client_secret_oauth2_pem_certificate_and_oauth2_private_key_step_by_step_guide # How to get your ADP Workforce Now Oauth2 client ID, Oauth2 client secret, Oauth2 PEM certificate and Oauth2 private key: Step-by-step guide ------ _April 24, 2023_ When connecting ADP Workforce Now to other platforms, authentication requires a Oauth2 client ID, Oauth2 client secret, Oauth2 PEM certificate and Oauth2 private key. Follow these step-by-step instructions to generate one from your ADP Workforce Now account, or provide them to your customers if they need to connect their account. 1. Log into your ADP account and access the ADP API Central portal. You will need the paid `ADP API Central` [application](https://apps.adp.com/en-US/apps/410612). 2. Select a project or create a new project ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_adp_workforce_now_oauth2_client_id_oauth2_client_secret_oauth2_pem_certificate_and_oauth2_private_key_step_by_step_guide-0.png) 3. Enter the project details. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_adp_workforce_now_oauth2_client_id_oauth2_client_secret_oauth2_pem_certificate_and_oauth2_private_key_step_by_step_guide-1.png) 4. Select domain ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_adp_workforce_now_oauth2_client_id_oauth2_client_secret_oauth2_pem_certificate_and_oauth2_private_key_step_by_step_guide-2.png) 5. Inside the Production integration Page, select the most suitable option for you. 6. After creating a new Project, select Development Credentials ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_adp_workforce_now_oauth2_client_id_oauth2_client_secret_oauth2_pem_certificate_and_oauth2_private_key_step_by_step_guide-3.png) 7. This page will Provide the Client ID, Secret Key and Certificates 8. Select Manage Certificate to generate Certificate ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_adp_workforce_now_oauth2_client_id_oauth2_client_secret_oauth2_pem_certificate_and_oauth2_private_key_step_by_step_guide-4.png) 9. Fill out the form, which will provide the PEM Key required to create a connection. 10. The processing time for the certificate is few minutes. Generated Certificate will be required to create a connection. 11. If asked, select `api` permission. For Unified, you need to have access to the following APIS for your App: - Job Applications API - Job Applicants API - Job Requisitions API - Client Data - Validation Tables API - Worker Leaves API - Worker API To add the API to your application scope, contact your ADP representative. Make sure you provide the following: - Organization name - Application name - List of APIs to be added Generating a ADP Workforce Now Oauth2 client ID, Oauth2 client secret, Oauth2 PEM certificate and Oauth2 private key is the first step. The harder part is building pipelines that stay reliable as customers add more systems. Unified.to gives you options: deliver data via API, streaming webhooks, database sync, or even as MCP tools for AI agents. One integration layer that adapts to your architecture instead of forcing tradeoffs. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how we simplify integration delivery across 1567 integrations and 37 categories. ## How to get your Amazon S3 AWS Region, AWS S3 Key and AWS S3 Secret: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_amazon_s3_aws_region_aws_s3_key_and_aws_s3_secret_step_by_step_guide # How to get your Amazon S3 AWS Region, AWS S3 Key and AWS S3 Secret: Step-by-step guide ------ _December 22, 2023_ Developers connecting to Amazon S3 will need an AWS Region, AWS S3 Key and AWS S3 Secret for authentication. Here's how to generate, configure, and copy your Amazon S3 AWS Region, AWS S3 Key and AWS S3 Secret so you can use it in your integration or share steps with your customers. # **Generating AWS Access Key ID and Secret Access Key** You can easily access your AWS S3 account using a Access key and Secret Access key of your AWS account. 1. If you don't have one already then go to your account and click on **My Security Credentials** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_amazon_s3_aws_region_aws_s3_key_and_aws_s3_secret_step_by_step_guide-0.png) 2. Then select **Access keys (access key ID and secret access key)** section. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_amazon_s3_aws_region_aws_s3_key_and_aws_s3_secret_step_by_step_guide-1.png) There is an important notification on the section, which recommends you to create an IAM Role instead of creating root access keys. 3. Click on **Create New Access Key** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_amazon_s3_aws_region_aws_s3_key_and_aws_s3_secret_step_by_step_guide-2.png) 4. Download the Key pairs to your system for future use. 5. Click on _**Show Access key**_ and you will get your _**Access Key ID**_ and _**Secret Access Key.**_ ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_amazon_s3_aws_region_aws_s3_key_and_aws_s3_secret_step_by_step_guide-3.png) 6. You need to use this _**Access Key ID**_ and _**Secret Access Key**_ to connect to your AWS connect and access the S3 bucket. With your Amazon S3 AWS Region, AWS S3 Key and AWS S3 Secret in hand, you're ready to connect. But managing keys, tokens, and refresh flows across every vendor quickly becomes a security risk. Unified.to abstracts all of that behind a single, secure layer. We never store customer data, credentials are always under your control, and our permissioning model removes the risk of misconfigured scopes. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how Unified keeps integrations real-time, compliant, and safe at scale. ## How to get your Ashby API Key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_ashby_api_key_step_by_step_guide # How to get your Ashby API Key: Step-by-step guide ------ _April 24, 2023_ Ashby supports authentication with an API Key. To connect, you'll need to generate an secure API Key from your account settings. This short guide shows you how to generate one, whether you're testing in your sandbox or helping customers connect their Ashby account. 1. Sign in to Ashby and click "Admin" in the top navigation bar 2. Navigate to Integrations > API Keys ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_ashby_api_key_step_by_step_guide-0.png) 3. Click + New in the upper right corner 4. Add a name for the new API Key, click Create API Key ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_ashby_api_key_step_by_step_guide-1.png) 5. Set necessary API Scopes, e.g. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_ashby_api_key_step_by_step_guide-2.png) To read Applications, you will also need the "Offers" READ permission. To use Webhooks, you will also need the "API Keys" WRITE permission. 6. Click "Save and Continue" 7. Copy the API Key ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_ashby_api_key_step_by_step_guide-3.png) Generating a Ashby API Key is the first step. The harder part is building pipelines that stay reliable as customers add more systems. Unified.to gives you options: deliver data via API, streaming webhooks, database sync, or even as MCP tools for AI agents. One integration layer that adapts to your architecture instead of forcing tradeoffs. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how we simplify integration delivery across 1602 integrations and 37 categories. ## How to get your Brex API token: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_brex_api_token_step_by_step_guide # How to get your Brex API token: Step-by-step guide ------ _March 21, 2025_ If you're building an integration with Brex, you'll need an API token from your customer's Brex account to authenticate requests. This guide shows you (or your customers) how to generate one from Brex's account settings. ### **Generate a user token** 1. Sign in to [dashboard.brex.com](https://dashboard.brex.com/) as an [account admin or card admin](https://developer.brex.com/docs/roles_permissions_scopes) . 2. Go to [_Developer > Settings_](https://dashboard.brex.com/settings/developer) . 3. Click _Create Token_ . 4. Create a name for your token that will help you identify it. Choose what level of data access you need for your application; these are the [scopes](https://developer.brex.com/docs/roles_permissions_scopes) your token will have. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_brex_api_token_step_by_step_guide-0.png) 5. The next screen will confirm your previous selections. Make sure it looks good, then select _Allow Access_ . ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_brex_api_token_step_by_step_guide-1.png) 6. Your token is now created. Copy and store the token securely. You won't be able to see it again. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_brex_api_token_step_by_step_guide-2.png) 7. Back on the developer page of your Brex dashboard, you should see your token listed now. As a security measure, part of the string is obfuscated. If you lose it, create a new one and replace the token. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_brex_api_token_step_by_step_guide-3.png) **Caution**: Your user token is private and should not be shared. Never check it into version control or save it somewhere publicly accessible. If your user token is compromised or leaked, make sure to revoke it. ### **Token revocation and expiration** User tokens will expire if they are not used to make an API call for 90 days. If your token is compromised, or you no longer need it, revoke the user token from the developer page in your Brex dashboard. Once revoked, any calls made with this token will immediately begin to fail. You've now created your Brex API token - just one of many if you support multiple vendors. Each one adds new auth flows, schema differences, and ongoing maintenance. Unified.to removes that overhead with unified objects that work across 475 APIs. Map once, launch everywhere. Your team ships features instead of debugging vendor-specific edge cases. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how much faster you can launch customer integrations with Unified.to. ## How to get your Bullhorn OAuth2 Client ID, OAuth2 Client Secret, API Username and API Password: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_bullhorn_oauth2_client_id_oauth2_client_secret_api_username_and_api_password_step_by_step_guide # How to get your Bullhorn OAuth2 Client ID, OAuth2 Client Secret, API Username and API Password: Step-by-step guide ------ _April 24, 2023_ If you're building an integration with Bullhorn, you'll need an OAuth2 Client ID, OAuth2 Client Secret, API Username and API Password from your customer's Bullhorn account to authenticate requests. This guide shows you (or your customers) how to generate one from Bullhorn's account settings. ## Why this is required? Bullhorn requires every connected application to use its own API user, client ID, client secret, and approved redirect URI. The redirect URI cannot be updated through the Bullhorn web interface, so Bullhorn Support must be contacted to approve and configure the redirect URI. ## Steps your customer must complete ### 1. Create an API User in Bullhorn The customer must create a dedicated API user for the application they want to connect. During this process, Bullhorn will provide: - Client ID - Client Secret These credentials will later be used when creating the connection in Unified. **Note**: A new API user is required each time a new application is connected to Bullhorn. ### 2. Contact Bullhorn Support After creating the API user, the customer must contact Bullhorn Support and request that the redirect URI for the API user be updated. The customer should provide: - The API user or application they created - The Unified redirect URI Example request: Please update the redirect URI for our Bullhorn API user to the following Unified redirect URI: **`https://api.unified.to/oauth/code`** ### 3. Wait for Bullhorn to approve and apply the Redirect URI change Bullhorn does not currently allow redirect URIs to be configured through the Bullhorn web UI. Because of this, the redirect URI change must be completed by Bullhorn Support before the connection can work. ## Important Notes - Every customer must request the redirect URI change individually for their own Bullhorn account. - This is a Bullhorn limitation and cannot be configured directly in the Bullhorn UI or programmatically via Unified. - This is a one-time setup - If you create a new API user in the future, you will need to go through this process again Generating a Bullhorn OAuth2 Client ID, OAuth2 Client Secret, API Username and API Password is the first step. The harder part is building pipelines that stay reliable as customers add more systems. Unified.to gives you options: deliver data via API, streaming webhooks, database sync, or even as MCP tools for AI agents. One integration layer that adapts to your architecture instead of forcing tradeoffs. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how we simplify integration delivery across 1561 integrations and 37 categories. ## How to get your Ceridian Dayforce Username, Password and Client Namespace: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_ceridian_dayforce_username_password_and_client_namespace_step_by_step_guide # How to get your Ceridian Dayforce Username, Password and Client Namespace: Step-by-step guide ------ _April 24, 2023_ Developers connecting to Ceridian Dayforce will need an Username, Password and Client Namespace for authentication. Here's how to generate, configure, and copy your Ceridian Dayforce Username, Password and Client Namespace so you can use it in your integration or share steps with your customers. To get the Dayforce client namespace, follow these steps: 1. Log in to Dayforce. 2. Navigate to the "Company" tab. 3. Click on "Settings". 4. Under the "General" section, you should find the "Client Namespace" field. The value in this field is the Dayforce client namespace. Getting your Ceridian Dayforce Username, Password and Client Namespace is the first step. The bigger challenge is supporting dozens of vendors - each with unique auth flows, schemas, and maintenance requirements. Unified.to is a real-time integration platform built to replace that work. Instead of building and maintaining 1567 custom connectors, you get one secure, normalized API layer across 37 SaaS tools. On top of that, our usage-based pricing scales cleanly with your product. No per-connection fees, no setup charges, and no lock-in, just infrastructure economics that grow with you. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to discover how predictable integration costs should be. ## How to get your Crelate API key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_crelate_api_key_step_by_step_guide # How to get your Crelate API key: Step-by-step guide ------ _August 18, 2023_ To access Crelate data or automate workflows through an integration, an API key is required. This guide walks you through where to find it and how to set it up for development or end-customer use. **Where Is My API Key?** Your API Key can be found in your Settings Tab under "My Settings & Preferences". You will have to enable your API Key. Once Your API is enabled, you will be presented with your API Key. If you ever need to regenerate a new API Key, you can do so by clicking "Regenerate New Key". However, please note: **Any Change in API KEY (for instance generating a new key or turning API Access off) will result in any exsisting links with the old API key to fail.** **Managing API** If you would like to manage your API, you can do so by going into your settings and clicking API Management under (Advanced Settings). You will presented with a screen that will allow you to manage users who have turned on API Access. If you click on a user who has enabled their API, administrators will have the option of turning off API Access for that user. Administrators can do this by clicking "Clear API Key". **Clearing the API key will cause any references to that API Key to fail and will turn off that users API access to that specific API key**. If that user would like API access again, they will have to regenerate another API key. To start, select your navigation menu and select "**Your Profile & Preferences**" ![mceclip1.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_crelate_api_key_step_by_step_guide-0.png) Next, navigate to **API Access** ![mceclip0.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_crelate_api_key_step_by_step_guide-1.png) You've now created your Crelate API key - just one of many if you support multiple vendors. Each one adds new auth flows, schema differences, and ongoing maintenance. Unified.to removes that overhead with unified objects that work across 514 APIs. Map once, launch everywhere. Your team ships features instead of debugging vendor-specific edge cases. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how much faster you can launch customer integrations with Unified.to. ## How to get your Dashlane API Key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_dashlane_api_key_step_by_step_guide # How to get your Dashlane API Key: Step-by-step guide ------ _March 26, 2025_ When connecting Dashlane to other platforms, authentication requires an API Key. Follow these step-by-step instructions to generate one from your Dashlane account, or provide them to your customers if they need to connect their account. ## Generate a Dashlane API key To access the Dashlane API endpoints, you'll need an API key. This key can be generated in two ways: directly through the **Admin Console** or the **Dashlane CLI**. ### **Generate an API key using Dashlane Admin Console** 1. Open the Admin Console and navigate to the **Integrations** section. 2. Select **Public API** and then **Create key**. ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_dashlane_api_key_step_by_step_guide-0.png) 3. Enter a name for the key and select **Generate key**. ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_dashlane_api_key_step_by_step_guide-1.png) 4. Copy or download the bearer token. The bearer token will be used to authenticate and access the endpoints. **Important**: Save your bearer token securely in a Secret on your vault, as it will only be displayed once. [Add and manage secrets in Dashlane](https://support.dashlane.com/hc/articles/17020356625682) ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_dashlane_api_key_step_by_step_guide-2.png) ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_dashlane_api_key_step_by_step_guide-3.png) You've now retrieved your Dashlane API Key. That's one system covered, but scaling integrations one by one means repeating this process hundreds of times. Unified.to handles this for you: 475 integrations out of the box, all with normalized schemas, real-time delivery, and Virtual Webhooks. No polling scripts, no retry logic, no maintenance backlog. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how Unified cuts integration work from quarters to days. ## How to get your Deel Access Token: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_deel_access_token_step_by_step_guide # How to get your Deel Access Token: Step-by-step guide ------ _August 26, 2023_ When connecting Deel to other platforms, authentication requires an Access Token. Follow these step-by-step instructions to generate one from your Deel account, or provide them to your customers if they need to connect their account. **Generating Access Tokens** 1. Navigate to **Apps & Integrations > Developer Center**. 2. Select the type of Token, whether Organization or Personal. The former ties the keys to the whole organization while the latter ties the keys to an individual - token will expire once this person leaves the company. ![181cd5c-accesstoken.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_deel_access_token_step_by_step_guide-0.png) 1. Add a token name and click on the **Next** button. ![8e6d69f-accessscopes.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_deel_access_token_step_by_step_guide-1.png) 1. In the popup, select the scopes for the access token and click **Generate**. `people:read` , `organizations:read`, `payslips:read`, `time-off:read` 2. Make sure to copy and save your newly generated token because you won't be able to see it again! Generating a Deel Access Token is the first step. The harder part is building pipelines that stay reliable as customers add more systems. Unified.to gives you options: deliver data via API, streaming webhooks, database sync, or even as MCP tools for AI agents. One integration layer that adapts to your architecture instead of forcing tradeoffs. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how we simplify integration delivery across 721 integrations and 33 categories. ## How to get your Dialpad API Key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_dialpad_api_key_step_by_step_guide # How to get your Dialpad API Key: Step-by-step guide ------ _April 24, 2023_ Dialpad supports authentication with an API Key. To connect, you'll need to generate an secure API Key from your account settings. This short guide shows you how to generate one, whether you're testing in your sandbox or helping customers connect their Dialpad account. To create and access your API key, you need to follow the below steps. Note that only Company Admins can create API keys on Dialpad. 1. Navigate to Admin Settings > My Company > Authentication > API Keys 2. Select **Add Key** 3. Name your key and set the expiration terms 4. Select desired **Additional Scopes** 5. Select **Save** [video](https://use.vg/wWKws7) Note: API keys can only be created by Dialpad customers on the Pro and Enterprise plans, and you must be a Company Admin to create and access an API key. You've now retrieved your Dialpad API Key. That's one system covered, but scaling integrations one by one means repeating this process hundreds of times. Unified.to handles this for you: 1258 integrations out of the box, all with normalized schemas, real-time delivery, and Virtual Webhooks. No polling scripts, no retry logic, no maintenance backlog. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how Unified cuts integration work from quarters to days. ## How to get your Discord OAuth 2 credentials and bot token URL: https://docs.unified.to/guides/how_to_get_your_discord_oauth_2_credentials_and_bot_token # How to get your Discord OAuth 2 credentials and bot token ------ _May 21, 2024_ _Last updated: May 2026_ This guide explains how to register your application with Discord and obtain your OAuth 2 credentials and bot token. You'll need these credentials to activate the Discord integration and access Discord via Unified.to's unified API. ## Register your application in Discord 1. Sign into Discord and navigate to the [Developer Portal](https://discord.com/developers/applications). ![discord_1.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_discord_oauth_2_credentials_and_bot_token-0.png) 2. Click **New Application** at the top right of the page. 3. In the **Create an application** window: - Enter a name for your application - Check the box to agree to Discord's terms of service and developer policy - Click **Create** ![discord_2.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_discord_oauth_2_credentials_and_bot_token-1.png) Discord will create a new application and take you to the **General Information** tab for your application's page. Think of this application as the container for both your OAuth 2 credentials and your bot user. ## Get your Discord OAuth 2 credentials Once your application is registered, you can obtain its OAuth 2 credentials. These authenticate your app and let it access the Discord API through Unified.to. 1. On your application's page, select the **OAuth2** tab from the left sidebar. 2. Under **Client Information**, make note of your **Client ID** and **Client Secret** — you'll need these later. 3. Under **Redirects**, click **Add Redirect**. 4. Enter `https://api.unified.to/oauth/code` as the redirect URL. (If your workspace is hosted in the EU or AU region, use the corresponding regional redirect URL.) 5. Click **Save Changes** at the bottom of the page. ![discord_4.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_discord_oauth_2_credentials_and_bot_token-2.png) > **Keep these private.** Never commit your Client Secret or Bot Token to source control, share them in screenshots, or include them in client-side code. Store them in environment variables or a secrets manager. ## Retrieve your Discord bot token The Discord integration also requires a **Bot Token** to access the Discord API. 1. Select **Bot** from the left sidebar. 2. Under **Build-A-Bot > Token**, copy the **Bot Token**. 3. If no token is shown (for a new bot), click **Reset Token** to generate one, confirm in the dialog, and copy the new token that appears. > **Note:** Resetting the token invalidates the previous one. If a bot is already running in production with the existing token, resetting will break those connections until you update them. You also won't be able to view a token again after it's generated, so store it securely right away. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_discord_oauth_2_credentials_and_bot_token-3.png) ## Configure bot permissions and intents Enable the permissions and privileged gateway intents that match the data your integration accesses. Enable only what you need — Unified.to maps each capability to the minimum required access. The Discord integration supports messages and channels (via the Messaging API) and members (via the HRIS API): - **To list channels** (Messaging Channel `list`/`get`): no privileged intent is required. The bot needs the standard **View Channels** permission in the server. - **To read or write messages** (Messaging Message `list`/`create`/`update`/`get`/`remove`): enable **Message Content Intent** under **Privileged Gateway Intents**. Note that Message Content Intent is gated by Discord and may require verification once your bot is in a larger number of servers. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_discord_oauth_2_credentials_and_bot_token-4.png) - **To read members** (HRIS Employee `list`/`get`, which maps Discord guild members to the unified employee object — a Unified.to abstraction, not a Discord-native concept): enable **Server Members Intent** under **Privileged Gateway Intents**. (Discord's documentation refers to this as the Guild Members Intent; the developer portal UI labels it "Server Members Intent.") After enabling the intents you need, click **Save Changes** at the bottom of the page. Granting intents or permissions you don't use increases risk and can complicate Discord's review process once your bot reaches a larger number of servers. ## Activate Discord in Unified.to 1. Navigate to [Integrations](https://app.unified.to/integrations) in Unified.to. 2. Find and click on the **Discord** integration card. 3. On the integration details page, select **Your OAuth 2 credentials**, then enter your: - Discord **Client ID** - Discord **Client Secret** - Discord **Bot Token** 4. Click **Activate** to save your changes and enable the Discord integration. ![discord_7.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_discord_oauth_2_credentials_and_bot_token-5.png) You can now use the Unified API to build new experiences with Discord. → [Start your 30-day free trial](https://app.unified.to/login) → [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) ## How to get your ELMO client ID and client secret: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_elmo_client_id_and_client_secret_step_by_step_guide # How to get your ELMO client ID and client secret: Step-by-step guide ------ _December 11, 2023_ ELMO supports authentication with client ID and client secret. To connect, you'll need to generate secure client ID and client secret from your account settings. This short guide shows you how to generate one, whether you're testing in your sandbox or helping customers connect their ELMO account. **Authentication Methods** ELMO uses three main credentials for API authentication: - **Client ID:** A unique identifier for your application. - **Client Secret:** A secure key used in conjunction with the Client ID. **Step-by-Step Setup** 1. **Request Access:** Contact your **ELMO Account Manager** to confirm your organization has API access enabled. 2. **Assign Permissions:** - Log in to the **ELMO Administration portal**. - Navigate to **Security Profiles** and add a profile with the **API Access Manager**role. - Assign this profile to the specific user who will manage the integration. 3. **Generate Credentials:** - Have a Company Admin access the **API Management console**. - Generate and securely record the **Client ID** and **Client Secret**. You've now created your ELMO client ID and client secret - just one of many if you support multiple vendors. Each one adds new auth flows, schema differences, and ongoing maintenance. Unified.to removes that overhead with unified objects that work across 475 APIs. Map once, launch everywhere. Your team ships features instead of debugging vendor-specific edge cases. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how much faster you can launch customer integrations with Unified.to. ## How to get your Gainsight Client ID, Client Secret and Gainsight API Domain: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_gainsight_client_id_client_secret_and_gainsight_api_domain_step_by_step_guide # How to get your Gainsight Client ID, Client Secret and Gainsight API Domain: Step-by-step guide ------ _November 15, 2025_ Gainsight supports authentication with Client ID, Client Secret and Gainsight API Domain. To connect, you'll need to generate secure Client ID, Client Secret and Gainsight API Domain from your account settings. This short guide shows you how to generate one, whether you're testing in your sandbox or helping customers connect their Gainsight account. To generate the M2M OAuth key: 1. Navigate to **Administration > Connectors 2.0**. 2. Click **Create Connection**. The Create Connection dialog appears. 3. From the **Connector** dropdown list, select **Gainsight API**. 4. In the **Name of the connection** field, enter the name of the connection. 5. In the **Authentication Type**, select the OAuth. 6. Click **Generate OAuth Credentials**. New OAuth API Key and OAuth API Secret keys are generated, which you can copy and use for authentication in all of the REST API requests to Gainsight. **Note**: - M2M OAuth can be created and managed only by super admins. - M2M OAuth cannot be used for Event APIs. - Two different M2M connections cannot have the same name for a single connection. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_gainsight_client_id_client_secret_and_gainsight_api_domain_step_by_step_guide-0.png) Generating a Gainsight Client ID, Client Secret and Gainsight API Domain is the first step. The harder part is building pipelines that stay reliable as customers add more systems. Unified.to gives you options: deliver data via API, streaming webhooks, database sync, or even as MCP tools for AI agents. One integration layer that adapts to your architecture instead of forcing tradeoffs. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how we simplify integration delivery across 475 integrations and 29 categories. ## How to get your Greenhouse API Key and Job board token: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_greenhouse_api_key_and_job_board_token_step_by_step_guide # How to get your Greenhouse API Key and Job board token: Step-by-step guide ------ _April 24, 2023_ Developers connecting to Greenhouse will need an API Key and Job board token for authentication. Here's how to generate, configure, and copy your Greenhouse API Key and Job board token so you can use it in your integration or share steps with your customers. 1. To create a Greenhouse "Harvest" API key, click the Configure icon on your Greenhouse navigation bar and select "_Dev Center_" on the left. ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_greenhouse_api_key_and_job_board_token_step_by_step_guide-0.png) 1. Click "_API Credential Management_". ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_greenhouse_api_key_and_job_board_token_step_by_step_guide-1.png) 1. Click "_Create New API Key_", and select "_Harvest_" for the API Type. ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_greenhouse_api_key_and_job_board_token_step_by_step_guide-2.png) 1. Click "_Manage Permissions_". ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_greenhouse_api_key_and_job_board_token_step_by_step_guide-3.png) 2. Copy your Harvest API key to a secure location then click _"I have stored the API key"_. ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_greenhouse_api_key_and_job_board_token_step_by_step_guide-4.png) 3. Set Jobs, Job Posts, and Candidates, Applications, Scorecards permissions. ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_greenhouse_api_key_and_job_board_token_step_by_step_guide-5.png) 4. Click Save 5. Optionally, you can specify the job board token for your organization. Go to the "_Configure Job Boards_" page. (Configure gear icon > Job Boards & Posts) 6. Find and edit the job board. (Ellipsis icon > Edit Board Settings) ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_greenhouse_api_key_and_job_board_token_step_by_step_guide-6.png) 1. On the _Edit Your Job Board_ page, find the _URL_ section. Copy this value as this is the token. ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_greenhouse_api_key_and_job_board_token_step_by_step_guide-7.png) Getting your Greenhouse API Key and Job board token is the first step. The bigger challenge is supporting dozens of vendors - each with unique auth flows, schemas, and maintenance requirements. Unified.to is a real-time integration platform built to replace that work. Instead of building and maintaining 1435 custom connectors, you get one secure, normalized API layer across 37 SaaS tools. On top of that, our usage-based pricing scales cleanly with your product. No per-connection fees, no setup charges, and no lock-in, just infrastructure economics that grow with you. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to discover how predictable integration costs should be. ## How to get your Helpscout API Key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_helpscout_api_key_step_by_step_guide # How to get your Helpscout API Key: Step-by-step guide ------ _June 8, 2024_ Developers connecting to Helpscout will need an API Key for authentication. Here's how to generate, configure, and copy your Helpscout API Key so you can use it in your integration or share steps with your customers. To generate, view, or regenerate the API key, click the "person" icon on the top right of your account, next to the search, then click **Your Profile**. ![your-profile.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_helpscout_api_key_step_by_step_guide-0.png) Next, click the **Authentication** link in the menu on the left and select **API Keys** tab ![api-keys.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_helpscout_api_key_step_by_step_guide-1.png) **Note:** If you don't see an API key in your profile, that's because you haven't been granted the "Docs: Create new, edit settings & Collections" permission by the Account Owner or an Administrator. Generating a Helpscout API Key is the first step. The harder part is building pipelines that stay reliable as customers add more systems. Unified.to gives you options: deliver data via API, streaming webhooks, database sync, or even as MCP tools for AI agents. One integration layer that adapts to your architecture instead of forcing tradeoffs. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how we simplify integration delivery across 475 integrations and 29 categories. ## How to get your HiBob Service User ID and Service User Token: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_hibob_service_user_id_and_service_user_token_step_by_step_guide # How to get your HiBob Service User ID and Service User Token: Step-by-step guide ------ _April 24, 2023_ If you're building an integration with HiBob, you'll need an Service User ID and Service User Token from your customer's HiBob account to authenticate requests. This guide shows you (or your customers) how to generate one from HiBob's account settings. ## **Step 1: Create a service user** Service Users are created and managed in the Service Users section in Bob. 1. **If you have direct access to Bob:** Go to the Service Users configuration page to create a new user and copy the ID and token. See [Manage service users](https://help.hibob.com/hc/en-us/articles/27875098648465-Manage-service-users). 2. **If you don't have direct access to Bob**: Ask a Bob Admin to generate the credentials for you. You'll need this ID and token to authenticate your API requests. ## **Step 2: Creating a permission group** By default, service users have no access permissions. To assign them access: 1. Create a dedicated Permission Group. 2. Add the Service User to this group. This group should include only the permissions required for the API operations the service user will perform. - **If you have access to Bob:** [Create a service user permission group](https://help.hibob.com/hc/en-us/articles/29550415706897). - **If not**: Ask a Bob Admin to create one and assign the user to it. ## **Step 3: Set permissions** A new permission group does not have any permissions enabled by default. **Notes:** - **Ask Bob Admin** to help determine which permissions are required for this service user. To learn more, see [Manage service users](https://help.hibob.com/hc/en-us/articles/27875098648465). - **Default employee data permissions**: To read basic employee data via the People search API, assign the Default Employee Fields permissions. This includes access to the "root," "about," "employment," and "work" categories. To learn more, see [Permissions for Default Employee Fields in People Search API](https://help.hibob.com/hc/en-us/articles/27875098648465#h_01JB9DYK9SZEJ7MF4DA7RHJE03). Permissions are grouped into: ### **3.1 Features** Grant access to Bob features you need to access via the API you plan to use with this service user. For example, if you want to access reports, you may need to activate some options in **Features > Reports**. 1. From the top right, click **Edit**. 2. Select the area of Bob you'd like to manage. 3. Check or uncheck the permissions this group should have. ![e2e3b78-image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_hibob_service_user_id_and_service_user_token_step_by_step_guide-0.png) ### **3.2 People's data** Grant access to people's data in each area of Bob for the API you plan to use with this service user. 1. From the top right, click **Edit**. 2. Select the area of Bob you'd like to manage. 3. Mark the checkbox(es) to enable or disable the permissions you'd like to give the service users in this group. ![5da0461-image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_hibob_service_user_id_and_service_user_token_step_by_step_guide-1.png) **'View', 'edit' and 'view history'** To access Employee data via the API you need to grant basic view access to each category: - **View all employees' [Category name] sections** If you want to access history data (such as all the entries in the Lifecycle or Work table), you also need to grant the history access: - **View all employees' [Category name] section histories**. If you want to update data or access sensitive data that requires an extra layer of permissions, you need to grant the edit permission: - **Edit all employees' [Category name] sections** ### **3.3 Access rights** Grant access to the people whose data you want to access via the API you plan to use with this service user. 1. From the **People's data** tab, click **Access data for**. 2. Select whose data these permissions apply to (by default, the access rights are set to all employees using the **Lifecycle status equals Any** condition). Options are: - **Everyone**: all employees marked as Employed in the system. > **Note**: By default, the service user's permission group grants access to all **active** employees. To access inactive employees you need to use **select by condition** and remove the condition: **Lifecycle status equals Employed** from the permission group's **Access data for** settings. - **Select people by condition**: click **Edit**, select conditions from the dropdown menu, then click **Apply**. - **Select people by name**: click **Edit**, select specific employees, then click **Apply**. ![c66efe9d529d03feeac57366db8107db3dec560db38cf0c6bd9910e1bcc618d3-image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_hibob_service_user_id_and_service_user_token_step_by_step_guide-2.png) To learn more, see [Create a service user permission group](https://help.hibob.com/hc/en-us/articles/29550415706897). You've now created your HiBob Service User ID and Service User Token - just one of many if you support multiple vendors. Each one adds new auth flows, schema differences, and ongoing maintenance. Unified.to removes that overhead with unified objects that work across 1412 APIs. Map once, launch everywhere. Your team ships features instead of debugging vendor-specific edge cases. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how much faster you can launch customer integrations with Unified.to. ## How to get your HighLevel API Key and Location ID: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide # How to get your HighLevel API Key and Location ID: Step-by-step guide ------ _April 24, 2023_ HighLevel supports authentication with API Key and Location ID. To connect, you'll need to generate secure API Key and Location ID from your account settings. This short guide shows you how to generate one, whether you're testing in your sandbox or helping customers connect their HighLevel account. This guide walks you through creating a **HighLevel token-based connection** using a **sub-account (location) API key** in Unified. --- ## ⚠️ Important Context HighLevel operates on two levels: - **Agency level (company)** - **Sub-account level (location)** ✅ _(token-based authentication works only here)_ To access **CRM** and **HRIS** data, you must use a **sub-account (location)** API key. > **Note:** HighLevel offers multiple ways to create connections. To avoid issues, follow every step in this guide carefully. --- ## ✅ Prerequisites - You must have **Admin** or **Owner** access to the sub-account. ## 🔧 Step-by-Step Setup ### 1. Navigate to Your Sub-Account Go to your **HighLevel sub-account (location)**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide-0.png) ### 2. Open Private Integrations - Go to **Settings → Private Integrations** _(inside the sub-account)_ ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide-1.png) ### 3. Create a New Integration - Click **Create Integration** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide-2.png) ### 4. Name Your Integration - Provide a clear name for your token (e.g., `Unified Integration`) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide-3.png) ### 5. Select Required Scopes Choose scopes based on your use case: | Permission | Resource | HighLevel Scopes | | ---------- | ------------- | ------------------------------------------------ | | Read | CRM Contact | `contacts.readonly` | | Write | CRM Contact | `contacts.write` | | Read | CRM Company | `businesses.readonly`, `locations/tags.readonly` | | Write | CRM Company | `businesses.write`, `locations/tags.write` | | Read | CRM Deal | `opportunities.readonly` | | Write | CRM Deal | `opportunities.write` | | Read | HRIS Employee | `users.readonly` | | Write | HRIS Employee | `users.write` | | Read | CRM Lead | `contacts.readonly` | | Write | CRM Lead | `contacts.write` | | Read | CRM Pipeline | `opportunities.readonly` | | Read | CRM Event | `forms.readonly`, `contacts.readonly` | | Write | CRM Event | `contacts.write` | **Reference:** [https://marketplace.gohighlevel.com/docs/Authorization/Scopes](https://marketplace.gohighlevel.com/docs/Authorization/Scopes) ### 6. Create and Confirm - Click **Create** - Then click **Confirm** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide-4.png) ### 7. Copy API Token - You will receive an **API Token** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide-5.png) - Store it securely (you won't be able to retrieve it later) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide-6.png) ### 8. Get Location ID To find your **Location ID**: ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide-7.png) - Look at the URL of your sub-account Example: ```plain text https://app.gohighlevel.com/v2/location/tG38xt5twyoGISw4o92p/settings/private-integrations/... ``` - The value after `/location/` is your **Location ID** → `tG38xt5twyoGISw4o92p` ### 9. Connect in Unified - Go to: [https://app.unified.to/integrations/highlevel](https://app.unified.to/integrations/highlevel) - Select **API TOKEN** - Click **Activate** --- ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide-8.png) ### 10. Create Connection - Paste: - **API Token** - **Location ID** - Click **Create Connection** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_highlevel_api_key_and_location_id_step_by_step_guide-9.png) ## ✅ Done Your HighLevel token-based connection should now be successfully set up in Unified. --- If it doesn't work, double-check: - You created the token at the **sub-account level** - The correct **scopes** are selected - The **Location ID** matches the sub-account If you have any more doubts or issues, please contact the [Unified.to](https://unified.to/) team for support. You've now created your HighLevel API Key and Location ID - just one of many if you support multiple vendors. Each one adds new auth flows, schema differences, and ongoing maintenance. Unified.to removes that overhead with unified objects that work across 1323 APIs. Map once, launch everywhere. Your team ships features instead of debugging vendor-specific edge cases. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how much faster you can launch customer integrations with Unified.to. ## How to get your HubSpot developer key and OAuth 2 credentials (Legacy Apps) URL: https://docs.unified.to/guides/how_to_get_your_hubspot_developer_key_and_oauth_2_credentials_legacy_apps # How to get your HubSpot developer key and OAuth 2 credentials (Legacy Apps) ------ _February 12, 2023_ This guide will show you how to retrieve your OAuth 2 credentials and developer API key in HubSpot. **Note**: HubSpot allows you to create different types of apps - public and private. This guide is about public apps. ## Register a HubSpot Developer Account First, you'll need to register for a free account on HubSpot if you haven't already. 1. Navigate to the [HubSpot Developers portal](https://developers.hubspot.com/get-started). 2. Click **Create App Developer Account.** 3. Follow the onboarding to complete your account setup. ## Get your HubSpot Developer API key Developer account API keys are separate from standard API keys - they can be used to manage subscriptions for webhooks and other HubSpot features. 1. Inside the HubSpot developer dashboard, open the sidebar and click [Apps](https://app.hubspot.com/developer/47094559/applications). 2. Click **Get HubSpot API key**. 3. Follow the instructions to create a Developer API key. 4. Click **Show key** to reveal your key. You will need this for the final step. ## Create a HubSpot app A HubSpot app is a container for your integration settings and is where you'll create and find your OAuth 2 credentials. 1. On the Development page → Legacy Apps→ click **Create Legacy App →** Select **Public App.** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_hubspot_developer_key_and_oauth_2_credentials_legacy_apps-0.png) 1. Give your app a name and description. 2. Click on the **Auth** tab. 3. Under **Redirect URLs,** enter: **`https://api.unified.to/oauth/code`** ![Screenshot_2024-08-16_at_3.41.50_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_hubspot_developer_key_and_oauth_2_credentials_legacy_apps-1.png) 4. Under **Scopes,** click **Add new scopes** and select the permissions that your application will require. For example, if your application reads Deals, then select `crm.deal.read` ![ba5f5e7e-5c03-49b4-8662-75d3a50f4f01.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_hubspot_developer_key_and_oauth_2_credentials_legacy_apps-2.png) 5. Click **Create app** to finish setting up your app. ## (optional) Add the `oauth` scope to your app For apps created before April 2024, the `oauth` scope may be missing from your app's auth settings. Apps created after this date have it on by default. This scope is required for the HubSpot integration to work. 1. Under **Scopes**, click **Add new scope.** 2. Search for **oauth** and select it. 3. Click **Update**. ## Get your OAuth 2 credentials 1. After creating your app, you will be redirected to the App Info tab. Click again on the **Auth** tab. 2. Your unique **Client ID and Client secret** will be displayed. You will need these for the next step. ![Screenshot_2024-08-16_at_3.52.29_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_hubspot_developer_key_and_oauth_2_credentials_legacy_apps-3.png) ## Activate the HubSpot integration in Unified.to with your client ID and secret 1. Open a new tab and navigate to [the HubSpot integration in Unified.to](https://app.unified.to/integrations/hubspot) 2. You will need the following three pieces of information from the earlier steps: 1. **Client ID** 2. **Client secret** 3. **Developer API key** from the developer dashboard 3. Copy and paste these values into their respective fields on the page. Make sure that **Your OAuth 2 credentials** is selected above. ![Screenshot_2024-08-16_at_4.19.04_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_hubspot_developer_key_and_oauth_2_credentials_legacy_apps-4.png) ## See also - [How to set up your scopes in HubSpot](https://docs.unified.to/guides/how_to_set_up_your_scopes_in_hubspot) - [How to configure webhooks in HubSpot](https://docs.unified.to/guides/how_to_configure_webhooks_in_hubspot) ## How to get your Humaans API Key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_humaans_api_key_step_by_step_guide # How to get your Humaans API Key: Step-by-step guide ------ _November 14, 2023_ When connecting Humaans to other platforms, authentication requires an API Key. Follow these step-by-step instructions to generate one from your Humaans account, or provide them to your customers if they need to connect their account. 1. Log in to the Humaans Admin Dashboard 2. Go to Settings 3. Open API access tokens 4. Click Create new token 5. Give it a name (e.g. "Unified.to") 6. Copy the generated token 7. Paste it into the API Token field 8. Click Authorize **Note:** You'll need **Admin access** in Humaans to see "API access tokens." You've now retrieved your Humaans API Key. That's one system covered, but scaling integrations one by one means repeating this process hundreds of times. Unified.to handles this for you: 1659 integrations out of the box, all with normalized schemas, real-time delivery, and Virtual Webhooks. No polling scripts, no retry logic, no maintenance backlog. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how Unified cuts integration work from quarters to days. ## How to get your iCIMS API Username, API Password and Customer ID: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_icims_api_username_api_password_and_customer_id_step_by_step_guide # How to get your iCIMS API Username, API Password and Customer ID: Step-by-step guide ------ _April 24, 2023_ When connecting iCIMS to other platforms, authentication requires a API Username, API Password and Customer ID. Follow these step-by-step instructions to generate one from your iCIMS account, or provide them to your customers if they need to connect their account. # How to get your iCIMS API credentials This guide explains how to obtain the required credentials for connecting iCIMS. You'll need to work with iCIMS support to get these credentials. ## Required credentials To connect iCIMS, you'll need: - Customer ID (your unique platform identifier) - API Username - API Password ## Steps to obtain your credentials ### Step 1: Contact iCIMS support 1. Reach out to your iCIMS account representative or support team 2. Request API access for integration purposes 3. Specify that you need: - Customer ID - API Username - API Password ### Step 2: Receive your credentials iCIMS support will provide you with: - Your unique Customer ID - API Username - API Password ### Step 3: Verify access 1. Once you receive your credentials, keep them in a secure location 2. Enter the credentials in the inputs on this page to create a connection to your iCIMS account ## Important notes - These credentials must be provided by iCIMS support - The Customer ID is unique to your organization Getting your iCIMS API Username, API Password and Customer ID is the first step. The bigger challenge is supporting dozens of vendors - each with unique auth flows, schemas, and maintenance requirements. Unified.to is a real-time integration platform built to replace that work. Instead of building and maintaining 514 custom connectors, you get one secure, normalized API layer across 30 SaaS tools. On top of that, our usage-based pricing scales cleanly with your product. No per-connection fees, no setup charges, and no lock-in, just infrastructure economics that grow with you. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to discover how predictable integration costs should be. ## How to get your JobDiva Client ID, Username/email and Password: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_jobdiva_client_id_username_email_and_password_step_by_step_guide # How to get your JobDiva Client ID, Username/email and Password: Step-by-step guide ------ _August 18, 2023_ If you're building an integration with JobDiva, you'll need an Client ID, Username/email and Password from your customer's JobDiva account to authenticate requests. This guide shows you (or your customers) how to generate one from JobDiva's account settings. # Obtain a JobDiva Client ID Contact JobDiva Support to obtain a Client ID to use for your integration. # Create a JobDiva API User A JobDiva admin must establish a dedicated API username and password. In JobDiva: 1. Navigate to Settings > My Team > Add User. 2. Add a new user. 3. Allow to access JobDiva API Calls permission for the user. With your JobDiva Client ID, Username/email and Password in hand, you're ready to connect. But managing keys, tokens, and refresh flows across every vendor quickly becomes a security risk. Unified.to abstracts all of that behind a single, secure layer. We never store customer data, credentials are always under your control, and our permissioning model removes the risk of misconfigured scopes. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how Unified keeps integrations real-time, compliant, and safe at scale. ## How to get your Jobvite API Key, API Key Secret and Email: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_jobvite_api_key_api_key_secret_and_email_step_by_step_guide # How to get your Jobvite API Key, API Key Secret and Email: Step-by-step guide ------ _August 18, 2023_ To access Jobvite data or automate workflows through an integration, a API Key, API Key Secret and Email are required. This guide walks you through where to find it and how to set it up for development or end-customer use. ## How do I get my Jobvite API key and secret? Your API key and secret are both provided by Jobvite's Customer Success team. To request API credentials, file a support ticket at: [https://www.jobvite.com/support/submit-a-case/](https://www.jobvite.com/support/submit-a-case/) (Note: you will need to be logged in to submit a case. If you don't see a button titled **Submit a Request**, reach out to your account representative i.e. your Customer Success Manager or Account Manager) ### Submit a ticket - Select the **issue type** from the dropdown menu - Add a **Subject** - Add a detailed **description** of the request: - **API names** that you want to grant access to - **Email** of a dedicated Jobvite user to determine that an update was made by the API and not an actual user. This email address needs to accept the Jobvite registration process. Assign the **Administrator** role to this user. Alternatively, you may start a support chat (see bottom right corner of Help Center) to chat with their support team for guidance on retrieving an API Key. Please make sure that Jobvite gives you access to: - New User module - Enable New Contact Details UI **NOTE:** Please note that access to the Contacts API requires that the Jobvite Customer has enabled the Jobvite Engage product on their account. The contacts API is not supported for customers who have not enabled the Jobvite Engage product (additional fee required). JobVite Contacts are required for access to Candidates. You've now retrieved your Jobvite API Key, API Key Secret and Email. That's one system covered, but scaling integrations one by one means repeating this process hundreds of times. Unified.to handles this for you: 805 integrations out of the box, all with normalized schemas, real-time delivery, and Virtual Webhooks. No polling scripts, no retry logic, no maintenance backlog. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how Unified cuts integration work from quarters to days. ## How to get your Lever API Key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_lever_api_key_step_by_step_guide # How to get your Lever API Key: Step-by-step guide ------ _April 24, 2023_ When connecting Lever to other platforms, authentication requires an API Key. Follow these step-by-step instructions to generate one from your Lever account, or provide them to your customers if they need to connect their account. 1. To generate API credentials, navigate to Settings > Integrations and API > API Credentials. 2. Click the Generate New Key button ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_lever_api_key_step_by_step_guide-0.png) 3. Input a name for the key that reflects the service or integration that will be using the key 4. Under the 'Permissions' heading, select "Select all" ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_lever_api_key_step_by_step_guide-1.png) 5. Click the Copy Key button next to the API key (which can be found next to the 'Key name' field). ![image](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_lever_api_key_step_by_step_guide-2.png) 6. Click done Generating a Lever API Key is the first step. The harder part is building pipelines that stay reliable as customers add more systems. Unified.to gives you options: deliver data via API, streaming webhooks, database sync, or even as MCP tools for AI agents. One integration layer that adapts to your architecture instead of forcing tradeoffs. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how we simplify integration delivery across 1412 integrations and 37 categories. ## How to get your Loxo Agency slug, API Key and Agency ID: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_loxo_agency_slug_api_key_and_agency_id_step_by_step_guide # How to get your Loxo Agency slug, API Key and Agency ID: Step-by-step guide ------ _August 18, 2023_ If you're building an integration with Loxo, you'll need an Agency slug, API Key and Agency ID from your customer's Loxo account to authenticate requests. This guide shows you (or your customers) how to generate one from Loxo's account settings. ### Where do I find my API Key? To get your API Key, we'll need to go to Loxo. Once there, click on your profile icon and then Settings. ![Loxo-Settings-1024x565.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_loxo_agency_slug_api_key_and_agency_id_step_by_step_guide-0.png) In the Settings view, look for the API Keys card and click on it. ![Loxo-Settings-API-1024x577.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_loxo_agency_slug_api_key_and_agency_id_step_by_step_guide-1.png) In the API Keys view, click the Add button. ![Loxo-Settings-API-Key-1024x571.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_loxo_agency_slug_api_key_and_agency_id_step_by_step_guide-2.png) ### How can I get my Agency Slug? To get your agency slug, navigate to your agency's career page. You can find it under your profile's menu. ![Loxo-Career-Page-1024x570.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_loxo_agency_slug_api_key_and_agency_id_step_by_step_guide-3.png) On the Careers page, your agency slug will be the first part of the path, after the domain. **Example**: `https://app.loxo.co/weblime-consultants` = _weblime-consultants_ You've now retrieved your Loxo Agency slug, API Key and Agency ID. That's one system covered, but scaling integrations one by one means repeating this process hundreds of times. Unified.to handles this for you: 1319 integrations out of the box, all with normalized schemas, real-time delivery, and Virtual Webhooks. No polling scripts, no retry logic, no maintenance backlog. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how Unified cuts integration work from quarters to days. ## How to get your Microsoft Active Directory / Entra ID Client ID, Client Secret and Tenant ID: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_microsoft_active_directory_entra_id_client_id_client_secret_and_tenant_id_step_by_step_guide # How to get your Microsoft Active Directory / Entra ID Client ID, Client Secret and Tenant ID: Step-by-step guide ------ _April 24, 2023_ Developers connecting to Microsoft Active Directory / Entra ID will need an Client ID, Client Secret and Tenant ID for authentication. Here's how to generate, configure, and copy your Microsoft Active Directory / Entra ID Client ID, Client Secret and Tenant ID so you can use it in your integration or share steps with your customers. # Microsoft AD/Entra ID Client ID and Secret Authentication ## **Step: 1 Register an application** Registering your application establishes a trust relationship between the 3rd-party application and the Microsoft identity platform. The trust is unidirectional: the application trusts the Microsoft identity platform, and not the other way around. Once created, the application object can't be moved between different tenants. 1. Sign in to the [Microsoft Entra admin center](https://entra.microsoft.com/). 2. If you have access to multiple tenants, use the **Settings** icon in the top menu to switch to the tenant in which you want to register the application from the **Directories + subscriptions** menu. 3. Browse to **Identity** > **Applications** > **App registrations** and select **New registration**. 4. Enter a display **Name** for the application. 5. Specify who can use the application in the **Supported account types** section 6. Select **Register** to complete the initial app registration. ![portal-02-app-reg-01.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_active_directory_entra_id_client_id_client_secret_and_tenant_id_step_by_step_guide-0.png) When registration finishes, the Microsoft Entra admin center displays the app registration's **Overview** pane. On this page, the app was assigned values for: - **Application (client) ID** which uniquely identifies your application in the Microsoft cloud ecosystem, across all tenants. - **Object ID** which uniquely identifies your application in your tenant. ![portal-03-app-reg-02.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_active_directory_entra_id_client_id_client_secret_and_tenant_id_step_by_step_guide-1.png) ## Step 2: Generate Client Secret Credentials are used by [confidential client applications](https://learn.microsoft.com/en-us/entra/identity-platform/msal-client-applications) that access a web API. Examples of confidential clients are web apps, other web APIs, or service-type and daemon-type applications. Credentials allow your application to authenticate as itself, requiring no interaction from a user at runtime. ![portal-05-app-reg-04-credentials.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_active_directory_entra_id_client_id_client_secret_and_tenant_id_step_by_step_guide-2.png) For this case, go to the Client Secrets tab, to generate a new Client Secret. The generated secret's value will be the one that will be used in Unified's authentication process. ## **Step 3: Configure permissions for Microsoft Graph** Microsoft Graph exposes [application permissions](https://learn.microsoft.com/en-us/graph/permissions-overview#application-permissions) for apps that call Microsoft Graph with their own identity. These permissions always require administrator consent. Preconfigure the application permissions the app needs when you register the app. An administrator can consent to these permissions either by using the [Microsoft Entra admin center](https://entra.microsoft.com/) when they install the app in their organization, or you can provide a sign-up experience in the app through which administrators can consent to the permissions you configured. Once Microsoft Entra ID records the administrator consent, the app can request tokens without having to request consent again. To configure application permissions for the app in the app registrations experience on the Microsoft Entra admin center, follow these steps: - On the application's **API permissions** page, choose **Add a permission**. - Select **Microsoft Graph** > select **Application permissions**. - In the **Select Permissions** dialog, choose the permissions to configure to the app. The following screenshot shows the **Select Permissions** dialog box for Microsoft Graph application permissions. ![Screenshot_2025-09-18_at_11.37.04_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_active_directory_entra_id_client_id_client_secret_and_tenant_id_step_by_step_guide-3.png) ## **Step 4: Grant tenant-wide admin consent in Enterprise apps pane** You can grant tenant-wide admin consent through the **Enterprise applications** pane if the application is already provisioned in your tenant. For example, an app could be provisioned in your tenant if at least one user consents to the application. For more information, see [How and why applications are added to Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity-platform/how-applications-are-added). To grant tenant-wide admin consent to an app listed in **Enterprise applications** pane: 1. Sign in to the [Microsoft Entra admin center](https://entra.microsoft.com/) as at least a [Cloud Application Administrator](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#cloud-application-administrator). 2. Browse to **Entra ID** > **Enterprise apps** > **All applications**. 3. Enter the name of the existing application in the search box, and then select the application from the search results. 4. Select **Permissions** under **Security**. ![grant-tenant-wide-admin-consent.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_active_directory_entra_id_client_id_client_secret_and_tenant_id_step_by_step_guide-4.png) 5. Carefully review the permissions that the application requires. If you agree with the permissions the application requires, select **Grant admin consent**. ## Step 5: Setup Authentication in Unified Setup the Authentication credentials in Unified using the client ID, and generated secret, along with the tenant ID ![Screenshot_2025-09-18_at_12.09.32_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_active_directory_entra_id_client_id_client_secret_and_tenant_id_step_by_step_guide-5.png) You've now created your Microsoft Active Directory / Entra ID Client ID, Client Secret and Tenant ID - just one of many if you support multiple vendors. Each one adds new auth flows, schema differences, and ongoing maintenance. Unified.to removes that overhead with unified objects that work across 514 APIs. Map once, launch everywhere. Your team ships features instead of debugging vendor-specific edge cases. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how much faster you can launch customer integrations with Unified.to. ## How to get your Microsoft Azure AD OAuth 2 credentials URL: https://docs.unified.to/guides/how_to_get_your_microsoft_azure_ad_oauth_2_credentials # How to get your Microsoft Azure AD OAuth 2 credentials ------ _June 3, 2024_ This guide will show you how to obtain your OAuth 2 credentials for Microsoft Azure Active Directory (Azure AD), which are also needed for other integrations in the Microsoft Azure ecosystem e.g. Outlook, Teams, and Dynamics. ## Register an app in Microsoft Azure Portal 1. Navigate to [Microsoft Azure Portal](https://portal.azure.com/) 2. Search for **Azure Active Directory** and select it. ![oidc-microsoft-1.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_azure_ad_oauth_2_credentials-0.png) 3. From the sidebar, under Manage, click **App registrations** and then **New registration.** ![oidc-microsoft-2.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_azure_ad_oauth_2_credentials-1.png) 4. Fill in the following form to register a new app: 1. Enter any name you'd like for your app. 2. Select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow anyone to log in. 3. Under **Redirect URI**, enter [`https://api.unified.to/oauth/code`](https://api.unified.to/oauth/code) 4. Click **Register**. ![oidc-microsoft-3.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_azure_ad_oauth_2_credentials-2.png) ## Get your client ID and client secret 1. After registering your app and on the resulting page, copy the **Client ID** (found next to **Application (client) ID**) 2. Click on **Add a certificate or secret** (found next to **Client credentials)** ![oidc-microsoft-4.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_azure_ad_oauth_2_credentials-3.png) 3. Under **Client secrets (0)**, click **New client secret**. ![oidc-microsoft-5.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_azure_ad_oauth_2_credentials-4.png) 4. Click **Add** - do not change anything else in the dialog. ![oidc-microsoft-6.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_azure_ad_oauth_2_credentials-5.png) 5. On the next page, copy the **Value** ![oidc-microsoft-7.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_azure_ad_oauth_2_credentials-6.png) ## Enter your OAuth credentials on Unified.to 1. Take note of your Microsoft client ID and client secret from the above steps 2. Navigate to the [app.unified.to](https://app.unified.to/) and find the integration page for the Microsoft integration you are interested in e.g. [https://app.unified.to/integrations/microsoft_ad](https://app.unified.to/integrations/microsoft_ad) 3. Enter your client ID 4. Enter your client secret ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_azure_ad_oauth_2_credentials-7.png) 5. Make sure that you enable the correct permission scopes for your application. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_microsoft_azure_ad_oauth_2_credentials-8.png) ## Troubleshooting **I'm getting an 'invalid client secret' error** This error indicates a problem with the client ID and/or secret that you generated. Please ensure you copied the correct values to the integration settings page on Unified.to, or regenerate these credentials in Microsoft Azure Active Directory and then try again. ## How to get your OAuth 2 credentials for Gmail URL: https://docs.unified.to/guides/how_to_get_your_oauth_2_credentials_for_gmail # How to get your OAuth 2 credentials for Gmail ------ _June 25, 2024_ _Last updated: May 2026_ To access Google services such as Gmail (also known as Google Mail) through the [Unified API](https://unified.to/technology), you'll generate and retrieve your [OAuth](https://unified.to/embeddedauth) 2 credentials in the Google Cloud Console, then enter them in Unified.to. ## Create or select a project on Google Cloud Console 1. Navigate to the [Google Cloud Console](https://console.cloud.google.com/). 2. From the top nav bar, select an existing project or create a new one. - If creating a new project, name it whatever you want and click **Create**. ![Screenshot_2024-09-09_at_9.56.18_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_oauth_2_credentials_for_gmail-0.png) ## Enable the Gmail API 1. With your project selected, open the left sidebar and navigate to **APIs & Services > Library** (or search for **Library** from the top nav bar). 2. Search for **Gmail API** and click the matching result. 3. Click **Enable**. ## Configure your OAuth consent screen Google requires you to configure the OAuth consent screen before it will let you create an OAuth client ID. The consent screen is what users see when they grant your app access to their Gmail. 1. Open the left sidebar and navigate to **APIs & Services > OAuth consent screen**. 2. Choose the user type: - **Internal** — for Google Workspace organization-internal apps. - **External** — for consumer Gmail accounts or public apps. 3. Fill in the required fields: app name, user support email, and developer contact information (and, for production, your authorized domains). 4. Complete the consent screen setup. ## Get your OAuth 2 credentials 1. Navigate to **APIs & Services > Credentials** (or search for **Credentials** from the top nav bar). 2. Click **Create Credentials** and select **OAuth client ID**. 3. Select **Web application** as the application type, and name your app whatever you want. 4. Under **Authorized redirect URIs**, enter `https://api.unified.to/oauth/code` (or the corresponding EU/AU regional URL if your workspace is hosted there). 5. Click **Create**. 6. A dialog will appear displaying your new **Client ID** and **Client Secret**. Copy both — you'll need them for the next step. > Store your Client Secret securely. Never commit it to source control or include it in client-side code; keep it in environment variables or a secrets manager. ## Activate Gmail in Unified.to 1. Navigate to the [Gmail integration page on Unified.to](https://app.unified.to/integrations/googlemail). 2. On the **Authorization** tab, with **OAuth 2** selected as the authentication method, enter your **Client ID** and **Client Secret**. 3. Click **Update** to save your changes and activate the Gmail integration. ## Set your OAuth 2 reauthentication policy If you're connecting a **Google Workspace** account, make sure your reauthentication policy is set correctly for the integration to keep functioning. This step requires Google Workspace admin access and doesn't apply to consumer Gmail accounts. 1. In the Google Workspace Admin Console, navigate to **Menu > Security > Access and data control > Google Cloud session control**. 2. On the left, select the organizational unit where you want to set the session length. For all users, select the top-level organizational unit. (An organizational unit initially inherits its parent's settings.) 3. Under **Reauthentication policy**, select **Never require reauthentication**. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_oauth_2_credentials_for_gmail-1.png) You can now use the Unified API to build new experiences with Gmail. Happy building! → [Start your 30-day free trial](https://app.unified.to/login) → [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) ## How to get your OAuth 2 credentials for Microsoft Dynamics 365 URL: https://docs.unified.to/guides/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365 # How to get your OAuth 2 credentials for Microsoft Dynamics 365 ------ _October 29, 2024_ _Last updated: May 2026_ This guide explains how to obtain your OAuth 2.0 credentials and configure API permissions for Microsoft Dynamics 365 business applications in Microsoft Entra ID (formerly Azure Active Directory). ## Background Business applications in Microsoft Dynamics 365 use Microsoft's identity platform, Entra ID, for secure authentication. By registering your application, you establish a trusted connection that allows Unified.to to access Dynamics 365 data through the Dynamics 365 Web API (Dataverse) and, where applicable, Microsoft Graph and other Microsoft APIs. This works through: - **Delegated access**: Your application acts on behalf of the signed-in user. The user is redirected to Microsoft's sign-in page, authenticates, and grants your application consent, with permissions approved by both the administrator and the user. - **OAuth 2.0 credentials**: A client ID (which identifies your application) and a client secret (which serves as your application's password). - **Permissions**: Specific capabilities your application requests, which must be approved by an administrator. For more information, see [What is the Microsoft identity platform?](https://learn.microsoft.com/en-us/entra/identity-platform/v2-overview) ## Before you begin Ensure you have: - Access to the Microsoft Entra admin center ([entra.microsoft.com](https://entra.microsoft.com/)) - Administrator access to Microsoft Entra ID ## Register your application You'll first register an application in the Microsoft Entra admin center. This provides you with a client ID and client secret. 1. Sign in to the [Microsoft Entra admin center](https://entra.microsoft.com/). 2. In the left sidebar, go to **App registrations**, then click **New registration**. ![Screenshot_2024-10-31_at_11.43.24_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365-0.png) 3. Configure your application: - Enter a meaningful name (for example, your product name). - Under **Supported account types**, select **Accounts in any organizational directory (Any Microsoft Entra ID directory - Multitenant)**. - In the **Redirect URI** section: - Select **Web** as the platform. - Enter `https://api.unified.to/oauth/code` as the redirect URI (or the corresponding EU/AU regional URL if your workspace is hosted there). - Click **Register**. The redirect URI is where Microsoft sends users and authorization codes after they sign in and grant access, so it must exactly match what Unified.to expects. ![Screenshot_2024-10-31_at_11.45.21_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365-1.png) ## Get your Dynamics 365 client ID and secret 1. **Retrieve your client ID:** - On your application's overview page, locate the **Application (client) ID**. - Copy this value — this is your client ID. 2. **Create a client secret:** - In the left menu, select **Certificates & secrets**. - Under **Client secrets**, click **New client secret**. - Provide a description (optional, but helpful for tracking). - Set the expiration to its maximum value, unless your security policy requires shorter rotation. - Click **Add**. - Immediately copy the secret **Value** (not the secret ID). You won't be able to view it again after leaving this page, so store it securely right away — never commit it to source control or share it client-side. ## Configure Dynamics 365 API permissions 1. **Set up Microsoft Graph permissions.** These are used to obtain information about your users. - In the left menu, click **API permissions**. - Click **Add a permission**, select **Microsoft Graph**, then choose **Delegated permissions**. - Search for and add the following permissions: - `openid` - `email` - `offline_access` - Click **Add permissions**. 2. **Set up permissions for your Dynamics 365 business application.** - From the **API permissions** screen, click **Add a permission** again. - Under **More Microsoft APIs**, select the API backing your Dynamics instance: - If you're using **Dynamics Sales**, select **Dynamics CRM**. - If you're using **Dynamics Customer Engagement**, select **Customer Insights**. - Choose **Delegated permissions**. - Search for and add the following delegated permission on the Dynamics API: - `user_impersonation` (required for basic access) - Click **Add permissions**. ![Screenshot_2024-10-31_at_11.58.11_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365-2.png) Depending on the specific Dynamics app or environment, you may see slightly different API names (for example, Dataverse or a specific Dynamics module), but you should always choose the API backing your Dynamics instance and add `user_impersonation`. Consent to these permissions is granted when an administrator connects the integration through Unified.to's authorization flow. ## Enter your Dynamics 365 credentials on Unified.to 1. Go to [app.unified.to/integrations](https://app.unified.to/integrations) and search for the Dynamics 365 integration you're using. 2. Enter your client ID and client secret from the steps above. 3. Save your changes. You can now use the Microsoft Dynamics 365 integration in your application. Happy building! → [How to troubleshoot unhealthy connections](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections) [→ Start your 30-day free trial](https://app.unified.to/login) [→ Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) ## How to get your OAuth 2 credentials in PipeDrive URL: https://docs.unified.to/guides/how_to_get_your_oauth_2_credentials_in_pipedrive # How to get your OAuth 2 credentials in PipeDrive ------ _August 26, 2024_ This guide explains how to create a public developer app in Pipedrive and obtain your OAuth 2 credentials. You'll need these credentials in order to authenticate your app with Unified.to. _Note: The state parameter is not automatically available in Marketplace Manager. To enable it for your app, please write to marketplace.devs@pipedrive.com._ ## Before you begin Before you create a developer app in Pipedrive, ensure: - You have a Pipedrive developer sandbox account. If you don't have one, sign up at [Pipedrive's developer portal](https://developers.pipedrive.com/). ## Access the Developer Hub 1. Log in to your Pipedrive account. 2. Click on your profile name in the upper right corner of the top navigation bar. 3. Select **Developer Hub** from the drop-down menu. ![Screenshot_2024-08-26_at_3.02.21_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_oauth_2_credentials_in_pipedrive-0.png) ## Create a Pipedrive public app 1. Click **Create an app** (or "+ Create an app" if you have existing apps). 2. Select **Create public app**. 3. Fill in your app details under **Basic info**. 1. In the Callback URL field, choose based on your flow: - **Unified-hosted OAuth:** `https://api.unified.to/oauth/code` - **Pipedrive Marketplace (own install + uninstall):** your app callback URL. Marketplace requires one callback that handles OAuth install (GET) and uninstall (DELETE). Unified's callback cannot receive uninstall. ![Screenshot_2024-08-26_at_3.06.15_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_oauth_2_credentials_in_pipedrive-1.png) 4. Click **Save.** You will be redirected to the **OAuth & access scopes** page after your app is created. ## Configure scopes and get your OAuth 2 credentials 1. Select the scopes your app requires. Only choose scopes that are necessary for your app's functionality. 1. For example, if you want to be notified about new or updated deals from Pipedrive, select **Deals** and choose whether you need read-only access or full (i.e. create, update, read, delete) access. 2. Scroll down to the **Client ID** section. 3. Here you'll find your `client_id` and `client_secret`. Make a note of them as you will need it for the final step. 4. Click **Save**. ## Activate the Pipedrive integration in Unified.to with your client ID and secret 1. Open a new tab and navigate to [the Pipedrive integration in Unified.to](https://app.unified.to/integrations/pipedrive). 2. You will need the following two pieces of information from the earlier steps: 1. **Client ID** 2. **Client secret** 3. Copy and paste these values into their respective fields on the page. Make sure that **OAuth 2** is selected under **Authentication Method**. 4. Click **Activate**. ## Marketplace listing: BYO callback → import into Unified If you list on the Pipedrive Marketplace and must own the callback URL for install and uninstall: 1. Set Callback URL to your app (not Unified). 2. Activate Pipedrive in Unified with the same `client_id` / `client_secret` from this Marketplace app. 3. On install, exchange the code on your server for `access_token`, `refresh_token`, and `api_domain`. 4. Create the Unified connection with `POST /unified/connection` using the payload below. Set `auth.api_url` to the company host from Pipedrive's `api_domain` token field. Do **not** send `auth.api_domain` (rejected by connection validation) or `other_auth_info` on this OAuth import. 5. On uninstall (Pipedrive sends `DELETE` to your callback), revoke the refresh token at Pipedrive (`POST https://oauth.pipedrive.com/oauth/revoke`), then call `DELETE /unified/connection/{id}` in Unified. **Full import shape** (adjust `permissions` to match your scopes; set `api_url` to Pipedrive's token-response `api_domain` value — do not send `api_domain` as a connection field): ```json { "integration_type": "pipedrive", "categories": ["crm"], "permissions": [ "crm_deal_read", "crm_deal_write", "crm_contact_read", "crm_contact_write" ], "environment": "Production", "external_xref": "", "auth": { "access_token": "", "refresh_token": "", "expires_in": 3599, "api_url": "https://acme.pipedrive.com" } } ``` Use the same Marketplace `client_id` / `client_secret` when activating Pipedrive in Unified so token refresh works. General import guidance: [How to migrate or import your integrations into Unified.to](https://docs.unified.to/guides/how_to_migrate_or_import_your_integrations_into_unified). **Notes:** - One Marketplace app = one callback URL. You cannot point the same app at both Zig/your domain and `https://api.unified.to/oauth/code`. For Marketplace production, use your callback + import. If you still want Unified-hosted OAuth (e.g. internal testing), use a separate private/sandbox Pipedrive app with Unified's redirect. - Refresh works on imported connections when Unified's Pipedrive activation uses the same Marketplace `client_id` / `client_secret` that issued the tokens. Include `refresh_token` and `expires_in` on import; do not send `other_auth_info`. ## How to get your OpenAI API Key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_openai_api_key_step_by_step_guide # How to get your OpenAI API Key: Step-by-step guide ------ _February 8, 2024_ To access OpenAI data or automate workflows through an integration, an API Key is required. This guide walks you through where to find it and how to set it up for development or end-customer use. ## How to get an OpenAI API key (LLM + Ads) ### 1) Create / sign in to your OpenAI account 1. Go to [https://platform.openai.com/](https://platform.openai.com/) 2. Sign in (or create an account). ### 2) Create an API key (for LLM / general API use) 1. In the OpenAI Platform, open **API keys**: - [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys) 2. Click **Create new secret key**. 3. Give it a recognizable name (e.g., `unified-prod`, `unified-dev`). 4. Copy the key immediately and store it in your password manager / secrets vault. - You won't be able to see the full key again after you close the dialog. **Use this key for:** - LLM requests (Responses API / Chat Completions) - Embeddings - Ads API ### 3) Add billing (if required) If you get "insufficient_quota" or similar errors: 1. Open **Billing** in the OpenAI Platform: - [https://platform.openai.com/account/billing](https://platform.openai.com/account/billing) 2. Add a payment method and/or purchase credits (depending on your plan). ### OpenAI Ads --- OpenAI does **not** issue a separate "Ads API key". for the Unified API, you use the **same OpenAI Platform API key** created above. Getting your OpenAI API Key is the first step. The bigger challenge is supporting dozens of vendors - each with unique auth flows, schemas, and maintenance requirements. Unified.to is a real-time integration platform built to replace that work. Instead of building and maintaining 1635 custom connectors, you get one secure, normalized API layer across 37 SaaS tools. On top of that, our usage-based pricing scales cleanly with your product. No per-connection fees, no setup charges, and no lock-in, just infrastructure economics that grow with you. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to discover how predictable integration costs should be. ## How to get your Paycom SID/Username and Token/Password: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_paycom_sid_username_and_token_password_step_by_step_guide # How to get your Paycom SID/Username and Token/Password: Step-by-step guide ------ _August 28, 2023_ To access Paycom data or automate workflows through an integration, a SID/Username and Token/Password are required. This guide walks you through where to find it and how to set it up for development or end-customer use. Please request assistance from your Paycom Account Representative for the following items: 1. Enabling the REST API for your Paycom instance 2. Whitelisting your OrgChart server IP address 3. Creating an API Service User 4. Generating valid authentication credentials (SID and Token) Getting your Paycom SID/Username and Token/Password is the first step. The bigger challenge is supporting dozens of vendors - each with unique auth flows, schemas, and maintenance requirements. Unified.to is a real-time integration platform built to replace that work. Instead of building and maintaining 884 custom connectors, you get one secure, normalized API layer across 34 SaaS tools. On top of that, our usage-based pricing scales cleanly with your product. No per-connection fees, no setup charges, and no lock-in, just infrastructure economics that grow with you. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to discover how predictable integration costs should be. ## How to get your PCRecruiter Username, Password, Database ID, App Key and App ID: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_pcrecruiter_username_password_database_id_app_key_and_app_id_step_by_step_guide # How to get your PCRecruiter Username, Password, Database ID, App Key and App ID: Step-by-step guide ------ _August 18, 2023_ Developers connecting to PCRecruiter will need an Username, Password, Database ID, App Key and App ID for authentication. Here's how to generate, configure, and copy your PCRecruiter Username, Password, Database ID, App Key and App ID so you can use it in your integration or share steps with your customers. To find your Database ID: 1. Go to **System** in the top navigation bar. 2. Go to **API** in the menu that opens. 3. Go to **Api Settings** under API. 4. This will open a menu that displays the correct **"Api Database Id"**. Getting your PCRecruiter Username, Password, Database ID, App Key and App ID is the first step. The bigger challenge is supporting dozens of vendors - each with unique auth flows, schemas, and maintenance requirements. Unified.to is a real-time integration platform built to replace that work. Instead of building and maintaining 878 custom connectors, you get one secure, normalized API layer across 34 SaaS tools. On top of that, our usage-based pricing scales cleanly with your product. No per-connection fees, no setup charges, and no lock-in, just infrastructure economics that grow with you. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to discover how predictable integration costs should be. ## How to get your SAP SuccessFactors (OpenID Connect) Username, Password, Client ID, Client Secret, Dependency Name, IAS Host and API URL: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_sap_successfactors_openid_connect_username_password_client_id_client_secret_dependency_name_ias_host_and_api_url_step_by_step_guide # How to get your SAP SuccessFactors (OpenID Connect) Username, Password, Client ID, Client Secret, Dependency Name, IAS Host and API URL: Step-by-step guide ------ _August 13, 2025_ To access SAP SuccessFactors (OpenID Connect) data or automate workflows through an integration, a Username, Password, Client ID, Client Secret, Dependency Name, IAS Host and API URL are required. This guide walks you through where to find it and how to set it up for development or end-customer use. ## **Procedure** 1. Sign into the administration console of your SAP Cloud Identity Services instance. 2. Go to **Applications and Resources** **[Your OIDC Application]** **Application APIs** **Client Authentication** **Secrets**, add a new secret. Once created, copy the client ID and secret for later use. Note that you won't be able to retrieve the secret from the system later. For more information, see [Configure Secrets for API Authentication](https://help.sap.com/docs/cloud-identity-services/aa08922a434a456ba44982c9f4f4d790/dev-configure-secrets-for-api-authentication). You've now retrieved your SAP SuccessFactors (OpenID Connect) Username, Password, Client ID, Client Secret, Dependency Name, IAS Host and API URL. That's one system covered, but scaling integrations one by one means repeating this process hundreds of times. Unified.to handles this for you: 475 integrations out of the box, all with normalized schemas, real-time delivery, and Virtual Webhooks. No polling scripts, no retry logic, no maintenance backlog. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how Unified cuts integration work from quarters to days. ## How to get your SAP SuccessFactors Username, Company ID, Password and API URL: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_sap_successfactors_username_company_id_password_and_api_url_step_by_step_guide # How to get your SAP SuccessFactors Username, Company ID, Password and API URL: Step-by-step guide ------ _April 24, 2023_ When connecting SAP SuccessFactors to other platforms, authentication requires a Username, Company ID, Password and API URL. Follow these step-by-step instructions to generate one from your SAP SuccessFactors account, or provide them to your customers if they need to connect their account. Please follow the following instructions to obtain your SAP SuccessFactors API credentials. ## Step 1: Find your API Server URL - Go to this [link](https://help.sap.com/docs/SAP_SUCCESSFACTORS_PLATFORM/93f95815070049ebaaff042d8322d518/af2b8d5437494b12be88fe374eba75b6.html#api-servers) and find your data-center (DC). Remember your API Server URL. For example, if your domain was https://salesdemo2.successfactors.com, search for `salesdemo2` ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_sap_successfactors_username_company_id_password_and_api_url_step_by_step_guide-0.png) ## Step 2: Find your Username and Company ID - To find your SAP SuccessFactors `username`, log-in to SuccessFactors, then click on your profile image in the upper-right corner to view your username. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_sap_successfactors_username_company_id_password_and_api_url_step_by_step_guide-1.png) - You SHOULD create a new username/password just for API access so that you do not use your own login credentials. - To find your `Company ID`, you can click on `Show version information` in the top-right menu ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_sap_successfactors_username_company_id_password_and_api_url_step_by_step_guide-2.png) - An alternate method to find your `Company ID` under the company settings ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_sap_successfactors_username_company_id_password_and_api_url_step_by_step_guide-3.png) ## Step 3: Set Security Configuration - Set the `Allow Admin to Access OData API through Basic Authentication` permission in `Admin Center > OData API Basic Authentication Configuration` page. - If you require specific IP Addresses to access the SuccessFactors API, then use the following IP addresses: `44.199.69.244` `3.65.142.239` `13.239.151.208`. This is performed in the `Admin Center > OData API Basic Authentication Configuration` or the `API Center > OData IP Allowlisting` pages. You've now created your SAP SuccessFactors Username, Company ID, Password and API URL - just one of many if you support multiple vendors. Each one adds new auth flows, schema differences, and ongoing maintenance. Unified.to removes that overhead with unified objects that work across 1647 APIs. Map once, launch everywhere. Your team ships features instead of debugging vendor-specific edge cases. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how much faster you can launch customer integrations with Unified.to. ## How to get your Shopify Admin API access token and Store ID: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_shopify_admin_api_access_token_and_store_id_step_by_step_guide # How to get your Shopify Admin API access token and Store ID: Step-by-step guide ------ _November 14, 2023_ If you're building an integration with Shopify, you'll need an Admin API access token and Store ID from your customer's Shopify account to authenticate requests. This guide shows you (or your customers) how to generate one from Shopify's account settings. ## How do I find my Shopify access token and store ID? Your access token and store ID can be found under **Settings** _>_ **Apps and sales channels** _>_ **Develop Apps** _>_ **Your_App**_._ If you have not used access tokens in the Shopify admin before, this guide will walk you through how to do that as well as how to retrieve your unique store ID. Note: These instructions are adapted from the [Custom apps](https://help.shopify.com/en/manual/apps/app-types/custom-apps#get-the-api-credentials-for-a-custom-app) guide in Shopify. ### Step 1: Activate custom app development 1. In the Shopify admin dashboard, click **Settings** > [**Apps and sales channels**](https://admin.shopify.com/settings/apps). 2. Click **Develop apps**. ![Screenshot_2024-08-08_at_1.52.04_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_shopify_admin_api_access_token_and_store_id_step_by_step_guide-0.png) 3. Read the disclaimer, and then click **Allow custom app development**. ### Step 2: Create a custom app 1. On the same page, click **Create an app**. 2. In the modal that appears, enter the **App name** and select an **App developer**. This can be the store owner or any collaborator with the **Develop apps** permission. ![Screenshot_2024-08-13_at_2.06.21_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_shopify_admin_api_access_token_and_store_id_step_by_step_guide-1.png) 3. Click **Create app**. ### Step 3: Configure Admin API scopes 1. On the same page, click **Configure Admin API scopes** ![b16e9ec4-a348-4f1d-86fa-f4a3cd115591.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_shopify_admin_api_access_token_and_store_id_step_by_step_guide-2.png) 2. Select all of the following checkboxes (tip: use the search bar to quickly look these up) - **Customers:** `read_customers` - **Inventory:** `read_inventory`, `write_inventory` - **Locations:** `read_locations` - **Orders:** `read_orders`, `write_orders` - **Products:** `read_products`, `write_products` ![Screenshot_2024-08-14_at_12.30.29_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_shopify_admin_api_access_token_and_store_id_step_by_step_guide-3.png) 1. Click **Save** ### Step 4: Install your app and retrieve the access token 1. Navigate to **API credentials** and then click **Install app** ![Screenshot_2024-08-13_at_2.13.26_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_shopify_admin_api_access_token_and_store_id_step_by_step_guide-4.png) 2. In the modal that appears, click **Install** 3. The page will now display a form containing your access token. ![Screenshot_2024-08-08_at_4.22.49_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_shopify_admin_api_access_token_and_store_id_step_by_step_guide-5.png) 4. Click on **Reveal token once** 5. Copy and paste the token to the **Sign in and Authorize page** ### Step 6: Retrieve your Shopify store ID 1. While you are in the Shopify admin dashboard, observe the URL in your address bar - it should look something like this: `https://admin.shopify.com/store/9564e1-49` 2. Your unique Shopify store ID is the part that comes after `/store` e.g. `9564e1-49` in the above example 3. Copy and paste the ID to the **Sign in and Authorize** page You've now retrieved your Shopify Admin API access token and Store ID. That's one system covered, but scaling integrations one by one means repeating this process hundreds of times. Unified.to handles this for you: 1242 integrations out of the box, all with normalized schemas, real-time delivery, and Virtual Webhooks. No polling scripts, no retry logic, no maintenance backlog. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how Unified cuts integration work from quarters to days. ## How to get your Wayfair Client ID, Client Secret and Vendor Encrypted Key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_wayfair_client_id_client_secret_and_vendor_encrypted_key_step_by_step_guide # How to get your Wayfair Client ID, Client Secret and Vendor Encrypted Key: Step-by-step guide ------ _November 19, 2024_ If you're building an integration with Wayfair, you'll need an Client ID, Client Secret and Vendor Encrypted Key from your customer's Wayfair account to authenticate requests. This guide shows you (or your customers) how to generate one from Wayfair's account settings. Your `Encrypted API Key` can be found in the UI for Supplier management of Vendor apps. ## Steps: 1. Go to the Supplier Integration Status page 2. Select "Vendor Applications" tab 3. Hit "Manage" on the Catalog API row 4. Select your Vendor from the list 5. Grant the needed permissions (see overview of permissions below) 1. Note: Only the permissions that correspond to capabilities that a given Vendor has adopted will be viewable + grantable. 6. Overview of permissions: 1. **read-product-classes**; Gets the product classes that suppliers can list items for sale 2. **read-product-catalog**; Export the supplier product catalog 3. **write-product-generic-description**; Update the generic descriptions for products 4. **read-product-generic-description-update-status**; Checks the status of generic description updates 5. **read-product-generic-descriptions-by-part**; Get the existing generic descriptions given a supplier part number 6. **read-media-metadata-tags**; Get the metadata tags associated with media; 7. **read-media-uploaded**; Get the list of media that was uploaded for a product 8. **read-media-uploaded-status**; Get the uploaded status for a given media request 9. **write-media-upload-from-url;** Uploads new media and associates it with a product 10. **read-product-create-submit-status;** Check the status of a new product request 11. **read-product-create-questions**; Get the questions required by a product class for listing new items for sale 12. write-product-create-submit; Submit a new product to be listed on site 13. read-supplier-brand-associations; Get the supplier brands that are authorized for the supplier to sell 7. On the Vendor application page, can copy their encrypted key by pressing the "Copy Encrypted Key" button. 1. This encrypted key is just an encrypted supplier ID + name. This is more secure than using inputing SuID into the Vendor's system. Generating a Wayfair Client ID, Client Secret and Vendor Encrypted Key is the first step. The harder part is building pipelines that stay reliable as customers add more systems. Unified.to gives you options: deliver data via API, streaming webhooks, database sync, or even as MCP tools for AI agents. One integration layer that adapts to your architecture instead of forcing tradeoffs. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how we simplify integration delivery across 1453 integrations and 37 categories. ## How to get your Workable API Token and Subdomain: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_workable_api_token_and_subdomain_step_by_step_guide # How to get your Workable API Token and Subdomain: Step-by-step guide ------ _April 24, 2023_ Workable supports authentication with API Token and Subdomain. To connect, you'll need to generate secure API Token and Subdomain from your account settings. This short guide shows you how to generate one, whether you're testing in your sandbox or helping customers connect their Workable account. ## Generate an API access token This guide shows how to generate an API access token for your Workable account. To start, open the drop-down menu on the top right of your screen and select "Integrations" or press [here](https://www.workable.com/backend/account/integrations): ![1a4f1f5-integrations_small.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_workable_api_token_and_subdomain_step_by_step_guide-0.png) Now click on the "Generate new token" button: ![7d9ac5c-Screenshot_2022-08-11_at_10.39.40_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_workable_api_token_and_subdomain_step_by_step_guide-1.png) Once you click the generate button, you'll see the new access token being generated: ![59ebf0e-Screenshot_2022-08-11_at_10.40.04_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_workable_api_token_and_subdomain_step_by_step_guide-2.png) This token type does not support `r_employees` scope. There are also new scopes like `w_employees` and `r_account` that are not currently supported by access tokens already used for ATS related endpoints. You need to contact support to enable them for your account tokens. ### Subdomain You will also need the account subdomain which you can find in the company profile settings page: ![6b1bcd3-Screenshot_2022-08-11_at_12.34.55_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_get_your_workable_api_token_and_subdomain_step_by_step_guide-3.png) ### Revoking The Access Token Be aware that you can revoke this access token anytime, but if you decide to proceed, every script or application that accesses the Workable API through this token will stop functioning. To keep things running smoothly after revoking your current token, you will have to generate a new access token and update every script or application bound to the previous one. You've now retrieved your Workable API Token and Subdomain. That's one system covered, but scaling integrations one by one means repeating this process hundreds of times. Unified.to handles this for you: 944 integrations out of the box, all with normalized schemas, real-time delivery, and Virtual Webhooks. No polling scripts, no retry logic, no maintenance backlog. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how Unified cuts integration work from quarters to days. ## How to get your yotpo APP Key / Store ID and Secret Key: Step-by-step guide URL: https://docs.unified.to/guides/how_to_get_your_yotpo_app_key_store_id_and_secret_key_step_by_step_guide # How to get your yotpo APP Key / Store ID and Secret Key: Step-by-step guide ------ _April 4, 2025_ To access yotpo data or automate workflows through an integration, a APP Key / Store ID and Secret Key are required. This guide walks you through where to find it and how to set it up for development or end-customer use. # **Retrieving your app key** **Your app key is sometimes referred to as your Store ID.** **To retrieve your app key:** 1. **In Yotpo Reviews, click the Profile icon at the top right corner of the screen.** 2. **Select Account Settings >** [**General Settings**](https://settings.yotpo.com/#/general_settings)**. You'll find your App Key at the bottom of the General Settings section.** **If you need a Secret Key, you'll need to generate it.** # **Generating your secret key** **Your secret key is sometimes referred to as your API secret.** **To generate your secret key:** 1. **Follow the steps above to get to your** [**Account Settings**](https://settings.yotpo.com/#/general_settings)**.** 2. **From your General Settings, click Get secret key. We'll send an email with a verification code to the email address associated with your account.** **3. Paste the code from the email and click Submit. Your secret key will be revealed on the next screen. Remember, only share your secret key with parties that should have access to your data.** Generating a yotpo APP Key / Store ID and Secret Key is the first step. The harder part is building pipelines that stay reliable as customers add more systems. Unified.to gives you options: deliver data via API, streaming webhooks, database sync, or even as MCP tools for AI agents. One integration layer that adapts to your architecture instead of forcing tradeoffs. [Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) to see how we simplify integration delivery across 506 integrations and 30 categories. ## How to Handle Attachments in Invoice, Bill and Credit Memo URL: https://docs.unified.to/guides/how_to_handle_attachments_in_invoice_bill_and_credit_memo # How to Handle Attachments in Invoice, Bill and Credit Memo ------ _August 5, 2025_ Unified.to provides a standardized way to read, upload, and manage attachments (e.g., invoices, receipts, contracts) across various integrations like accounting, CRM, and etc platforms. Here's everything you need to know to get started with attachments via the `Storage File` endpoint. --- ### 🔍 How to Check If an Integration Supports Attachments To check if a specific integration supports reading or writing attachments: 1. **Go to the Integration Page** in the Unified.to dashboard. 2. Navigate to **Feature Support**. 3. Select the relevant **endpoint** (e.g., `Accounting → Invoice`). 4. Look under **Readable Fields** — if `attachments` is listed, the endpoint supports reading attachments. 🛠️ **Tip:** You can also check support programmatically using the `/storage` endpoint. --- ### 📂 How to List All Attachments To fetch all available attachments across an integration: - Use the `GET /storage` (Storage File List) endpoint. ⚠️ **Note:** Most integrations **do not support** listing all attachments globally. Unified.to reflects this limitation. However, some integrations like **Sage** _do_ support it. --- ### 📌 How to Fetch Attachments for a Specific Record (e.g., Invoice) To get attachments for a specific entity like an invoice, bill, or candidate: - Use the `GET /storage` endpoint. - Pass the corresponding `parent_id` (e.g., `invoice_id`) in the query parameters. Example: ```plain text http CopyEdit GET /storage-file?parent_id=inv_1234 ``` --- ### 🧾 How to Get Details of a Specific Attachment To retrieve metadata (name, file type, size, etc.) of a particular attachment: - Use either: - `GET /storage/:id` — to get a single attachment - `GET /storage` — to filter and retrieve multiple files with query params --- ### 📥 How to Download an Attachment We return a secure `download_url` in the response when you fetch an attachment. You can use this URL to download the file directly. ⚠️ **Note:** The `download_url` is **time-limited** and expires after a few minutes for security reasons. Always use the URL shortly after fetching. --- ### 📤 How to Upload or Create an Attachment To upload a new attachment: 1. Go to the **Integration Page** and confirm the `storage-file` endpoint is supported under Feature Support. 2. Use the `POST /storage-file` endpoint with required fields. Key fields: - `name` - `data` (base64 encoded) | `download_url` - `parent_id` (e.g., `invoice_id`, `bill_id`, `employee_id`) Unified.to will automatically associate the attachment with the parent entity. Example payload: ```json json CopyEdit { "file_name": "invoice-august.pdf", "file_data": "base64-encoded-data", "parent_id": "inv_1234" } ``` --- ### 🧷 How to Associate an Attachment with an Entity When uploading a file, simply set the `parent_id` to the ID of the resource (invoice, bill, employee, etc.). Unified.to handles the rest — no extra linking needed. --- ### 💬 Questions or Requests? - If you're unsure whether an integration supports attachments for a specific resource type, reach out to our support team. - If a feature isn't currently supported, let us know! We're always looking to expand support where possible. ## How to migrate or import your integrations into Unified.to URL: https://docs.unified.to/guides/how_to_migrate_or_import_your_integrations_into_unified # How to migrate or import your integrations into Unified.to ------ _October 25, 2024_ ![app.unified.to_connections_import_typedropbox.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_migrate_or_import_your_integrations_into_unified-0.png) This guide explains how to import your existing integrations into Unified.to, effectively creating new connections using your existing customer credentials. A connection is a secure link between your application and your customer's third-party account. While connections are typically created through an auth flow where users grant access through the provider's authorization page, you can also create connections by importing existing credentials that your customers have already provided to you. **Vendor-specific guides.** Moving from another unified API? Each guide maps that vendor's authorization, API calls, pagination, webhooks and passthrough to [Unified.to](https://unified.to/): - [Merge](https://docs.unified.to/migrate/merge) - [Apideck](https://docs.unified.to/migrate/apideck) - [Kombo](https://docs.unified.to/migrate/kombo) - [Finch](https://docs.unified.to/migrate/finch) - [Rutter](https://docs.unified.to/migrate/rutter) - [Migration overview](https://docs.unified.to/migrate/overview), for any other unified API ## When to use import (vs Unified OAuth) Use `POST /unified/connection` (import) only when you already hold end-user credentials—for example migrating from another platform, or completing OAuth on your own callback (BYO redirect) and then creating the Unified connection. - **Do not** paste your app `client_id` / `client_secret` into Import Connections. Those are workspace activation credentials (Activate Integration), not per-connection end-user tokens. - For new end-user connects where Unified can host the OAuth callback, prefer [Authorize new connection](https://docs.unified.to/unified/integration/Authorize_new_connection) or the [embedded auth component](https://docs.unified.to/concepts/embedded-components). ## Before you begin This guide assumes you have: - A Unified.to account. - Existing customer credentials for the integrations you want to import. - Basic understanding of REST APIs, authentication flows, and [connections](https://docs.unified.to/guides/end_users_integrations_and_connections#end-users-integrations-and-connections). ## Understand the authentication types Before importing your integrations, you need to determine which authentication type each integration uses. Unified.to supports two main authentication flows: ### API token authentication - Simple token: Requires a single token or key. - Multi-field token: Requires multiple credentials (e.g. API key + domain). ### OAuth 2 authentication - Requires client credentials (client ID and secret). - Requires access tokens and optional refresh tokens. - May include additional user information e.g. emails, names. ## Check instructions for the integration you want to import The Unified.to Core API contains information that will help you determine what you need to successfully import your integrations. 1. Make a GET request to the `/unified/integration` endpoint to get information about the integration you want to import, passing in the categories and/or names of the integrations you are interested in. 2. Check the following fields in the response: - `token_names`: Lists required credential fields for API token authentication. - `token_instructions`: Provides guidance on where to find these credentials. For example: ```javascript const baseUrl = 'https://api.unified.to/unified/integration'; const params = new URLSearchParams({ categories: ['ats', 'crm'].join(',') }); const url = `${baseUrl}?${params}`; // Make the request fetch(url, { method: 'GET', headers: { 'Authorization': `Bearer ${YOUR_API_KEY}`, 'Accept': 'application/json' } }) .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then(integrations => { // Loop through each integration and log its auth details integrations.forEach(integration => { console.log(`\n=== ${integration.name} ===`); // Log token information if it exists if (integration.token_names && integration.token_names.length > 0) { console.log('Required credentials:', integration.token_names); console.log('How to find credentials:'); integration.token_instructions?.forEach((instruction, index) => { console.log(`${index + 1}. ${instruction}`); }); } else { console.log('No token credentials required (likely uses OAuth)'); } }); }) .catch(error => { console.error('Error fetching integrations:', error); }); /* Example output: === ActiveCampaign === Required credentials: ["API Key", "Domain"] How to find credentials: 1. Your API key can be found in your account on the Settings page under the "Developer" tab... 2. Your API URL can be found in your account on the My Settings page under the "Developer" tab... === HubSpot === No token credentials required (likely uses OAuth) */ ``` **API reference:** [Get all integrations](https://docs.unified.to/unified/integration/Returns_all_integrations). ## Import an API token integration For single-token integrations, construct your connection object: ```json { "integration_type": NAME_OF_INTEGRATION, "permissions": [PERMISSIONS], "categories": [CATEGORIES], "environment": "Production", // or any other non-sandbox environment "auth": { "token": "your_customer_token" } } ``` For multi-field token integrations, use the `other_auth_info` array: ```json { "integration_type": NAME_OF_INTEGRATION, "permissions": [PERMISSIONS], "categories": [CATEGORIES], "environment": "Production", // or any other non-sandbox environment "auth": { "token": "your_customer_token" "other_auth_info": [ "first_credential", "second_credential" ] } } ``` **Important**: The order of credentials in `other_auth_info` must match the order of the `token_names` from the integration instructions (see above). ## Import an OAuth 2.0 integration 1. Construct your connection object with the OAuth credentials: ```json { "integration_type": "NAME_OF_INTEGRATION", "permissions": ["PERMISSIONS"], "categories": ["CATEGORIES"], "environment": "Production", "auth": { "access_token": "customer_access_token", "refresh_token": "customer_refresh_token", "expires_in": 3599, "api_url": "https://company.example.com", "emails": ["user@example.com"], "name": "User Name" } } ``` 1. Choose the redirect / callback model that matches your product: **Notes**: - Set `expiry_date` to the token's expiration date (if known) or today's date. - Include the authenticating user's email and name if available. - **Unified-hosted OAuth:** set the provider redirect URI to `https://api.unified.to/oauth/code`. - **BYO / Marketplace callback:** keep your own callback URL when the provider requires your app to own install (and often uninstall) on a single URL. Exchange the code on your server, then import the resulting tokens into Unified. Handle uninstall on your callback and call `DELETE /unified/connection/{id}`. ### Dual-auth integrations and company hosts Some integrations support both OAuth and API-token auth, and/or return a per-tenant API host in the token response (commonly copied onto `auth.api_url`). For OAuth imports: - Include `access_token` / `refresh_token` (and `expires_in` when known). - If the token response includes a company/instance URL, set `auth.api_url` to that value. Only send fields on the Unified connection `auth` schema (e.g. `api_url`) — vendor-only field names may be rejected by validation. - Activate the integration in Unified with the **same** OAuth app `client_id` / `client_secret` that issued the tokens, so refresh works. - Omit `other_auth_info` on OAuth imports (see warning below). > ⚠️ **Dual-auth integrations** (integrations that support both OAuth and API-token auth): do **not** include `other_auth_info` on an OAuth import. If `other_auth_info` is present, Unified uses API-token mode instead of Bearer, which can return 401 even when your OAuth tokens are valid. Use `other_auth_info` only for API-token imports (values in `token_names` order). ## Create the connection Make a POST request to create the connection. Example using `curl`: ```shell curl -X POST "" \\ -H "Authorization: Bearer YOUR_API_KEY" \\ -H "Content-Type: application/json" \\ -d '{ "integration_type": "your_integration_type", "permissions": ["required_permissions"], "categories": ["integration_category"], "auth": { // Your auth object here } }' ``` **API reference**: [Create a connection](https://docs.unified.to/unified/connection/Create_connection) ## Test the imported connection After creating the connection: 1. Note the connection ID from the response. 2. Make a test API call using the new connection ID to verify it works. 3. If the call fails, check the error message and verify your auth credentials. ## Best practices when importing a connection - Always include relevant `permissions` for the minimum set of data access you require. - Use a non-sandbox `environment`. - Store the connection ID securely for future API calls. - Consider adding `external_xref` to link the connection to your customer's ID. - Never mix OAuth tokens with `other_auth_info` on the same connection for dual-auth integrations. - If the provider returns a company/instance URL in the token response, include it as `api_url` on import. ## See also - [How to associate a connection ID with your end-user](https://docs.unified.to/guides/how_to_associate_a_connection_id_with_your_end_user#how-to-associate-a-connection-id-with-your-end-user) - [How to troubleshoot unhealthy connections](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections#how-to-troubleshoot-unhealthy-connections) - [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks) ## How to obtain your GitHub OAuth2 credentials URL: https://docs.unified.to/guides/how_to_obtain_your_github_oauth2_credentials # How to obtain your GitHub OAuth2 credentials ------ _June 3, 2024_ In order to set up OAuth2 for Github, you need to create a new OAuth application in your [GitHub developer settings](https://github.com/settings/applications/new): ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_obtain_your_github_oauth2_credentials-0.png) Make sure to fill in the appropriate details, but more importantly the Authorization Callback URL, set to [`https://api.unified.to/oauth/code`](https://localhost:8080/_renarde/security/github-success). Now click on `Register application` and you'll be shown your application page: ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_obtain_your_github_oauth2_credentials-1.png) You need to click on `Generate a new client secret`, confirm your credentials, and write down your Client ID and Client secret (especially that one, because you will not be able to see it again later from that page, but you can always recreate one, do don't worry too much): ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_obtain_your_github_oauth2_credentials-2.png) Take your GitHub clientID and clientSecret and add them to the [Unified.to](https://unified.to/) integration settings for Github at [https://app.unified.to/integrations/github](https://app.unified.to/integrations/github) ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_obtain_your_github_oauth2_credentials-3.png) You will now be ready to use GitHub on [Unified.to](https://unified.to/) realtime unified API. ## How to Power Claude with Live Customer Data Using Unified MCP URL: https://docs.unified.to/guides/how_to_power_claude_with_live_customer_data_using_unified_mcp # How to Power Claude with Live Customer Data Using Unified MCP ------ _May 28, 2025_ ![MCPSERVER.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_power_claude_with_live_customer_data_using_unified_mcp-0.png) If you're building your software application with Anthropic's LLM API [a](https://claude.ai/)nd want more than a thin agent, your application needs access to real end-customer data — like pulling HubSpot deals, sending a Slack message, or updating a candidate in Greenhouse. You can wire this up yourself using Unified's API, which will save you a ton of time as it is a single API for multiple SaaS APIs. Or you can point your LLM API at Unified's MCP server and launch even faster. We'll break down when to use Unified's MCP server, and when to reach for the API instead. ## How Unified's MCP Server Works Unified's MCP Server exposes real, user-authorized SaaS integrations to any LLM that supports Model Context Protocol. Anthropic's API, Claude or Cursor can: - Discover tools based on a given Unified connection - Call those tools directly through our MCP endpoint - Use the result in its next generation step ## Two Paths: API vs MCP Unified supports two integration interfaces: **1. Unified API (Backend Integration)** Best for production-grade apps. You fetch and transform data yourself, persist what matters, and optionally embed it in a vector database. Full control, real scalability. **2. Unified MCP Server (Direct-to-LLM)** Best for prototyping or agent UXs. You point Anthropic's API, Claude or Cursor at a live MCP endpoint, and it auto-discovers available tools — all backed by your end-user's authorized connections. ## Why Claude + MCP Is the Best Fit The strongest use case for Unified's MCP Server is direct integration with the Anthropic API. It's already a common pattern among Unified users: AI-native SaaS teams wiring real-time tools into their product experience. With MCP, you now have two ways to access your customer's data: 1. Use the **Unified API** and call it from your backend 2. Point the **Anthropic API** at Unified's MCP Server and let it fetch tools on its own This lets your team skip everything between OAuth and LLM — no middleware, no polling, no schema juggling. You stay focused on your product's core logic while Claude handles tool orchestration via Unified. That's always been the promise of a Unified API: do less glue work and ship faster. MCP just makes that even more direct. ## What to Do if You're Building for Scale If your product is heading toward scale, especially if it requires logging, observability, or context-aware data access, the better path is: 1. Use the Unified API to fetch and normalize data 2. Store and filter what matters 3. Embed it into a vector database 4. Let your LLM consume it through whatever interface makes sense MCP is a fast path to working demos. The API is your infrastructure. ## Example Flow Claude gets this prompt: > 'Post a message to #sales about the new signed deal.' It: 1. Identifies a tool call (e.g. `send-slack-message`) 2. Sends the tool and parameters to Unified's MCP Server 3. Unified makes the call to Slack using the user's connection 4. Claude receives the result and continues the conversation No servers, no APIs, no code in between. Unified's MCP Server runs in production over Streamable HTTP, with one host per region (US, EU and AU). It supports any authenticated connection, works with Claude or Cursor, and helps you wire up AI features fast without touching infrastructure. Building the integration itself with a coding agent? [Unified.to](https://unified.to/)'s [Agent Skills](https://docs.unified.to/skills/install) give coding agents such as Claude Code, Codex and Cursor a `SKILL.md` for each [Unified.to](https://unified.to/) API, and the Claude Code plugin adds the Core MCP server in the same install. [Read the MCP docs](https://docs.unified.to/mcp) or [start your 30-day free trial](https://app.unified.to/login). ## How to register a Google developer app and get OAuth 2 credentials URL: https://docs.unified.to/guides/how_to_register_a_google_developer_app_and_get_oauth_2_credentials # How to register a Google developer app and get OAuth 2 credentials ------ _May 29, 2025_ > 💡 **Note** that if you use `Restricted` or `Sensitive` Scopes you need to contact Unified's Support to setup a CNAME for the API URL (US and EU regions) in order to get your App approved by Google > Check [https://docs.unified.to/guides/how_to_set_up_a_custom_api_url](https://docs.unified.to/guides/how_to_set_up_a_custom_api_url). 1. **Create a Google Cloud Platform project:** - Go to the Google Cloud Console (https://console.cloud.google.com/) and sign in. - If you don't have a project, click the "Select a project" dropdown and choose "New Project". - Give your project a name and click "Create". 2. **Enable the Necessary APIs** - In the Google Cloud Console, make sure your newly created or existing project is selected. - Navigate to [**APIs & Services**](https://console.cloud.google.com/apis/library)[ > ](https://console.cloud.google.com/apis/library)[**Library**](https://console.cloud.google.com/apis/library) - Search for the specific API you want to use (e.g., "Gmail API", "Google Drive API", 'Google Calendar API'). - For Google Directory, make sure that you have the[ Admin SDK API ](https://console.cloud.google.com/apis/library/admin.googleapis.com)enabled. - Click on the API from the search results. - Click the "**Enable**" button. Repeat this for all Google APIs your application will need to access 3. **Configure the OAuth Consent Screen** - In the Google Cloud Console, go to [Google Auth Platform](https://console.cloud.google.com/auth/overview) - Fill in what is asked and click **Create** - Click 'Create' - Go to [Auth Branding](https://console.cloud.google.com/auth/branding) and fill in the required Information 4. **Scopes**: Select the scopes your application needs. Scopes define the permissions your app is requesting (e.g., read emails, access calendar). Be specific and only request the scopes you absolutely need. - Go to [Auth Data Access](https://console.cloud.google.com/auth/scopes) - You will at least need `openid`, `userinfo.email`, `userinfo.profile`, `admin.directory.group.readonly` and `admin.directory.user` or `admin.directory.user.readonly`. - The scopes needed by other Google integrations can be found in the Unified App, for example: - [Google Contacts](https://app.unified.to/integrations/googlecontacts?tab=oauth2) - [Google Directory](https://app.unified.to/integrations/googledirectory?tab=oauth2) - [Gmail](https://app.unified.to/integrations/googlemail?tab=oauth2) - **Note** that if you use `Restricted` or `Sensitive` Scopes you need to contact Unified's Support to setup a CNAME for the redirect URL (US and EU regions) in order to get your App approved by Google - Review the information and click "**Save and Continue**" through the steps. If you selected "External" and are just testing, you can often leave your app in "Testing" publishing status. If you intend for it to be publicly available, you'll eventually need to "Publish" it and potentially go through verification. 5. Create OAuth 2.0 Credentials - Go to [Auth Clients](https://console.cloud.google.com/auth/clients) in the Google Cloud Console. - Click on "**+ Create Client**" at the top of the page, Follow the steps - Configure the authorized redirect URIs. Get the correct value from the Integration, for example [Google Drive](https://app.unified.to/integrations/googledrive?tab=oauth2) - Copy your Client ID and Secret - Enter these values in your Integrations, for example: - [Google](https://app.unified.to/integrations/google) - [Gmail](https://app.unified.to/integrations/googlemail?tab=auth) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_google_developer_app_and_get_oauth_2_credentials-0.png) 1. Publish your Application - Go to [Auth Audience](https://console.cloud.google.com/auth/audience) - Click on Publish app ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_google_developer_app_and_get_oauth_2_credentials-1.png) 1. Once your application is approved and verified it will start showing your application name on the authorization consent screen. Until then it will be showing the '[unified.to](https://unified.to/)' name, which is inferred from the redirect URL. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_google_developer_app_and_get_oauth_2_credentials-2.png) ## How to register a Salesforce developer app and get OAuth 2 credentials URL: https://docs.unified.to/guides/how_to_register_a_salesforce_developer_app_and_get_oauth_2_credentials # How to register a Salesforce developer app and get OAuth 2 credentials ------ _February 12, 2023_ In this guide, we'll walk you through how to register a Salesforce developer app to get the OAuth 2 credentials you'll need to be a Salesforce integration for your product. Whether you're planning to build a custom-code integration or using a Unified API solution like [Unified.to](https://unified.to/), retrieving your OAuth 2 credentials is the first step in the process of integrating with Salesforce. We'll also added directional for existing Unified.to users that are leveraging our Unified CRM API for a faster integration development process. ## What is a Salesforce Developer App? A Salesforce developer app is a container for one or more developer apps. A developer app is an application that can access Salesforce data or functionality through the Salesforce REST, SOAP, or Streaming APIs. A connected app defines the following information: - The name and logo of the app - The OAuth settings for the app, such as the callback URL, the scopes, and the refresh token policy - The API version and the endpoints for the app - The user profiles or permission sets that can access the app By registering a developer app, you can generate a client ID and a client secret, which are the credentials you'll need to authenticate your requests to the Salesforce APIs ## Step 1: Register a Salesforce developer app To register a Salesforce developer app, you'll need a Salesforce developer account. If you don't have one, sign up for free at [https://developer.salesforce.com/signup](https://developer.salesforce.com/signup). Once you have a developer account, follow these steps to register a developer app: 1. Log in to your Salesforce developer account and go to **Setup** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_salesforce_developer_app_and_get_oauth_2_credentials-0.png) 2. In the Quick Find box, search for **App Manager** and click on it 3. Click on the **New External Client App** button 4. Fill in the basic information for your app, such as the name, contact email, and logo URL 5. Select `Packaged` in **Distribution State** 6. In the API (Enable OAuth Settings) section, check the **Enable OAuth Settings** checkbox 7. Enter the callback URL for your app (this is the URL that Salesforce will redirect the user to after they authorize your app). For [Unified.to](https://unified.to/) users, the callback URL needs to be `https://api.unified.to/oauth/code`, `https://api-eu.unified.to/oauth/code` or `https://api-au.unified.to/oauth/code`. 8. Select the OAuth scopes for your app (these are the permissions that your app will request from the user). To set up [Unified.to](https://unified.to/) successfully you'll need to add these OAuth Scopes: `Manage user data via APIs (api)` and `Platform requests at any time (refresh_token, offline_access)`. If you are also going to use Salesforce to sign-in your users into your application, then also set the `openid`,`profile`,`email`scopes. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_salesforce_developer_app_and_get_oauth_2_credentials-1.png) 9. Next, enable the following options for you app: 1. Enable Authorization Code and Credentials Flow 2. Require user credentials in the POST body for Authorization Code and Credentials Flow 3. Require Secret for Web Server Flow 4. Require Secret for Refresh Token Flow 10. Do NOT enable CODE CHALLENGE (Require Proof Key for Code Exchange (PKCE) Extension for Supported Authorization Flows) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_salesforce_developer_app_and_get_oauth_2_credentials-2.png) 11. Click **Create** 12. Next click **Edit** under **Policies** tab ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_salesforce_developer_app_and_get_oauth_2_credentials-3.png) 13. Set 'Refresh token is valid until revoked' and click **Save** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_salesforce_developer_app_and_get_oauth_2_credentials-4.png) 14. Lastly, go to Settings tab and retrieve you 'Consumer Key' (client ID) and 'Consumer Secret' (client secret).You will need these credentials to activate your Salesforce integration in [Unified.to](https://unified.to/). ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_salesforce_developer_app_and_get_oauth_2_credentials-5.png) ## Step 2: Use Unified.to to Simplify Integration Development Using Salesforce APIs directly can be a complex and time-consuming process, especially if you plan to integrate with other leading CRM platforms like HubSpot, ActiveCampaign, and Copper. Without Unified.to, you'll be tasked with handling authentication, error handling, rate limiting, data mapping, and the synchronization logic for each individual integration. [Unified.to](https://unified.to/) simplifies this entire process, providing SaaS developers with a unified set of APIs, data models, and webhooks for 220 CRM integrations. Add all the CRM integrations on your roadmap in a matter of hours. ## Step 3: Activate Salesforce in [Unified.to](https://unified.to/) (for [Unified.to](https://unified.to/) users) 1. [Log in](https://app.unified.to/login) and navigate to [**Active Integrations**](https://app.unified.to/integrations) 2. Use the search bar or click the **CRM** tab to select **Salesforce** 3. Click **Activate** on Salesforce 4. Enter the information from the previous step (client ID, client secret) Once you've activated Salesforce along with other integrations, you'll be able to add integrations to your product using our low-code authorization UI embed. ## One API to integrate them all You're reading this article on the [**Unified.to**](https://unified.to/) blog. We're a Unified API developer platform for SaaS customer-facing integrations. We're excited to continue to innovate at Unified.to and solve hard, critical integration-related problems for our customers. If you're curious about our integrations-as-a-service solution, consider [**signing up for a free account**](https://app.unified.to/login?utm_source=blog&utm_medium=blog_signup_salesforce&utm_campaign=blog_signup&utm_id=blog_signup) or [**meet with an integrations expert**](https://calendly.com/michelle-unified/discovery-via-blog)**.** ## How to register a Slack developer account and get OAuth 2 credentials URL: https://docs.unified.to/guides/how_to_register_a_slack_developer_account_and_get_oauth_2_credentials # How to register a Slack developer account and get OAuth 2 credentials ------ _January 3, 2025_ This guide walks you through how to register a Slack developer account, obtain OAuth 2 credentials, and configure scopes for your application. ## Before you begin Make sure you have: - A Slack account - Admin access to a Slack workspace where you can test your app ## Create a Slack app 1. Go to the [Slack API website](https://api.slack.com/apps) and sign into your Slack account 2. Click **Create New App** 3. Select **From scratch** 4. Enter your app's name 5. Select the workspace where you want to install and test out your app 6. Click **Create App** ![Screenshot_2025-01-03_at_11.13.30_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_slack_developer_account_and_get_oauth_2_credentials-0.png) After you create your app, you'll be redirected to the app's settings page where you can find your Client Id and Client Secret. Make a note of these as you will need them to activate the Slack integration on Unfiied.to. ![Screenshot_2025-01-03_at_11.15.20_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_slack_developer_account_and_get_oauth_2_credentials-1.png) _Note: Store these values securely - never commit them to version control!_ ## Configure OAuth 2 settings In addition to activating the Slack integration, you'll need to configure the redirect URL and enable the correct scopes in order to successfully authorize a connection with Unified.to. 1. From your app's settings page, navigate to **OAuth & Permissions** 2. Under **Redirect URLs,** enter: [`https://api.unified.to/oauth/code`](https://api.unified.to/oauth/code) 3. Click **Save URLs** ![Screenshot_2025-01-03_at_11.18.11_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_slack_developer_account_and_get_oauth_2_credentials-2.png) 4. If you are using the SlackBot integration, use **Bot Token Scopes.** If not, then use **User Token Scopes.** click **Add an OAuth Scope** 5. Search for and add the scopes you need for your application - refer to [this page ](https://app.unified.to/integrations/slack?tab=oauth2)to see the scopes that are supported by Unified. 1. For example, if you want to read messages, then you should choose the scopes that map to : 1. **messaging_channel_read**: `im:read, mpim:read, channels:read, groups:read` 2. **messaging_message_read**: `im:read, im:history, mpim:read, mpim:history, channels:history, channels:read, groups:read, groups:history` ## Activate the Slack integration with your credentials 1. Go to [https://app.unified.to/integrations/slack?tab=auth](https://app.unified.to/integrations/slack?tab=auth) or  [https://app.unified.to/integrations/slackbot?tab=auth](https://app.unified.to/integrations/slack?tab=auth) 2. Enter your Client ID and Client secret from the steps above 3. Save your changes # For a Slack Bot, follow these additional instructions 1. In the left sidebar, go to **"App Home"** 2. Under **"Your App's Presence in Slack"**, click **"Add"** or **"Edit"** 3. Set a **Display Name** and **Default Username** for your bot 4. Toggle on **"Always Show My Bot as Online"** (optional) 5. Go to **"Event Subscriptions"** in sidebar 6. Toggle **"Enable Events"** to On 7. Enter the Unified webhook URL `https://api.unified.to/webhook/workspace/slackbot?workspace_id={ID}` 8. Subscribe to most bot events like: - message.channels - app_mention - message.im 9. Go to **"Interactivity & Shortcuts" to enable receiving information about button events** 10. Toggle On and enter in `https://api.unified.to/webhook/workspace/slackbot?workspace_id={ID}` When a user authorizes your `slackbot` integration, their user ID will be stored in `connection.auth.user_id` and the bot ID will be stored in `connection.auth.app_id` . Congratulations, you're now ready to use the Slack integration in your application. Happy building! ## How to Register a Workday Developer App and Get OAUTH2 Credentials URL: https://docs.unified.to/guides/how_to_register_a_workday_developer_app_and_get_oauth2_credentials # How to Register a Workday Developer App and Get OAUTH2 Credentials ------ _March 13, 2023_ As a developer of an application in the HR space, you absolutely need to have a Workday integration so that you can access your customers' data in the most popular HRIS vendor, Workday. Before you can use Unified.to's HRIS and ATS unified APIs with Workday, you must get your customer to generate Workday API credentials for your application. ## Register a new API Client in Workday 1. Sign-in to the Workday tenant 2. Search for 'Register API Client for Integration' using the search bar 3. Fill out the requested inputs with the following example details: 1. Client Name; 2. Refresh Token Timeout; 0 days (ie. never) 3. Non-Expiring Refresh Token: Checked 4. Scope: Choose all of the permission scopes for the use-case. Minimum should be 'Staffing' (for employee/worker access), 'Leave of Absence' (for timeoff). Always include 'Tenant Non-Configurable' and 'Integration' as well. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_workday_developer_app_and_get_oauth2_credentials-0.png) 4. Once the API client has been registered, go to 'Related Actions' icon ⇒ 'API Client' ⇒ 'Manage Refresh Tokens for Integrations' ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_workday_developer_app_and_get_oauth2_credentials-1.png) 5. Remember the Client ID and Client Secret 6. Enter the 'Integration System User (ISU)' in the Workday Account field. This ISU needs to have access to all of the resources that you need for the API. 7. Check the "Regenerate New Refresh Token" box and click OK. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_workday_developer_app_and_get_oauth2_credentials-2.png) 8. A new refresh token will be generated. Remember that Refresh Token. ## Use [Unified.to](https://unified.to/) to Simplify your Integrations Development Using Workday APIs directly can be complex and time-consuming, especially if you want to integrate with multiple services or platforms. You will need to handle the authentication, the error handling, the rate limiting, the data mapping, and the synchronization logic for each integration. A simpler and faster alternative is to use [Unified.to](https://unified.to/) APIs for HRIS and ATS integrations. It provides a unified set of APIs, data-models, and webhooks for most of the HRIS and ATS integrations. ## Activate Workday in Unified.to 1. Navigate to [Active Integrations](https://app.unified.to/integrations) 2. Search for Workday 3. Click on Activate on Workday 4. Enter in the information from the previous step (client ID, secret, endpoints/URLs) ## How to Register a Workday Developer App and Get OAUTH2/ SOAP Credentials URL: https://docs.unified.to/guides/how_to_register_a_workday_developer_app_and_get_oauth2_soap_credentials # How to Register a Workday Developer App and Get OAUTH2/ SOAP Credentials ------ _November 10, 2025_ # How to Register and Get OAUTH2 **Credentials for Workday:** You will need 3 endpoints (URLs): - REST API Endpoint - Token Endpoint - Authorization Endpoint You will also need: - Client ID - Client Secret ### How to setup Client ID 1. **Log in to the Workday application** 2. Register API Client: Navigate to the "Register API Client" section 3. Select the Register API Client form 4. Select the Register API Client form 5. For the grant type, select "Authorization Code Grant' 6. For the access token type, select "Bearer' 7. In the Redirection URI field, enter `https://api.unified.to/oauth/code` 8. In the Scope section, add Staffing and/or Recruiting, depending on your application's needs 9. Select "Non-expiring Refresh Tokens' 10. Select the Include Workday Owned Scope checkbox. Click OK to save the app. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_a_workday_developer_app_and_get_oauth2_soap_credentials-0.png) Once you create your application, Workday will display the information that you entered plus additional information. ### **Retrieve Endpoints** 1. Access "View API Clients" 2. Use the Workday search bar to navigate to "View API clients" 3. Retrieve REST API Endpoint, Token Endpoint, Authorization Endpoint # How to Register and Get OAUTH2 **Credentials for Workday Legacy**:- ## **Integration System User(ISU)** ### Create User 1. Log in to the Workday tenant 2. search 'Create Integration System User'. 3. Goto 'Create Integration System User' 4. In the "Create Integration System User" page, go to the "Account Information" section. 5. Provide Username & Password 6. Require New Password should remain unchecked 7. Type 0 (zero) for Session Timeout Minutes to prevent session expiration 8. Click "OK" to save. ### Provide permissions to ISU 1. Search field, type "Create Security Group' 2. Select "Integration System Security Group" from the "Type of Tenanted Security Group" drop-down menu. 3. In the "Name" field, enter a name for the security group. 4. Integration System Security Group on Workday select 'Maintain Permissions for Security Group'' 5. Configure the Permissions and select "Maintain' Operation 6. "Source Security Group" name matches your created security group. 7. Now add Domain Security Policy Permissions 8. Search 'Activate Pending Security Policy Changes' ### WSDL URL 1. Go to Workday, search 'Public Web Services'. 2. Click **"Public Web Services Report**' 3. Hover over the "Human Resources" section. 4. Click the three dots to open the menu. 5. Select "Web Services" and then click "View WSDL' 6. Goto bottom of the page and get host URL. That looks like following:- [**https://wd5-services1.myworkday.com/ccx**](https://wd5-services1.myworkday.com/ccx)**.** ## How to Register a ZendeskSell Developer App and Get OAUTH2 Credentials URL: https://docs.unified.to/guides/how_to_register_a_zendesksell_developer_app_and_get_oauth2_credentials # How to Register a ZendeskSell Developer App and Get OAUTH2 Credentials ------ _February 12, 2023_ If you are a developer who wants to build data integrations for your SaaS software, you might be interested in using the ZendeskSell API. The ZendeskSell API allows you to access and manipulate data from ZendeskSell, a powerful CRM platform that helps you manage your sales pipeline, contacts, deals, and tasks. However, before you can use the ZendeskSell API, you need to register a developer app and get credentials. In this article, I will show you how to do that in a few simple steps. ## Step 1: Create a ZendeskSell account If you don't have a ZendeskSell account yet, you need to create one first. You can sign up for a free trial here: [https://www.zendesk.com/register/?source=zendesk_sell](https://www.zendesk.com/register/) Once you have created your account, you can log in to your ZendeskSell dashboard and access the settings menu. ## Step 2: Register a developer app To register a developer app, you need to go to the Admin Settings and click on "APIs > Zendesk APIs" and then on 'Oauth Clients'. Then, you need to click on the "Add OAUTH2 Client" button and fill in the required information. You need to provide a name, a description, a logo URL, and a redirect URI for your app. The redirect URI is the URL where ZendeskSell will send the authorization code after the user grants permission to your app. You can use any URL that you control, but it must match the one you will use in your code later. If you are going to use Unified.to, then this needs to be `https://api.unified.to/oauth/code` You also need to select the scopes that your app will need to access the ZendeskSell data. The scopes are the permissions that define what your app can do with the ZendeskSell API. You can choose from the following scopes: - read: Allows your app to read data from ZendeskSell - write: Allows your app to write data to ZendeskSell - delete: Allows your app to delete data from ZendeskSell You can select one or more scopes depending on your app's functionality. However, you should only request the minimum scopes that your app needs to avoid unnecessary access. After you fill in the information, you can click on the "Register App" button and your app will be created. ## Step 3: Get credentials Once you have registered your app, you will see a confirmation page with your app's credentials. You will need these credentials to authenticate your app with the ZendeskSell API. The credentials are: - Client ID: A unique identifier for your app - Client Secret: A secret key that you should keep confidential and never share with anyone You should copy and save these credentials somewhere safe, as you will need them later in your code. ## Step 4: Use [Unified.to](https://unified.to/) to Simplify your Integrations Development Using ZendeskSell APIs directly can be complex and time-consuming, especially if you want to integrate with multiple services or platforms. You will need to handle the authentication, the error handling, the rate limiting, the data mapping, and the synchronization logic for each integration. A simpler and faster alternative is to use [Unified.to](https://unified.to/) APIs for CRM integration. It provides a unified set of APIs, data-models, and webhooks for 220 CRM integrations. ## Step 5: Activate Workday in Unified.to 1. Navigate to [Active Integrations](https://app.unified.to/integrations) 2. Search for ZendeskSell 3. Click on Activate on ZendeskSell 4. Enter in the information from the previous step (client ID, secret) ## How to Register a Zoho Developer App and Get OAUTH2 Credentials URL: https://docs.unified.to/guides/how_to_register_a_zoho_developer_app_and_get_oauth2_credentials # How to Register a Zoho Developer App and Get OAUTH2 Credentials ------ _August 23, 2024_ Zoho has many applications that we integrate with, including ZohoCRM, ZohoBooks, ZohoPeople, and ZohoRecruit. If you are a developer who wants to build data integrations for your SaaS software with any of the Zoho product suite, you will need to register a developer app and get credentials from Zoho. In this article, I will show you how to do that in a few simple steps. ## Step 1: Create a Zoho Developer Account The first step is to create a Zoho Developer account, if you don't have one already. You can sign up for free at [https://developer.zoho.com/signup](https://developer.zoho.com/signup). Once you have created your account, you will be able to access the Zoho Developer Console, where you can manage your apps and credentials. ## Step 2: Register a Zoho Developer App The next step is to register a Zoho developer app, which will allow you to access the Zoho API and data. To do that, follow these steps: - Go to the [Zoho Developer Console](https://api-console.zoho.com/) and click on the "Add App" button. - Choose "Server-Based" as the Client Type and click on "Create". - Enter a name and a homepage for your app - For your Authorized redirect URI, enter in `https://api.unified.to/oauth/code` or `https://api-eu.unified.to/oauth/code` - Click on "Create". - You will see a confirmation message and a client ID and a client secret for your app. Copy and save them somewhere safe, as you will need them later. Congratulations! You have successfully registered a Zoho developer app and got credentials. You can now use them to access the Zoho API and data from your SaaS software. ## Step 3: Activate your Zoho Apps in [Unified.to](https://unified.to/) 1. Navigate to [Active Integrations](https://app.unified.to/integrations) 2. Search for your Zoho app, such as ZohoCRM, ZohoBooks, ZohoPeople, or ZohoRecruit 3. Click on the integration 4. Enter in the information from the previous step (client ID, secret) 5. Click on Activate ## How to Register an ADP Developer App and Get OAUTH2 Credentials URL: https://docs.unified.to/guides/how_to_register_an_adp_developer_app_and_get_oauth2_credentials # How to Register an ADP Developer App and Get OAUTH2 Credentials ------ _April 30, 2023_ Developers who are looking to build data integrations for their SaaS software will need to register an ADP Developer application and obtain OAUTH2 credentials. This will allow you to access ADP data and build integrations with ADP services. In this article, we'll go over the steps to register an ADP Developer application and obtain OAUTH2 credentials. ## **Step 1: Create an ADP Developer Account** To get started, you will need to create an ADP Developer account. If you don't already have one, you can sign up for free on the [ADP Developer Portal](https://developers.adp.com/). ## **Step 2: Register a Developer App** Once you have an ADP Developer account, you can register a new developer app. Here are the steps to do so: 1. Log in to your ADP Developer account. 2. Navigate to the "Apps" section of the Developer Portal. 3. Click on the "Register App" button. 4. Fill in the required information for your app, such as its name, description, and callback URL. 5. Agree to the ADP Developer Terms of Service and Privacy Policy. 6. Click the "Submit" button to complete the app registration process. ## **Step 3: Get Your App Credentials** Once your app is registered, you can obtain your app credentials, including the Client ID and Client Secret, which are used to authenticate your app and access the ADP APIs. 1. Navigate to the "Apps" section of the Developer Portal. 2. Click on the name of the app you just registered. 3. Click on the "Credentials" tab to view your app's Client ID and Client Secret. 4. Make sure that your redirect_uri is [`https://api.unified.to/oauth/code`](https://api.unified.to/oauth/code) if you are going to use [Unified.to](https://unified.to/) with these credentials ## Step 4: Using Unified.to Registering an ADP Developer application and obtaining OAUTH2 credentials is an important step in building data integration for your SaaS software. With these credentials, you'll be able to access ADP data and build integrations with ADP services. Using ADP APIs directly can be complex and time-consuming, especially if you want to integrate with multiple services or platforms. You will need to handle the authentication, the error handling, the rate limiting, the data mapping, and the synchronization logic for each integration. It's much easier to use Unified.to APIs for building scalable integrations. With these APIs, you can focus on building your SaaS software and let Unified.to handle the heavy lifting of integrating with ADP. Check out the Unified.to website for more information on how they can help you build better integrations. ## Step 5: Activate ADP in Unified.to 1. Navigate to [Active Integrations](https://app.unified.to/integrations) 2. Search for ADP 3. Click on Activate on ADP 4. Enter in the information from the previous step (client ID, secret) ## How to Register an Atlassian Developer App and Get OAUTH2 Credentials URL: https://docs.unified.to/guides/how_to_register_an_atlassian_developer_app_and_get_oauth2_credentials # How to Register an Atlassian Developer App and Get OAUTH2 Credentials ------ _February 12, 2023_ Developers who are looking to build data integrations for their SaaS software with Atlassian services will need to register an Atlassian Developer application and obtain OAUTH2 credentials. This will allow you to access Atlassian data and build integrations with Atlassian services. In this article, we'll go over the steps to register an Atlassian Developer application and obtain OAUTH2 credentials. ## Step 1: **Registering an Atlassian Developer App** To register an Atlassian Developer app, you need to follow the steps below: 1. Go to the [**Atlassian Developer site**](https://developer.atlassian.com/console/myapps/). 2. Click on "Get started" and create an Atlassian account if you don't have one already. 3. Navigate to the "My Atlassian" page and click on "Create a new app." 4. Fill out the required information for your app, including the app name, description, and contact information. 5. Once you have completed the form, click on "Create app." 6. You will be redirected to the app's overview page, where you can see the app key and client ID. ## Step 2: **Obtaining Credentials** To obtain the necessary credentials for your salesforce integration, you need to do the following: 1. Go to the app's overview page and click on "Add credentials." 2. Select "OAuth" as the authentication method. 3. Fill out the necessary information, including the callback URL and OAuth scopes. If you are going to use Unified.to, then this needs to be `https://api.unified.to/oauth/code` 4. Once you have completed the form, click on "Create credentials." 5. You will be redirected to the credentials page, where you can see the client secret. ## Step 3: Using Unified.to Registering an Atlassian Developer application and obtaining OAUTH2 credentials is an important step in building data integration for your SaaS software. With these credentials, you'll be able to access ADP data and build integrations with Atlassian services. Using Atlassian APIs directly can be complex and time-consuming, especially if you want to integrate with multiple services or platforms. You will need to handle the authentication, the error handling, the rate limiting, the data mapping, and the synchronization logic for each integration. It's much easier to use Unified.to APIs for building scalable integrations. With these APIs, you can focus on building your SaaS software and let Unified.to handle the heavy lifting of integrating with ADP. Check out the Unified.to website for more information on how they can help you build better integrations. ## Step 4: Activate Atlassian in Unified.to 1. Navigate to [Active Integrations](https://app.unified.to/integrations) 2. Search for Atlassian 3. Click on Activate on Atlassian 4. Enter in the information from the previous step (client ID, secret) ## How to Register your Facebook OAuth2 Application URL: https://docs.unified.to/guides/how_to_register_your_facebook_oauth2_application # How to Register your Facebook OAuth2 Application ------ _August 28, 2026_ 1. Create a Meta Developer account - Go to [**https://developers.facebook.com/**](https://developers.facebook.com/) 2. Create an application - Go to [**https://developers.facebook.com/apps/creation/**](https://developers.facebook.com/apps/creation/) ![how_to_register_your_metaads_oauth2_application-0.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_facebook_oauth2_application-0.png) Enter the following details: - **App Name** - **App Contact Email** ![Screenshot_2026-08-28_at_11.38.19_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_facebook_oauth2_application-1.png) 1. Add the use case ![Screenshot_2026-08-28_at_11.39.22_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_facebook_oauth2_application-2.png) 2. Connect your app to a business portfolio ![Screenshot_2026-08-28_at_11.40.44_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_facebook_oauth2_application-3.png) 3. Once you create the app, select it from the [https://developers.facebook.com/apps/](https://developers.facebook.com/apps/) page. From the app dashboard, select 'Customize the Manage everything on your Page use case' ![Screenshot_2026-08-28_at_11.44.55_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_facebook_oauth2_application-4.png) 4. Now add the permissions - pages_show_list - pages_read_engagement - pages_read_user_content - read_insights - pages_manage_posts - email - public_profile ![Screenshot_2026-08-28_at_11.49.07_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_facebook_oauth2_application-5.png) 1. **Configure Facebook Login for Business** - In the left sidebar, open (1, 2): Facebook Login for Business → Settings - Specify the [**Unified.to**](https://unified.to/) OAuth2 redirect URLs (3) - Click **Save Changes (4)** ![how_to_register_your_metaads_oauth2_application-7.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_facebook_oauth2_application-6.png) ![how_to_register_your_metaads_oauth2_application-8.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_facebook_oauth2_application-7.png) 1. **Configure App Domains** - Go to App Settings → Basic - Add the following **App Domains**(3): [**unified.to**](https://unified.to/)[**api.unified.to**](https://api.unified.to/)[**app.unified.to**](https://app.unified.to/)[**api-eu.unified.to**](https://api-eu.unified.to/)[**app-eu.unified.to**](https://app-eu.unified.to/)[**api-au.unified.to**](https://api-au.unified.to/)[**app-au.unified.to**](https://app-au.unified.to/) - Scroll down and save the settings (4) ![how_to_register_your_metaads_oauth2_application-9.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_facebook_oauth2_application-8.png) 1. In Unified [https://app.unified.to/integrations/facebook](https://app.unified.to/integrations/facebook), add the application's App ID (as the client ID) and the App secret (as the client secret). This information can be found in the App settings > Basic page. You are now all set to authorize a Facebook connection. Users should see this screen at time of Oauth. ![dialog_box_updated.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_facebook_oauth2_application-9.png) ## How to Register your Google Ads OAuth2 Application URL: https://docs.unified.to/guides/how_to_register_your_google_ads_oauth2_application # How to Register your Google Ads OAuth2 Application ------ _March 21, 2026_ ## **How to Register your Google Ads OAuth2 Application** 1. Create a Google Cloud project - Go to [**https://console.cloud.google.com/**](https://console.cloud.google.com/) - Click **Select a project** at the top of the page, then click **New Project** - Enter the following details: - Project Name - Organization (if applicable) - Click **Create** 2. Enable the Google Ads API - Go to [**https://console.cloud.google.com/apis/library**](https://console.cloud.google.com/apis/library) - Search for **Google Ads API** - Click on **Google Ads API** in the results - Click **Enable** 3. Configure the OAuth Consent Screen - Go to [**https://console.cloud.google.com/apis/credentials/consent**](https://console.cloud.google.com/apis/credentials/consent) - Select **External** as the user type, then click **Create** - Enter the following details: - App Name - User Support Email - Developer Contact Email - Click **Save and Continue** - On the Scopes step, click **Add or Remove Scopes** and add the following scope: - `https://www.googleapis.com/auth/adwords` - Click **Save and Continue** - On the Test Users step, add any Google accounts you want to test with - Click **Save and Continue** 4. Publish the OAuth Consent Screen - Go back to [**https://console.cloud.google.com/apis/credentials/consent**](https://console.cloud.google.com/apis/credentials/consent) - Click **Publish App** to move from Testing to Production status - This allows any Google user to authorize your application, not just the test users you added 5. Create OAuth2 Credentials - Go to [**https://console.cloud.google.com/apis/credentials**](https://console.cloud.google.com/apis/credentials) - Click **Create Credentials** → **OAuth client ID** - Select **Web application** as the Application Type - Enter a name for the OAuth client - Under **Authorized redirect URIs**, add the following **Unified.to** OAuth2 redirect URL: - [**https://api.unified.to/oauth/code**](https://api.unified.to/oauth/code) - Click **Create** - Copy the **Client ID** and **Client Secret** from the dialog that appears 6. Request Basic Access for the Google Ads Developer Token - Go to [**https://ads.google.com/aw/apicenter**](https://ads.google.com/aw/apicenter) - Note: You must be logged into a Google Ads Manager account. If you do not have one, create one at [**https://ads.google.com/intl/en/home/tools/manager-accounts/**](https://ads.google.com/intl/en/home/tools/manager-accounts/) - In the API Center, you will see your **Developer Token** - Your token will initially have **Test Account** access. To use it with production accounts, click **Apply for Basic Access** - Fill out the application form with details about how you will use the API - Google typically reviews and approves Basic Access applications within a few business days 7. Configure Unified.to - In Unified [**https://app.unified.to/integrations/googleads**](https://app.unified.to/integrations/googleads), add the following: - The OAuth client's **Client ID** - The OAuth client's **Client Secret** - Your **Developer Token** from the API Center You are now all set to authorize a Google Ads connection ## How to Register your MetaAds OAuth2 application URL: https://docs.unified.to/guides/how_to_register_your_metaads_oauth2_application # How to Register your MetaAds OAuth2 application ------ _March 4, 2026_ 1. Create a Meta Developer account - Go to [https://developers.facebook.com/](https://developers.facebook.com/) 2. Create an application - Go to [https://developers.facebook.com/apps/creation/](https://developers.facebook.com/apps/creation/) ![03cc19ca-b1b5-43fa-b858-37a6aeb9ab77.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_metaads_oauth2_application-0.png) - Enter the following details: - **App Name** - **App Contact Email** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_metaads_oauth2_application-1.png) ![Screenshot_2026-03-05_at_09.37.16.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_metaads_oauth2_application-2.png) - Add the following use-cases; 'Manage ads using Marketing API' and 'Measure ad performance' ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_metaads_oauth2_application-3.png) **3. Configure Permissions for the Marketing API** - Enable all permissions in the 'Create & Manage Ads' and 'Measure ad performance' use-cases ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_metaads_oauth2_application-4.png) - or - - After the application is created: - Open the **App Dashboard**. - Navigate to: Use Cases → Customize → Create & Manage Ads ![Screenshot_2026-03-05_at_09.42.12.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_metaads_oauth2_application-5.png) - Enable the following permissions: - ads_management - ads_read - business_management - email - Additional permissions such as: - pages_manage_ads - pages_read_engagement This may also be enabled depending on your integration requirements. ![Screenshot_2026-03-05_at_09.44.57.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_metaads_oauth2_application-6.png) **4. Configure Facebook Login for Business** - In the left sidebar, open (1, 2): Facebook Login for Business → Settings ![Screenshot_2026-03-05_at_09.49.45.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_metaads_oauth2_application-7.png) - Specify the [Unified.to](https://unified.to/) OAuth2 redirect URLs (3) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_metaads_oauth2_application-8.png) - Click **Save Changes (4)**. **5. Configure App Domains** - Go to App Settings → Basic - Add the following **App Domains**(3): [unified.to](https://unified.to/) [api.unified.to](https://api.unified.to/) [app.unified.to](https://app.unified.to/) [api-eu.unified.to](https://api-eu.unified.to/) [app-eu.unified.to](https://app-eu.unified.to/) [api-au.unified.to](https://api-au.unified.to/) [app-au.unified.to](https://app-au.unified.to/) - Scroll down and save the settings (4). ![Screenshot_2026-03-05_at_10.05.24.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_register_your_metaads_oauth2_application-9.png) In Unified [https://app.unified.to/integrations/metaads](https://app.unified.to/integrations/metaads), add the application's App ID (as the client ID) and the App secret (as the client secret). This information can be found in the App settings > Basic page. You are now all set to authorize a MetaAds connection ## How To Request & Write Raw Integration Data URL: https://docs.unified.to/guides/how_to_request_and_write_raw_integration_data # How To Request & Write Raw Integration Data ------ _February 6, 2024_ ### Reading data While [Unified.to](https://unified.to/) abstracts all of the integration's objects into common objects, you can still request that the original object. Simply include a `fields=raw` parameter in your request. ```javascript GET /crm/123456789/contacts?fields=raw ``` The result will also include a field named `raw` that will contain the original vendor's object. You can add more field names in there as well (comma-delimited) and only those fields will be returned. ### Writing data Simply include a `raw` field in the payload with the integration's specific fields. ```javascript POST /crm/123456789/contacts { "name": "Joe Smith", "raw": { "customfield1":"Blue", "customfield2":"Bananas" } } ``` Those fields in the `raw` object can also overwrite any mapped unified fields. ### Passing Raw Parameters Some providers support extra filters or options that Unified doesn't standardize. You can pass them using the `raw` query parameter. Take normal query params like: ```plain text status=active&limit=10 ``` Encode them into: ```plain text GET /crm/123456789/contact?raw=status%3Dactive%26limit%3D10 ``` ## How to Retrieve Microsoft Dynamics 365 Business Central Credentials URL: https://docs.unified.to/guides/how_to_retrieve_microsoft_dynamics_365_business_central_credentials # How to Retrieve Microsoft Dynamics 365 Business Central Credentials ------ _January 21, 2026_ This guide walks you through creating an Azure App Registration, granting the correct permissions, and collecting the credentials required to integrate with **Microsoft Dynamics 365 Business Central** using Unified. --- ## 1. Create or Select an App Registration ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_retrieve_microsoft_dynamics_365_business_central_credentials-0.png) 1. Go to the Azure Portal: 👉 [https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) 2. Navigate to **App registrations** 3. Either: - Select an existing app, **or** - Click **New registration** to create a new one --- ## 2. Configure Redirect URI 1. Inside your App Registration, go to **Authentication** 2. Under **Redirect URI (optional)**: - Select **Web** - Enter the redirect URL provided by Unified ```plain text https://api.unified.to/oauth/code ``` 3. Click **Save** --- ## 3. Grant Business Central API Permissions ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_retrieve_microsoft_dynamics_365_business_central_credentials-1.png) 1. In the App Registration, go to **API permissions** 2. Click **Add a permission** 3. Select **APIs my organization uses** 4. Search for and select **Microsoft Dynamics 365 Business Central** 5. Choose **Delegated permissions** 6. Select the required permissions (typically default access) 7. Click **Add permissions** 8. If required, click **Grant admin consent** --- ## 4. Get Client ID and Client Secret ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_retrieve_microsoft_dynamics_365_business_central_credentials-2.png) 1. In the App Registration, go to **Certificates & secrets** 2. Copy the following: - **Client ID** (from the app Overview page) - **Client Secret** - Create a new client secret if one does not exist - Copy it immediately (it will not be shown again) ⚠️ Store the client secret securely. --- ## 5. Retrieve Business Central Company Name ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_retrieve_microsoft_dynamics_365_business_central_credentials-3.png) Unified also requires the **Business Central company name**. 1. Go to: 👉 [https://businesscentral.dynamics.com/](https://businesscentral.dynamics.com/) 2. Sign in to your Business Central account 3. Locate the **company name** using either method: - The company name shown in the **top-left corner**, **or** - Click the **building (company) icon** in the navigation bar and copy the desired company name Use **exactly** the company name as shown. --- ## 6. Information You Will Need for Unified After completing the steps above, you should have: - ✅ Client ID - ✅ Client Secret - ✅ Business Central Company Name These values are required to complete the Business Central connection in Unified. ## How to set up a custom API URL with a CNAME URL: https://docs.unified.to/guides/how_to_set_up_a_custom_api_url_with_a_cname # How to set up a custom API URL with a CNAME ------ _September 5, 2025_ This guide walks you through setting up a custom subdomain (CNAME) for your Unified API workspace, so you can use your own domain instead of `api.unified.to` for API calls and OAuth flows. > 💡 This is mandatory when using Sensitive scopes in any of the Google integrations. It allows the Google verification to pass. ## Prerequisites - A Pro or Scale plan (custom domains are not available on Grow) - A workspace in the US or EU region (custom domains are not yet available in the AU region) - Access to your domain's DNS management console - Admin access to your Unified workspace ## Step 1: Choose your custom subdomain Pick the subdomain you want to use, for example: - `api.yourcompany.com` - `unified-api.yourcompany.com` - `integrations.yourcompany.com` ## Step 2: Create the CNAME record 1. Log in to your DNS provider (e.g. Cloudflare, GoDaddy, Namecheap, AWS Route 53). 2. Go to DNS management for your domain. 3. Add a CNAME record: - **Type**: CNAME - **Name**: your subdomain (e.g. `api` or `unified-api`) - **Value/Target**, for your workspace's region: - US: `unified-domains-us-7d67c6f04efebb36.elb.us-east-1.amazonaws.com` - EU: `unified-domains-eu-a0f8a17b303a3c06.elb.eu-central-1.amazonaws.com` - **TTL**: 3600 (or default) - **Proxy status**: DNS only / disabled (if using Cloudflare). A proxied record hides the CNAME, so it can't be verified, and it blocks the TLS certificate from being issued. 4. Save the record. The target is also shown in your workspace settings, in the help text of the **Custom API domain** field. ## Step 3: Wait for DNS propagation DNS changes can take from a few minutes to 48 hours to propagate. You can check with [whatsmydns.net](https://whatsmydns.net/) or [dnschecker.org](https://dnschecker.org/): the CNAME for your subdomain should show the target above. ## Step 4: Update your OAuth applications **Important**: as soon as your domain is saved in Step 5, OAuth flows redirect to it. To avoid failed authorizations, add the new redirect URI to every OAuth application you use with Unified _before_ Step 5 (most providers allow several), then remove the old one once everything works. Add: ```plain text https://api.yourcompany.com/oauth/code ``` alongside: ```plain text https://api.unified.to/oauth/code ``` ## Step 5: Add the domain to your workspace 1. Go to [Settings → Workspace](https://app.unified.to/settings/workspace) (use [app-eu.unified.to](https://app-eu.unified.to/settings/workspace) for EU workspaces). 2. Enter your subdomain in **Custom API domain**, without `https://` (e.g. `api.yourcompany.com`). 3. Click **Verify** (or **Update**). Unified checks that the CNAME points to your region's target. If it does, the domain shows as **Verified** and Unified adds it to its load balancer. A TLS certificate is issued automatically, usually within a few minutes. If the CNAME isn't in place yet, you'll see an error explaining what's missing. Wait for DNS to propagate and click **Verify** again. To stop using a custom domain, clear the field and click **Update**. ## Step 6: Check the setup 1. Open `https://api.yourcompany.com/swagger.json`. It should load over HTTPS. If you get a certificate error, wait a few minutes for the certificate to be issued. 2. Authorize a test connection to an OAuth integration and confirm the redirect works. ## Important notes ### OAuth verification - **Google OAuth**: after changing the redirect URI, you may need to complete app verification again, since Google sees it as a new domain ([Google's verification guide](https://support.google.com/cloud/answer/13461325)). - **Other providers** may require similar verification. ### API usage - Your API calls can use your custom domain. API functionality is identical. - API keys and authentication don't change. ### Security - Protect access to your DNS provider and consider enabling DNSSEC. - Monitor for unauthorized DNS changes. ## Troubleshooting 1. **"does not have a CNAME record pointing to …"** - DNS hasn't propagated yet, or the record points somewhere else. Make sure the target matches your workspace's region exactly and that Cloudflare proxying is off. 2. **"is already used by another workspace"** - Each custom domain can belong to one workspace. Contact Unified Support if you need to move it. 3. **Certificate error on your domain** - The certificate is issued a few minutes after verification. If it still fails after an hour, contact Unified Support. 4. **OAuth errors after the change** - Make sure redirect URIs are updated in all OAuth applications, and complete app verification if the provider requires it. ## Example For `api.yourcompany.com` in the US region: ```plain text Type: CNAME Name: api Value: unified-domains-us-7d67c6f04efebb36.elb.us-east-1.amazonaws.com TTL: 3600 ``` OAuth redirect URI: `https://api.yourcompany.com/oauth/code` API endpoint: `https://api.yourcompany.com/crm/{connection_id}/contact` ## How to set up a HiBob marketplace install URL: https://docs.unified.to/guides/how_to_set_up_a_hibob_marketplace_install # How to set up a HiBob marketplace install ------ _October 16, 2026_ Use this when a HiBob customer installs your app from Bob Marketplace. Bob sends the browser to a fixed callback with only a code. Unified exchanges that code and creates the connection. The embedded sign-in flow, where the user starts in Unified and returns to your app, is a different setup. A HiBob app has only one Redirect URI, so see "One Redirect URI per HiBob app" below before you choose. --- ## Before you start - A Unified workspace in the region where the connection should live - A HiBob app in the [HiBob Developer Portal](https://developers.hibob.com/), with its Client ID and Client Secret. HiBob OAuth is only available to approved Marketplace and technology partners. - The scopes your app needs, set on the HiBob app --- ## 1. Save the HiBob credentials in Unified 1. In Unified, open Integrations and activate HiBob for the environment you are using (Production or Sandbox). 2. Enter the Client ID and Client Secret from the HiBob app. HiBob issues separate Development and Production credentials. Use the set that matches the installs you expect, because tokens issued with one set cannot be refreshed with the other. 3. Save the integration. The marketplace callback reads these credentials. It does not take a workspace API key, and it does not use the App ID field. Unified only uses the App ID when the install starts in Unified. --- ## 2. Register the Redirect URI on the HiBob app In the HiBob Developer Portal, open your app and, under OAuth, set the Redirect URI to the callback for this workspace and environment. Use the API host for the workspace region. ```plain text https://api.unified.to/hb/{workspace_id}/{environment}/connection ``` Replace the placeholders: - workspace_id is the Unified workspace ID. - environment is Production or Sandbox, matching the integration you activated. Regional hosts: - United States: https://api.unified.to - Europe: https://api-eu.unified.to - Australia: https://api-au.unified.to Example: ```plain text https://api.unified.to/hb/5f9c2a1b8e4d3c0011223344/Production/connection ``` The URI must match exactly. Do not add a query string, a trailing slash, or a success or failure URL. HiBob only sends the authorization code to this address, and Unified sends the same address back to HiBob when it exchanges the code. When you save the Redirect URI, HiBob regenerates the App Installation URL. If you test with the "App installation URL for testing", copy it again after this change. --- ## 3. Install from Bob Marketplace 1. In Bob, an admin opens your app in Bob Marketplace and clicks Install. 2. Bob shows the consent screen. The admin approves the scopes and chooses the People data access for the app. 3. Bob redirects the browser to the URL above with ?code=. The code expires after 5 minutes. 4. Unified creates the connection. The HiBob company ID (the companyId claim in the access token) is stored on the connection as the external reference. 5. Unified shows a short page saying the install finished and the tab can be closed. Unlike HighLevel, the browser is not sent back to Bob, because the page it came from is the consent screen. If your workspace listens for new connections, that webhook includes the new connection and its external reference. To test before your listing is live, follow [Test apps in Bob Marketplace](https://apidocs.hibob.com/docs/test-apps-in-bob-marketplace) and use your Development credentials in Unified. --- ## Each HiBob company is its own connection Each company that installs the app creates a separate Unified connection, identified by its HiBob company ID. Reinstalling the app creates a new connection. It does not update the existing one, and the old connection stops working at its next token refresh. --- ## One Redirect URI per HiBob app A HiBob app has a single Redirect URI, and HiBob requires every request to use it. An app set up with the URL above is for marketplace installs. Installs that start in Unified's embedded flow use https://api.unified.to/oauth/code as the redirect address, which does not match that app. --- ## Uninstalls Customers uninstall the app from Bob Marketplace. HiBob does not send a notification when this happens. The connection stops working at its next token refresh, when HiBob rejects the refresh token. --- ## If the install fails - Redirect URI does not match. Compare the HiBob app Redirect URI with the workspace ID, environment, and regional API host. - HiBob integration is missing, or the Client ID and Client Secret are not saved for that environment. - Development and Production credentials are mixed up. The credentials saved in Unified must belong to the same set as the install. - The authorization code expired. Codes last 5 minutes, so start the install again. - The admin declined the install. Unified shows the error HiBob returned. - The HiBob app does not include the scopes you need. Scopes are configured on the HiBob app, not on the Redirect URI. HiBob reference: [Add and submit an app](https://apidocs.hibob.com/docs/add-and-submit-an-app) and [OAuth 2.0](https://apidocs.hibob.com/reference/oauth-20). ## How to Set Up a Microsoft Teams Bot with Unified URL: https://docs.unified.to/guides/how_to_set_up_a_microsoft_teams_bot_with_unified # How to Set Up a Microsoft Teams Bot with Unified ------ _April 20, 2026_ ## Overview If your Microsoft Teams bot connection is created successfully in Unified but later API calls fail with `No authorization information present on the request`, the issue is usually not OAuth. OAuth connects Microsoft 365 to Unified, but your bot still needs to be: - packaged correctly - uploaded to Teams - installed in the correct Team, channel, or chat Without this, API calls will fail due to missing context. --- ## ⚠️ Important Clarification (Common Issue) - The manifest downloaded from **Microsoft Entra app registration is NOT a valid Teams manifest** - The Microsoft 365 schema is also **not the correct reference** You must use the **Microsoft Teams manifest schema**: [https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema) Using the wrong manifest will result in errors like: `Manifest parsing error message unavailable` --- ## What You'll Need Before starting: - Guide: [https://docs.unified.to/guides/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365](https://docs.unified.to/guides/how_to_get_your_oauth_2_credentials_for_microsoft_dynamics_365) - A Microsoft Entra app / bot registration - A Microsoft Teams app package (`.zip`) - Permission to upload custom apps in Teams - A Unified connection using **Microsoft Teams (bot)** --- ## Create a Valid Teams App Package Your `.zip` must include: - `manifest.json` - Color icon (192×192 PNG) - Outline icon (32×32 PNG) --- ### Recommended Approach (Avoid Manual Errors) Instead of writing JSON manually, use: 👉 Teams Developer Portal [https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/teams-developer-portal](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/teams-developer-portal) This allows you to: - Configure your bot via UI - Set scopes correctly - Generate a valid app package - Avoid manifest parsing errors --- ### Manifest Checklist Ensure your `manifest.json` includes: - Valid app ID - Bot capability with correct `botId` - Required scopes: - `team` - `groupchat` - `personal` (optional) Use: - `team` → for channels - `groupchat` → for chats --- ### Minimal Manifest Template (Reference) ```plain text { "$schema":"https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", "manifestVersion":"1.16", "version":"1.0.0", "id":"YOUR-APP-ID", "name": { "short":"My Teams Bot", "full":"My Teams Bot" }, "description": { "short":"Bot for Teams", "full":"Bot for Teams integrated with Unified" }, "developer": { "name":"Your Company", "websiteUrl":"https://yourdomain.com", "privacyUrl":"https://yourdomain.com/privacy", "termsOfUseUrl":"https://yourdomain.com/terms" }, "icons": { "color":"color.png", "outline":"outline.png" }, "accentColor":"#FFFFFF", "bots": [ { "botId":"YOUR-ENTRA-APP-ID", "scopes": ["team","groupchat"] } ] } ``` --- ### ⚠️ Notes (Follow as Steps) 1. Replace `YOUR-APP-ID` and `YOUR-ENTRA-APP-ID` with your actual app ID 2. Ensure both icons exist in the `.zip` file 3. Use this only as a reference template, not a full production manifest --- ## Upload the App in Microsoft Teams Steps: 1. Open **Apps** (left sidebar) 2. Click **Manage your apps** 3. Stay on **Apps tab** 4. Click **Upload an app** 5. Upload your `.zip` ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_a_microsoft_teams_bot_with_unified-0.png) ⚠️ If upload option is missing → admin restriction --- ## Install the Bot in the Right Scope Uploading ≠ installing You must install the bot in: - A Team - A channel - A group chat - Personal scope ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_a_microsoft_teams_bot_with_unified-1.png) ⚠️ If not installed → API calls will fail --- ## Connect Microsoft Teams (bot) to Unified Steps: 1. Create connection in Unified 2. Complete OAuth 3. Approve scopes 4. Save connection ### Common Required Scopes - `Channel.ReadBasic.All` - `ChannelMessage.Read.All` - `ChatMessage.Read` - `Chat.Read` - `ChannelMessage.Send` - `ChatMessage.Send` - `offline_access` 💡 If permissions change → reconnect integration --- ## Test the Connection 1. Send a message where the bot is installed 2. Call Unified messaging API 3. Verify read/send works 💡 If connection was created before installation → retry --- ## Troubleshooting ### ❌ Manifest parsing error - Wrong manifest type (Entra instead of Teams) - Wrong schema --- ### ❌ App package failed validation - Missing icons - Files not at root - Invalid JSON - `botId` mismatch --- ### ❌ No authorization information present - Bot not installed in correct scope - Missing permissions - Wrong scope - Token outdated → reconnect ## How to set up and configure Notion URL: https://docs.unified.to/guides/how_to_set_up_and_configure_notion # How to set up and configure Notion ------ _May 21, 2024_ This guide describes how to register a public Notion application (a.k.a. ‘integration') and configure it so that you can use the Unified API to query pages and databases. ## Understanding Notion integrations: Internal vs Public Before setting up your Notion integration, it's important to understand the two types of integrations Notion offers: 1. **Internal Integrations**: These are confined to a single workspace and are only accessible by members of that workspace. They're ideal for custom workspace enhancements. 2. **Public Integrations**: These are designed for a wider audience and can be used across any Notion workspace. They follow the OAuth 2.0 protocol for workspace access. The main difference in terms of accessing user data is: - For Internal Integrations, workspace members explicitly grant access to their pages or databases via Notion's UI. - For Public Integrations, users authorize access to their pages during the OAuth flow, or by sharing pages directly with the integration. For more information on the differences, refer to the [Notion API Overview](https://developers.notion.com/docs/getting-started). ## Setting up your Notion integration The following steps outline how to register a public Notion integration: 1. Go to [https://www.notion.so/my-integrations](https://www.notion.so/my-integrations) 2. Click on your integration (or create a new one) 3. Follow the steps to register a new Notion integration. 1. Under **Type**, select **Public**. 2. Under **Redirect URIs**, enter `https://api.unified.to/oauth/code` 4. Click **Save**. 5. Go to the configuration settings of your newly created integration and make a note of the **OAuth Client ID** and **OAuth Client Secret.** 6. Still under the configuration settings, make sure to select the permissions you need for your app under **Capabilities** e.g. read or update content. 7. Enter your OAuth credentials on [https://app.unified.to/integrations/notion](https://app.unified.to/integrations/notion) to activate the Notion integration. **Note:** If you wish to create an Internal integration, most of the steps above can be skipped, and you will need to enter your `Internal integration token` on [app.unified.to](https://app.unified.to/) instead. Instructions on how to find your internal token can be found [here](https://www.notion.so/help/create-integrations-with-the-notion-api). ## Querying databases in Notion To query databases in Notion, you'll need to use our Unified KMS endpoint: 1. First, make sure you have a `connection_id` for your Notion integration. 2. Since database entries in Notion are technically pages, we'll use the **List all pages** endpoint: ```plain text GET /kms/{connection_id}/page ``` API reference: [List all pages](https://docs.unified.to/kms/page/List_all_pages) 1. When querying pages in a database, you need to pass the database ID as the `space_id` parameter. For example: ```plain text GET /kms/{connection_id}/page?space_id={database_id} ``` 2. You can use additional parameters to filter and sort your results: - `limit`: Number of results to return (default: 30) - `offset`: Number of results to skip (default: 0) - `updated_gte`: Return only results updated on or after this date - `sort`: Sort by 'name', 'updated_at', or 'created_at' - `order`: Sort order ('asc' or 'desc') - `query`: Search query string - `fields`: Comma-separated list of fields to return For example, to get the first 50 pages in a database, sorted by name in ascending order: ```plain text GET /kms/{connection_id}/page?space_id={database_id}&limit=50&sort=name&order=asc ``` ## Webhooks Notion doesn't offer programmatic creation of webhooks, so you need to first create webhooks for your application. Then, you can use our CreateWebhook API endpoint to select what data and connection you want to receive. 1. Go you your Notion's integrations page at [https://www.notion.so/profile/integrations](https://www.notion.so/profile/integrations) 2. Either create a new integration or select an existing one. 3. Navigate to the **Webhooks** tab and click **+ Create a subscription**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_and_configure_notion-0.png) 4. Enter your public **Webhook URL** — this is the public endpoint where you want Notion to send events. It must be a secure (SSL) and publicly available endpoint. Endpoints in localhost are not reachable. 1. US: `https://api.unified.to/webhook/workspace/notion?workspace_id=$WORKSPACE_ID` 2. EU: `https://api-eu.unified.to/webhook/workspace/notion?workspace_id=$WORKSPACE_ID` ![1ef497d7b9b3622de379e6907cd722167766413693ac9f1885b59eb028b4e7dd-webhooks-2.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_and_configure_notion-1.png) 5. Select the events that you are interested in receiving from all of your customers. Created and Deleted events are associated with our `created` and `deleted` events, while all other events are associated with our `updated` event. Both Notion's `Page` and `Database` are mapped to Unified's `KMSPage`, while `Comments` are mapped to `KMSComment`. You can modify these later if needed. 6. Click **Create subscription**. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_and_configure_notion-2.png) 7. After you create the notion webhook, notion will send us a verification token. You can find that verification token in [https://app.unified.to/settings/api](https://app.unified.to/settings/api). Paste the `verification_token` value into the Verify Subscription form in Notion, and click **Verify subscription.** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_and_configure_notion-3.png) Once completed, you can create webhooks in Unified.to that will receive these Notion events in real-time. ## How to set up Google Business Profile API access and get OAuth 2 credentials URL: https://docs.unified.to/guides/how_to_set_up_google_business_profile_api_access_and_get_oauth_2_credentials # How to set up Google Business Profile API access and get OAuth 2 credentials ------ _August 31, 2026_ > 💡 **Note** that Google Business Profile uses the `https://www.googleapis.com/auth/business.manage` scope, which Google classifies as `Sensitive`. You need to contact Unified's Support to setup a CNAME for the API URL (US and EU regions) in order to get your App approved by Google. > Check [How to set up a custom API URL](https://docs.unified.to/guides/how_to_set_up_a_custom_api_url). > ⚠️ Enabling the APIs is **not** enough. Until Google approves your [Application for Basic API Access](https://support.google.com/business/contact/api_default), your project's quota for the Business Profile APIs stays at **0 requests per minute** and every call fails with a `429`. Submit that form as early as possible — Google's review can take several days to a few weeks. 1. **Prerequisites** - A Google account with **owner** or **manager** access to the Business Profile(s) you want to connect. - A verified, active Business Profile. Google's review expects the profile to have been active for at least 60 days and to have a website listed on it. - Review Google's [Prerequisites](https://developers.google.com/my-business/content/prereqs) page before you apply. 2. **Create a Google Cloud project** - Go to the [Google Cloud Console](https://console.cloud.google.com/) and sign in with that Google account. - Click the **Select a project** dropdown at the top of the page and choose **New Project**. - Give your project a name and click **Create**. - Copy the **Project number** and **Project ID** from the Cloud Console dashboard — you need them in the next step. 3. **Submit the Application for Basic API Access** - Open the [Application for Basic API Access](https://support.google.com/business/contact/api_default) form. - Fill in the requested information: - The **Project ID** and **Project number** from step 2 - The email address of an **owner or manager** of the Business Profile - Your company name, website and a description of how you will use the API - Submit the form. Google sends a follow-up email once the request has been reviewed. - You do not have to wait for approval to continue — steps 4 to 8 can be completed right away. 4. **Enable the Business Profile APIs** - In the Google Cloud Console, make sure your project is selected. - Navigate to [**APIs & Services > Library**](https://console.cloud.google.com/apis/library) and enable each of the following: - [Google My Business API](https://console.cloud.google.com/apis/library/mybusiness.googleapis.com) (`mybusiness.googleapis.com`) — posts and reviews - [My Business Account Management API](https://console.cloud.google.com/apis/library/mybusinessaccountmanagement.googleapis.com) (`mybusinessaccountmanagement.googleapis.com`) — the accounts you manage - [My Business Business Information API](https://console.cloud.google.com/apis/library/mybusinessbusinessinformation.googleapis.com) (`mybusinessbusinessinformation.googleapis.com`) — the locations under each account - [Business Profile Performance API](https://console.cloud.google.com/apis/library/businessprofileperformance.googleapis.com) (`businessprofileperformance.googleapis.com`) — location insights - These four are the only APIs Unified calls. Google [documents several others](https://developers.google.com/my-business/content/basic-setup) (Notifications, Verifications, Q&A, Place Actions, Lodging) — you do not need them for a Unified connection. - The **Google My Business API** only becomes visible in the Library **after** your Basic API Access request is approved. If you cannot find it, come back to this step once you receive Google's approval email. 5. **Configure the OAuth Consent Screen** - In the Google Cloud Console, go to [Google Auth Platform](https://console.cloud.google.com/auth/overview). - Fill in what is asked and click **Create**. - Go to [Auth Branding](https://console.cloud.google.com/auth/branding) and fill in the required information, including your app name, support email, authorized domain and privacy policy URL. 6. **Scopes** - Go to [Auth Data Access](https://console.cloud.google.com/auth/scopes). - Add `https://www.googleapis.com/auth/business.manage`, plus `openid`, `userinfo.email` and `userinfo.profile`. - The exact list of scopes used by Unified can always be found in the [Google Business Profile integration](https://app.unified.to/integrations/googlebusinessprofile?tab=oauth2) in the Unified App. - **Note** that `business.manage` is a `Sensitive` scope, so you need to contact Unified's Support to setup a CNAME for the redirect URL (US and EU regions) in order to get your App approved by Google. - Review the information and click **Save and Continue** through the steps. 7. **Create OAuth 2.0 Credentials** - Go to [Auth Clients](https://console.cloud.google.com/auth/clients) in the Google Cloud Console. - Click on **+ Create Client** and select **Web application**. - Configure the authorized redirect URI. Get the correct value from the [Google Business Profile integration](https://app.unified.to/integrations/googlebusinessprofile?tab=oauth2) in the Unified App. - Copy your **Client ID** and **Client Secret**. 8. **Publish your Application** - Go to [Auth Audience](https://console.cloud.google.com/auth/audience) and click on **Publish app**. - Because you are using a `Sensitive` scope, Google will require verification of your app. Until it is verified, your consent screen will show the `unified.to` name, which is inferred from the redirect URL — this is what the CNAME in step 1 solves. 9. **Configure your Unified integration** - In the [Google Business Profile integration](https://app.unified.to/integrations/googlebusinessprofile?tab=auth) in the Unified App, enter: - The OAuth client's **Client ID** - The OAuth client's **Client Secret** - Save the integration. You are now ready to authorize a Google Business Profile connection. 10. **Verify that your API access was approved** - Go to [IAM & Admin > Quotas](https://console.cloud.google.com/iam-admin/quotas) in the Cloud Console and filter by one of the Business Profile services, for example `mybusinessaccountmanagement.googleapis.com`. - A **Requests per minute** limit of **0** means your Basic API Access request has not been approved yet. - A limit of **300** means you have been approved and your connection will work. ## Troubleshooting | Symptom | Cause and fix | | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `429 RATE_LIMIT_EXCEEDED` — _Quota exceeded for quota metric 'Requests' and limit 'Requests per minute'_ | Either your Basic API Access request is still pending (quota is 0 QPM), or you are exceeding the default 300 QPM. Check the quota as described in step 10, and request a quota increase from the same Quotas page if you legitimately need more. | | `invalid_client` when authorizing a connection | The Client ID or Client Secret in the Unified integration does not match the OAuth client, or the redirect URI in Google does not match the one shown in the integration. Re-copy all three values. | | `403 PERMISSION_DENIED` or an empty list of accounts | The Google account used to authorize does not have owner or manager access to the Business Profile, or the required API from step 4 is not enabled on the project. | | The consent screen shows `unified.to` instead of your app name | Your app has not been verified by Google yet. Contact Unified's Support to setup a CNAME for the API URL (US and EU regions), then submit your app for verification. See [How to set up a custom API URL](https://docs.unified.to/guides/how_to_set_up_a_custom_api_url). | | An API cannot be found in the Cloud Console Library | The Google My Business API is only exposed once Google approves your Basic API Access request. Wait for the approval email and enable it then. | ## How to Set Up LinkedIn Webhooks with Unified URL: https://docs.unified.to/guides/how_to_set_up_linkedin_webhooks_with_unified # How to Set Up LinkedIn Webhooks with Unified ------ _February 14, 2026_ LinkedIn's Organization Social Action Notifications let you receive real-time updates when people interact with your company page: likes, comments, shares, and mentions. With Unified, you can subscribe to these events through the same webhook API you use for other integrations, so there's no need to learn LinkedIn's proprietary webhook protocol. ## **What You'll Need** Before starting, review the [Getting Started with Unified](https://docs.unified.to/guides/getting_started_with_unified) article if you haven't already. You'll also need: - A LinkedIn developer app (see setup instructions below) - A LinkedIn connection created through Unified's embedded auth flow - A publicly accessible URL to receive webhook events ## **Setting Up Your LinkedIn Developer App** Before you can receive webhook events, you need a LinkedIn app configured with the right products and permissions. **Creating the App** 1. Go to [linkedin.com/developers/apps](https://www.linkedin.com/developers/apps) and click **Create App** 2. Fill in the required fields: - **App name**: Your application's name - **LinkedIn Page**: Select the LinkedIn company page you want to monitor (you must be an admin of the page) - **App logo**: Upload a logo for your app 3. Accept the legal terms and click **Create app** **Requesting API Products** LinkedIn gates webhook access behind specific API products. From your app's settings page: 1. Navigate to the **Products** tab 2. Request access to **Community Management API**, which grants the `r_organization_social` scope needed for social action notifications 3. If you also want to send messages, request **LinkedIn Marketing API Partner Program** for the `w_messages` scope Product approvals can take a few business days. You can check the status on the **Products** tab. **Configuring OAuth Scopes** Once your products are approved: 1. Go to the **Auth** tab in your app settings 2. Under **OAuth 2.0 scopes**, verify that `r_organization_social` is listed 3. Copy your **Client ID** and **Client Secret**; you'll need these when creating a connection in Unified 4. Under **Authorized redirect URLs**, add your Unified OAuth callback URL **Verifying Your Company Page** LinkedIn requires that your developer app is associated with a verified company page: 1. Go to the **Settings** tab in your app 2. Under **App Settings**, confirm the correct LinkedIn Page is linked 3. A page admin must verify the app. Click **Generate URL** next to the verification prompt and share it with a page admin if needed Once the page is verified and the products are approved, your app is ready to receive social action notifications. ## **Creating a Webhook for LinkedIn Events** You can create a webhook subscription through the API or the Unified dashboard. The webhook will listen for `messaging_event` objects with the `created` event, which is how LinkedIn's social action notifications are surfaced. **Via the API** `import { UnifiedTo } from '@unified-api/typescript-sdk'; const sdk = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY!, }, }); const webhook = await sdk.unified.createUnifiedWebhook({ webhook: { hookUrl: 'https://your-app.com/webhooks/linkedin', connectionId: process.env.LINKEDIN_CONNECTION_ID!, objectType: 'messaging_event', event: 'created', }, }); console.log('Webhook created:', webhook.webhook?.id);` **Via the Dashboard** 1. Open [app.unified.to](https://app.unified.to/) and navigate to your workspace 2. Go to **Webhooks** and click **Create Webhook** 3. Select your LinkedIn connection 4. Set the object type to `messaging_event` and the event to `created` 5. Enter your webhook URL and save Once the webhook is active, Unified registers your endpoint with LinkedIn's event subscription API using the organization URN from your connection. ## **Receiving Events** When someone interacts with your LinkedIn company page, Unified converts the raw LinkedIn notification into a standard `MessagingEvent` object and POSTs it to your `hook_url`. Here's what that looks like: `import express from 'express'; const app = express(); app.use(express.json()); app.post('/webhooks/linkedin', (req, res) => { const event = req.body; switch (event.type) { case 'MESSAGE_RECEIVED': // Someone commented on a company post, shared it, or edited/deleted a comment console.log('Comment or share from:', event.user?.id); console.log('On organization:', event.channel?.id); break; case 'REACTION_ADDED': // Someone liked a company post console.log('Like from:', event.user?.id); break; case 'APP_MENTION': // Your company was mentioned in a share console.log('Mentioned by:', event.user?.id); break; } res.sendStatus(200); });` ## **Event Type Mapping** LinkedIn's social action types are mapped to Unified's standard `MessagingEvent` types: - **COMMENT** and **ADMIN_COMMENT** become `MESSAGE_RECEIVED`: a new comment was posted on your organization's content - **LIKE** becomes `REACTION_ADDED`: someone liked your organization's post - **SHARE** becomes `MESSAGE_RECEIVED`: someone shared your organization's content - **SHARE_MENTION** becomes `APP_MENTION`: your organization was mentioned in a share - **COMMENT_EDIT** and **COMMENT_DELETE** become `MESSAGE_RECEIVED`: an existing comment was modified or removed Each event includes the `channel` field set to your organization's ID, the `user` field with the actor's person ID, and a `created_at` timestamp. The original LinkedIn payload is always available in the `raw` field if you need additional detail. ## **Sending Messages Alongside Webhooks** While webhooks cover inbound events, you can also send outbound messages through LinkedIn's Messages API using the same connection. This is useful for responding to engagement by reaching out to the people interacting with your content. `const message = await sdk.messaging.createMessagingMessage({ messagingMessage: { message: 'Thanks for engaging with our content!', destinationMembers: [ { userId: 'urn:li:person:ABC123' }, ], }, connectionId: process.env.LINKEDIN_CONNECTION_ID!, });` Note that LinkedIn's Messages API requires partner-level access with the `w_messages` scope. Message creation supports new conversations (via `destinationMembers`) or replies to existing threads (via `channelId`). ## **Troubleshooting** **Webhook not receiving events?** LinkedIn requires the `r_organization_social` OAuth scope, which is granted through the Community Management API product. Verify your app has this product approved on the [LinkedIn Developer Portal](https://www.linkedin.com/developers/apps), and that your connection has the scope by checking the connection details in the Unified dashboard. **Events arriving for the wrong organization?** The webhook subscription is tied to the `organization_id` stored in your connection metadata, which is set during the initial OAuth flow. If you manage multiple LinkedIn organizations, create separate connections for each one. **LinkedIn Page not verified?** Your developer app must be associated with a verified company page. Go to your app's **Settings** tab on the LinkedIn Developer Portal and complete the page verification process. A page admin must approve the association. With just a few lines of code, you're now receiving LinkedIn engagement events through the same webhook API you use for Slack, Discord, and other messaging integrations on Unified. No custom protocol handling required. For creating the webhook in Unified, see [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks). [→ Start your 30-day free trial](https://app.unified.to/login) [→ Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) ## How to set up native webhooks for QuickBooks Online with Unified URL: https://docs.unified.to/guides/how_to_set_up_native_webhooks_for_quickbooks_online_with_unified # How to set up native webhooks for QuickBooks Online with Unified ------ _August 25, 2026_ How to set up native webhooks for QuickBooks Online with Unified. Intuit does **not** provide a programmatic webhook-registration API. The callback URL and entity subscriptions are configured in the [Intuit Developer Portal](https://developer.intuit.com/). Unified then receives Intuit deliveries and fans them out to your Unified webhook subscriptions. Official Intuit docs: [Configure webhooks](https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks/configure-webhooks) --- ## Prerequisites - An Intuit Developer app with QuickBooks Online Accounting scopes - A Unified workspace and a connected QuickBooks Online connection (OAuth completed) - Your Unified **workspace ID** (from the Unified dashboard / API) --- ## Step 1: Build the Unified webhook endpoint URL Use this format: ```plain text https://api.unified.to/webhook/workspace/quickbooks?workspace_id=YOUR_WORKSPACE_ID ``` > 💡 Replace `YOUR_WORKSPACE_ID` with your actual Unified workspace ID. > If your workspace runs in another Unified data region, use that region's API host instead of `api.unified.to` (for example EU / AU hosts). Example: ```plain text https://api.unified.to/webhook/workspace/quickbooks?workspace_id=5e8ba11d3fb8ff95c90b2 ``` --- ## Step 2: Open your app in the Intuit Developer Portal 1. Go to [https://developer.intuit.com/app/developer/dashboard](https://developer.intuit.com/app/developer/dashboard) 2. Sign in and open the QuickBooks Online app that customers connect through Unified 3. In the left navigation, select **Webhooks** --- ## Step 3: Choose Production or Development Intuit maintains **separate** webhook configs: - **Production**: live QuickBooks Online companies - **Development**: sandbox / testing 1. Select **Production** or **Development** to match the companies you are testing 2. Enter the Endpoint URL from Step 1 3. Click **Show webhooks** (or equivalent) to expand the entity list --- ## Step 4: Enable the entities Unified supports Enable at least the entities that map to the Unified objects you care about: | **QuickBooks entity** | **Unified object** | **Operations to enable** | | --------------------- | --------------------- | ------------------------ | | Customer | `accounting_contact` | Create, Update, Delete | | Vendor | `accounting_contact` | Create, Update, Delete | | Invoice | `accounting_invoice` | Create, Update, Delete | | Class | `accounting_category` | Create, Update | > 📚 **Class** is listed as a supported webhook entity in Intuit's docs (Create / Update / Delete): > [Configure webhooks → supported API entities](https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks/configure-webhooks) 1. Select **Save** --- ## Step 5: Create Unified webhooks for each object/event In Unified, create a **native** webhook for each object + event pair you need. Examples: - `accounting_contact` → `created` / `updated` / `deleted` - `accounting_invoice` → `created` / `updated` / `deleted` - `accounting_category` → `created` / `updated` --- ## Step 6: Test the delivery path 1. In the Intuit Developer Portal Webhooks page, use **Send test notification** (if available) to confirm your endpoint responds with HTTP 200 quickly 2. In a connected QuickBooks company (sandbox or production matching Step 3), create or update an enabled entity; for example, create/update a **Class** for `accounting_category` 3. Confirm Unified receives the delivery and dispatches to your Unified webhook hook URL --- ## Troubleshooting - **No events arriving** - Confirm the Endpoint URL includes the correct `workspace_id` - Confirm you configured **Production** vs **Development** for the company you changed - Confirm the entity (e.g. **Class**) is enabled in the Intuit portal - Confirm a Unified native webhook exists for that `object_type` + `event` - **Subscribe fails with realm_id error** - Re-auth the QuickBooks connection so `realm_id` is present - **Events for one company only** - Expected: matching is per `realmId`. Each company connection has its own subscription marker For creating the webhook in Unified, see [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks). --- ## References - [Intuit: Configure webhooks](https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks/configure-webhooks) - [Intuit: Manage webhook notifications](https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks/manage-webhooks-notifications) - [Intuit: Best practices](https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks/best-practices) ## How to set up Slack webhooks using event subscriptions URL: https://docs.unified.to/guides/how_to_set_up_slack_webhooks_using_event_subscriptions # How to set up Slack webhooks using event subscriptions ------ _November 14, 2024_ This guide explains how to configure your Slack app's event subscriptions to work with Unified.to webhooks. This is a required step before you can create webhooks that listen for Slack events. Once configured, your webhooks will receive real-time notifications when specific events occur in your Slack workspace, such as when users send messages. ## Before you begin Ensure you have: - A Slack app created in your workspace - Access to your Slack app's configuration settings - Your [Unified.to](https://unified.to/) workspace ID - An understanding of [how webhooks work in Unified.to](https://docs.unified.to/concepts/webhooks) - Create a webhook in your ## Enable event subscriptions in Slack 1. Go to the Slack API dashboard at [https://api.slack.com/apps](https://api.slack.com/apps) 2. Select your app from the list of available apps 3. In the left sidebar, under **Features**, click **Event Subscriptions** 4. Toggle the **Enable Events** switch to **On** 5. In the **Request URL** field, enter (depending on your data centre; replace ID with your workspace ID, and replace `slack` with `slackbot` if using that integration): 1. (US data center) `https://api.unified.to/webhook/workspace/slack?workspace_id={ID}` 2. (EU data center) `https://api-eu.unified.to/webhook/workspace/slack?workspace_id={ID}` 6. Wait for Slack to verify the URL. You should see a green checkmark indicating successful verification. ![Screenshot_2024-11-14_at_5.10.40_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_slack_webhooks_using_event_subscriptions-0.png) 7. Click **Save Changes** at the bottom of the page ## Subscribe to events from Slack After you've enabled events and verified the request URL works, you need to subscribe to the events that you are interested in. 1. The minimum event you should subscribe to is`message.groups` (under user) 2. The other events you can subscribe to are the ones that fall under the following scopes under **Subscribe to events on behalf of users**: 1. `message.channels` 2. `message.groups` 3. `message.im` 4. `message.mpim` ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_slack_webhooks_using_event_subscriptions-1.png) _Example of Slack events that you can subscribe to for webhook events_ 3. Click **Save Changes** at the bottom of the page Once completed, you can create webhooks in Unified.to that will receive these Slack events in real-time. Happy building! ## See also - [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks) - [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks) ## How to set up Telegram (bot) with Unified.to URL: https://docs.unified.to/guides/how_to_set_up_telegram_bot_with_unified # How to set up Telegram (bot) with Unified.to ------ _June 30, 2026_ Telegram's **Bot API** lets you send and receive messages through a bot account you control. This guide walks you through creating a Telegram bot, connecting it to Unified.to with its bot token, and (optionally) turning on webhooks so you receive incoming messages and button clicks in real time. **Estimated time:** 10 minutes --- **Prerequisites** - A Telegram account (any mobile or desktop client). - A Unified.to account with a workspace. [Sign up here](https://unified.to/) if you don't have one. - For receiving messages via webhooks: nothing extra — Unified.to hosts the HTTPS endpoint for you. > **Bot API vs. personal account:** This integration uses the Telegram **Bot API**, so all messages are sent and received as your **bot**, not as your personal Telegram user. People interact with your bot by starting a chat with it or by adding it to a group/channel. --- **Step 1 — Create a bot and get your bot token** 1. In any Telegram client, open a chat with [**@BotFather**](https://t.me/BotFather) (the official bot for creating bots). 2. Send the command **`/newbot`**. 3. When prompted, enter a **name** for your bot (the display name, e.g. _Acme Support_). 4. Then enter a **username** for your bot. It must end in `bot` (e.g. `acme_support_bot`). 5. BotFather replies with a confirmation that includes your **HTTP API token**. It looks like this: ```plain text 123456789:AAEhBP0...M_zv1u123ew11 ``` 6. **Copy this token** and keep it safe — it grants full control of your bot. You'll paste it into Unified.to in Step 3. > Lost the token later? Send **`/token`** to BotFather and pick your bot, or **`/revoke`** to roll it. --- **Step 2 — (Optional) Let your bot read group messages** By default a bot in **privacy mode** only receives messages that mention it or reply to it. If you want your bot to receive **all** messages in a group: 1. Open [**@BotFather**](https://t.me/BotFather) and send **`/setprivacy`**. 2. Select your bot. 3. Choose **Disable**. You can skip this step for one-to-one (direct) chats — bots always receive direct messages sent to them. --- **Step 3 — Connect Telegram in Unified.to** 1. In the Unified.to dashboard, go to **Integrations** and search for **Telegram (bot)**. 2. Click **Connect** (or **Authorize**) to start a new connection. 3. When prompted for credentials, paste the value from Step 1 into the **Bot Token** field. 4. Submit. Unified.to validates the token by calling the Telegram `getMe` endpoint and, on success, creates the connection. The bot's name is shown on the connection. You can also create the connection programmatically by storing the bot token as the connection's token credential — see the [Authorization API](https://docs.unified.to/unified/unified/authorization). That's it for sending messages. To **receive** messages, continue to the webhook setup below. --- **Step 4 — Find a chat ID to send to** The Bot API can't list chats, so you need the **chat ID** of whoever you want to message. A chat ID is a number (e.g. `123456789` for a person, or a negative number like `-1001234567890` for a group/channel). The easiest way to discover one: 1. From the recipient's Telegram account, **start a chat with your bot** (search its `@username` and press **Start**), or **add the bot to your group/channel**. 2. Have them send any message to the bot / group. 3. That inbound message arrives through your Unified.to webhook (Step 5) and includes the `channel_id` (the Telegram chat ID). Use that value as the destination for future messages. **Sending a message** Once you have a `channel_id`, send a message through the unified `messaging_message` endpoint: ```plain text curl -X POST 'https://api.unified.to/messaging//message' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "channels": [{ "id": "123456789" }], "message": "Hello from Unified.to 👋" }' ``` You can also include inline buttons (mapped to Telegram inline keyboards) and reply to a message via `parent_id`. The response contains the new message `id`, which is required to **edit** (`editMessageText`) or **delete** (`deleteMessage`) it later. --- **Step 5 — Set up a webhook to receive messages** Unified.to registers the Telegram webhook for you — you don't call `setWebhook` yourself. When you create a webhook subscription, Unified.to points your bot at a secure HTTPS endpoint and verifies every delivery with a secret token. 1. In the Unified.to dashboard, open your **Telegram (bot)** connection and go to **Webhooks** (or use the [Webhook API](https://docs.unified.to/unified/unified/createunifiedwebhook)). 2. Create a webhook and choose what you want to receive: - **`messaging_message`**, event **`created`** — each new incoming message (and **`updated`** for edited messages). - **`messaging_event`**, event **`created`** — incoming messages **and** inline-button clicks, delivered as unified events (`MESSAGE_RECEIVED`, `BUTTON_CLICK`). 3. Provide your destination URL — the place Unified.to should forward normalized events to. 4. Save. Unified.to calls Telegram's `setWebhook` behind the scenes and begins forwarding events. Example webhook creation via the API: ```plain text curl -X POST 'https://api.unified.to/unified/webhook' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "connection_id": "", "object_type": "messaging_message", "event": "created", "hook_url": "https://your-app.example.com/telegram/incoming" }' ``` To stop receiving events, delete the webhook. Unified.to removes the bot's Telegram webhook automatically once the last subscription on the connection is gone. --- **Notes & troubleshooting** - **One webhook per bot.** Telegram allows a single webhook URL per bot, so all of a connection's webhook subscriptions share the same registration — that's expected and handled automatically. - **HTTPS is required.** Telegram only delivers updates to HTTPS endpoints. Webhooks therefore work on Unified.to's hosted environment but not against a plain-HTTP localhost listener during local development. - **No history.** The Bot API cannot list or fetch past messages or chats — a bot only sees messages sent **after** it joined a chat, delivered via webhooks. There is no "list messages" or "list channels" operation. - **Not seeing group messages?** Make sure you disabled privacy mode (Step 2) and re-added the bot to the group if needed. - **`getMe`** **/ connection fails?** Double-check the bot token was pasted exactly, with no extra spaces, and that the bot hasn't been revoked in BotFather. --- **What you can do next** - Send, edit, and delete messages (`sendMessage`, `editMessageText`, `deleteMessage`). - Look up a single chat's details with `messaging_channel` (`getChat`). - Receive incoming messages and inline-button clicks via `messaging_message` and `messaging_event` webhooks. Need help? [Contact Unified.to support](https://unified.to/) or browse the [other guides](https://docs.unified.to/guides). ## How to set up your scopes in HubSpot URL: https://docs.unified.to/guides/how_to_set_up_your_scopes_in_hubspot # How to set up your scopes in HubSpot ------ _June 11, 2024_ This guide explains how to correctly configure the advanced scopes settings in your HubSpot developer app for seamless integration with Unified.to. We'll cover setting up both required and optional scopes, and address some common issues you might encounter. ## Before you begin This guide assumes you have a basic understanding of: - [Scopes](https://docs.unified.to/concepts/scopes) - [How to create a connection (Project) ](https://docs.unified.to/guides/how_to_create_connection_with_hubspot) - [How to get your HubSpot developer key and OAuth 2 credentials (Legacy App)](https://docs.unified.to/guides/how_to_get_your_hubspot_developer_key_and_oauth_2_credentials_legacy_apps) ## Set up required scopes in HubSpot (Project) 1. Go to [https://offers.hubspot.com/free-cms-developer-sandbox](https://offers.hubspot.com/free-cms-developer-sandbox), Sign in. 2. Development → Project → select your project 3. In your project(code) you will see following folder structure. GOTO→ yourproject (account-testing) → app-hsmeta.json ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_your_scopes_in_hubspot-0.png) 1. Following will be the scopes and redirect URL. You can remove which you want. However, do remember those removed scopes before creating a connection in unified. Also remember to add redirect URL as follows. ```javascript { "uid": "get_started_app", "type": "app", "config": { "description": "A Unified.to token app for testing", "name": "My Get Started app", "distribution": "private", "auth": { "type": "oauth", "redirectUrls": [ "https://api.unified.to/oauth/code", ], "requiredScopes": [ "oauth", "crm.objects.companies.read", "crm.objects.contacts.read", "crm.objects.deals.read", "crm.objects.owners.read", "crm.pipelines.orders.read", "crm.objects.contacts.write", "crm.pipelines.orders.read", "crm.pipelines.orders.write", "crm.objects.deals.write", "sales-email-read", "content", "tickets", "crm.lists.read", "settings.users.teams.read" ], "optionalScopes": [], "conditionallyRequiredScopes": [] }, "permittedUrls": { "fetch": ["https://api.hubapi.com"], "iframe": [], "img": [] }, "support": { "supportEmail": "support@example.com", "documentationUrl": "https://example.com/docs", "supportUrl": "https://example.com/support", "supportPhone": "+18005555555" } } } ``` 1. You can update those changes using following command in terminal. ```javascript hs project upload ``` 1. When the code is uploaded successfully you can check that by going to following Development→ Project → Select your project. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_your_scopes_in_hubspot-1.png) 1. To check your scopes click on the project Component. It will show your app-hsmeta.json ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_your_scopes_in_hubspot-2.png) ## Set up required scopes in HubSpot (Legacy App) **Note**: If you are using webhooks, please refer to: [How to configure webhooks in HubSpot](https://docs.unified.to/guides/how_to_configure_webhooks_in_hubspot) 1. Go to [https://offers.hubspot.com/free-cms-developer-sandbox](https://offers.hubspot.com/free-cms-developer-sandbox), Sign in. 2. Development → Legacy App→ Your New App 3. In the same Auth tab, scroll 4. If you are not using webhooks, then [Unified.to](https://unified.to/) only requires two scopes to be set as **Required**: `crm.objects.owners.read` and `oauth` 1. Click **+ Add new scope** 2. Select `crm.objects.owners.read` 3. Set it as a **Required** scope 4. Save your changes ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_your_scopes_in_hubspot-3.png) ## Setup optional scopes 1. You can see the list of scopes required for the objects you need at [https://app.unified.to/integrations/hubspot?tab=oauth2](https://app.unified.to/integrations/hubspot?tab=oauth2) ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_your_scopes_in_hubspot-4.png) 1. Make sure to select optional ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_your_scopes_in_hubspot-5.png) 1. Add all the scopes you need 2. Save your changes ### Configure your scopes on [Unified.to](https://unified.to/) Be sure to also configure these scopes in your [Unified.to](https://unified.to/) settings. For instructions on how to do that, refer to our [Scopes guide](https://docs.unified.to/concepts/scopes). ## Understanding HubSpot's scope constraints HubSpot has some specific rules about how scopes work: - If you specify a scope as **Required**, it must be included in every authorization request. - **Note**: If you intend to _read_ data via a webhook, then the read scope for that object must be marked as **Required** in HubSpot. - **Optional** scopes may or may not be included in the authorization. - If you don't specify a scope at all, it cannot be included in the authorization request. - The OAuth scope cannot be removed and must always be required. Behind the scenes, [Unified.to](https://unified.to/) will request scopes according to what you have configured on the platform: - We'll always request `crm.objects.owners.read` as a **Required** scope - When you enable `webhook` scopes on [Unified.to](https://unified.to/), we convert every Hubspot scope ending with `.read` to required, assuming they'll all be used for webhooks. - Otherwise, if you have not enabled the `webhook` scope, then all other scopes will be requested as **Optional**. In general, we recommend that you only stick to requesting scopes for the data you actually need. For more instructions on setting up scopes to work with webhooks, please refer to: [How to configure webhooks in HubSpot](https://docs.unified.to/guides/how_to_configure_webhooks_in_hubspot) ## What your users will see When your users sign in with HubSpot, they'll see a screen requesting access to the scopes you've specified: - Required scopes will always be shown and cannot be deselected. - Optional scopes will be shown and can be deselected by the user. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_set_up_your_scopes_in_hubspot-6.png) ## Specifying Required vs Optional Scopes Unified tries to automatically classify scopes as required or optional. If you need additional control, you can always specify this information in the `scopes` URL parameter. Just prefix the Hubspot-specific scope with `required:` or `optional:` and we will handle them correctly. eg. `required:crm.objects.contacts.read` ## Troubleshooting If your HubSpot scopes don't match the requested scopes via [Unified.to](https://unified.to/), your users may see an error message about there being a scopes mismatch. Double-check that: 1. All scopes in your HubSpot app are included in every [Unified.to](https://unified.to/) request. 2. All scopes requested by [Unified.to](https://unified.to/) are specified (either as required or optional) in your HubSpot app. 3. If you're using webhooks, ensure all read scopes are set as **Required** in your HubSpot app. 4. If you're not using webhooks, ensure all scopes aside from `oauth` and [`crm.objects.owners.read`](https://crm.objects.owners.read/) are set as **Optional**. ## See also - [Understanding scopes](https://docs.unified.to/concepts/scopes) - [How to configure webhooks in HubSpot](https://docs.unified.to/guides/how_to_configure_webhooks_in_hubspot) - [How to troubleshoot unhealthy connections](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections#how-to-troubleshoot-unhealthy-connections) ## How to setup a Freshbooks developer app URL: https://docs.unified.to/guides/how_to_setup_a_freshbooks_developer_app # How to setup a Freshbooks developer app ------ _October 3, 2025_ This guide will show you how to retrieve your OAuth 2 credentials on Freshbooks ## Create your Freshbooks App and get your Client ID and Secret 1. Go to the Freshbooks Developer Portal: [https://my.freshbooks.com/#/developer](https://my.freshbooks.com/#/developer) 2. Click on `Create New App` ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_a_freshbooks_developer_app-0.png) 1. Fill in the Application Name, Application Type, these are mandatory 2. Add the scopes needed 1. You can see the scopes we use on [https://app.unified.to/integrations/freshbooks?tab=oauth2](https://app.unified.to/integrations/freshbooks?tab=oauth2) 3. Add [`https://api.unified.to/oauth/code`](https://api.unified.to/oauth/code) in the Redirect URIs ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_a_freshbooks_developer_app-1.png) 1. You can also fill in the non mandatory fields, logo, etc. 2. Click Save 3. Click on the App again ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_a_freshbooks_developer_app-2.png) 1. Your Client ID and Secret are at the bottom ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_a_freshbooks_developer_app-3.png) ## Activate the Freshbooks Integration in Unified 1. Go to [https://app.unified.to/integrations/freshbooks?tab=auth](https://app.unified.to/integrations/freshbooks?tab=auth) 2. Click in `Your OAuth 2 credentials` 3. Add your Client ID and Secret 4. Click Activate ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_a_freshbooks_developer_app-4.png) ## How to setup a TikTok Shop application URL: https://docs.unified.to/guides/how_to_setup_a_tiktok_shop_application # How to setup a TikTok Shop application ------ _August 11, 2026_ TikTok Shop uses different terminology than the standard OAuth2. There's no menu item labeled 'OAuth' — the OAuth 2.0 client _is_ the 'app,' and the credentials are labeled App Key / App Secret rather than Client ID / Client Secret. Here's the mapping and the exact path: - **App Key** = your OAuth2 Client ID - **App Secret** = your OAuth2 Client Secret - (You'll also see a **Service ID** on the same page — TikTok-specific, not part of standard OAuth) Steps in Partner Center: 1. Log in and make sure you're registered as an app developer (not just a seller account). If you skipped developer onboarding, the App & Service section won't behave as expected. 2. In the left navigation, select App & Service, then click Create app & service in the top-right corner. 3. Choose the app type. A Public app is for applications you plan to publish on the TikTok Shop App Store; a Custom app is for your own store or direct distribution to specific sellers. Both use the same OAuth 2.0 authentication and have the same level of API access. For a unified-API connector like yours, Public is the usual choice (you can convert a Custom app later if needed). 4. Fill in name, category, target market, and set your **Redirect URL** to your callback endpoint. Enable API access. 5. After creating, you land on the app detail page. Copy the App key (client ID), the App secret (client secret), and the ID / service ID shown below the app name. 6. Separately, apply for the API permissions/scopes you need under Manage API — these are granted per-app and gate what data you can pull. One important gotcha for you: region is split. The main Partner Center is partner.tiktokshop.com, but the US region has a separate center at partner.us.tiktokshop.com. Credentials aren't shared across them, so if your sellers span US and non-US, you may need an app in each. ## How to Setup AWS Assume Role for AWS Secret Manager URL: https://docs.unified.to/guides/how_to_setup_aws_assume_role_for_aws_secret_manager # How to Setup AWS Assume Role for AWS Secret Manager ------ _March 12, 2026_ This guide walks you through configuring AWS IAM Assume Role so that Unified can securely access AWS Secrets Manager in your account — without sharing long-lived AWS access keys. ## Overview Instead of providing static AWS credentials (access key + secret), you can create an IAM role in your AWS account and grant Unified permission to assume it. Unified uses AWS STS (Security Token Service) to obtain short-lived, temporary credentials scoped to your Secrets Manager. ### How it works ```plain text ┌───────────────┐ STS AssumeRole ┌────────────────────┐ │ Unified API │ ──────────────────────────────► │ Your AWS Account │ │ (account │ (with External ID check) │ │ │ 944579081756│ ◄────────────────────────────── │ IAM Role │ └───────────────┘ temporary credentials │ Secrets Manager │ └────────────────────┘ ``` 1. You create an IAM role in your AWS account with a trust policy that allows Unified's AWS account to assume it. 2. You attach a permissions policy to that role granting access to Secrets Manager. 3. You provide the role's ARN and an External ID in the Unified dashboard. 4. Unified calls `sts:AssumeRole` with the External ID, receives temporary credentials, and uses them to read/write secrets. ## Prerequisites - An AWS account with permissions to create IAM roles and policies - Access to the Unified dashboard with workspace admin permissions ## Step 1: Create an IAM Policy for Secrets Manager In the AWS Console, create a policy that grants the permissions Unified needs on Secrets Manager. 1. Go to **IAM** > **Policies** > **Create policy** 2. Select the **JSON** tab and paste the following: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "secretsmanager:CreateSecret", "secretsmanager:GetSecretValue", "secretsmanager:UpdateSecret", "secretsmanager:DeleteSecret" ], "Resource": "arn:aws:secretsmanager:YOUR_REGION:YOUR_ACCOUNT_ID:secret:*" } ] } ``` Replace `YOUR_REGION` (e.g. `us-east-1`) and `YOUR_ACCOUNT_ID` with your values. **Tip:** To restrict access further, you can narrow the `Resource` to a specific prefix, for example: `arn:aws:secretsmanager:us-east-1:123456789012:secret:unified/*` 3. Name the policy (e.g. `UnifiedSecretsManagerAccess`) and create it. ## Step 2: Create an IAM Role with a Trust Policy 1. Go to **IAM** > **Roles** > **Create role** 2. Select **Custom trust policy** and paste the following: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::944579081756:user/unified_assume_role_user" }, "Action": "sts:AssumeRole", "Condition": { "StringEquals": { "sts:ExternalId": "YOUR_EXTERNAL_ID" } } } ] } ``` Replace `YOUR_EXTERNAL_ID` with a unique, hard-to-guess string of your choice (e.g. a UUID). You will enter this same value in the Unified dashboard later. **Why an External ID?** The External ID prevents the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). It ensures that only requests originating through your Unified workspace — and not a third party who happens to know the role ARN — can assume the role. 3. Click **Next**, then attach the `UnifiedSecretsManagerAccess` policy you created in Step 1. 4. Name the role (e.g. `UnifiedSecretsManagerRole`) and create it. 5. Copy the role's **ARN** from the role summary page. It will look like: `arn:aws:iam::123456789012:role/UnifiedSecretsManagerRole` ## Step 3: Configure Unified 1. Log in to the [Unified dashboard](https://app.unified.to/). 2. Navigate to **Settings** > **Workspace Settings**. 3. Under the secrets manager section, select **AWS Secret Manager**. 4. Fill in the following fields: | Field | Value | | ------------------- | ---------------------------------------------------------------------------------------------------------- | | **AWS Region** | The region where your Secrets Manager secrets are stored (e.g. `us-east-1`) | | **AWS ARN** | The full ARN of the IAM role you created (e.g. `arn:aws:iam::123456789012:role/UnifiedSecretsManagerRole`) | | **AWS External ID** | The same External ID string you used in the trust policy | **Note:** When using Assume Role, you do **not** need to fill in the AWS Key and AWS Secret fields. Those fields are only required for the static-credentials approach. 5. Save your workspace settings. ## Step 4: Verify the Setup Once saved, Unified will automatically use the Assume Role flow for all new connections in your workspace. To verify: 1. Create or update a connection in your workspace. 2. Check your AWS Secrets Manager console — you should see a new secret created with a name that includes your workspace ID. 3. If there are any issues, Unified will surface errors in the connection status. ## Troubleshooting ### 'Missing role ARN or region' Ensure both the **AWS Region** and **AWS ARN** fields are filled in on your workspace settings. ### 'Access Denied' or 'Not authorized to perform sts:AssumeRole' - Verify the **trust policy** on your IAM role references the correct Unified AWS principal: `arn:aws:iam::944579081756:user/unified_assume_role_user` - Verify the **External ID** in the trust policy matches exactly what you entered in Unified. - Ensure the IAM role's permissions policy includes the required `secretsmanager:*` actions. ### 'The security token included in the request is expired' Temporary credentials are cached for up to 55 minutes and refreshed automatically. If you see this error persistently, confirm that your IAM role allows a session duration of at least 1 hour (the default). ### Secrets are not being stored - Confirm the IAM role's permissions policy allows actions on the correct region and account. - Check that the `Resource` in your permissions policy matches the region configured in Unified. ## Migrating from Static Credentials If you are currently using the static AWS Key / AWS Secret approach: 1. Follow Steps 1-3 above to set up the IAM role and configure Unified. 2. Once the ARN and External ID are saved, Unified will prefer the Assume Role flow over static credentials. 3. After verifying that secrets are being read and written correctly, you can remove the static AWS Key and AWS Secret from your workspace settings. 4. Revoke or delete the old IAM user credentials in your AWS account. ## Security Best Practices - **Use a unique External ID per workspace.** This prevents cross-workspace role assumption. - **Scope the permissions policy narrowly.** Restrict the `Resource` to only the secret prefixes Unified needs. - **Rotate the External ID periodically.** Update both the IAM trust policy and the Unified dashboard when you do. - **Enable CloudTrail logging.** Monitor `AssumeRole` events in your AWS account to audit access. - **Do not share the External ID publicly.** Treat it as a sensitive configuration value. ## How to setup Oracle JD Edwards URL: https://docs.unified.to/guides/how_to_setup_oracle_jd_edwards # How to setup Oracle JD Edwards ------ _August 17, 2026_ # Connect JD Edwards to [Unified.to](https://unified.to/) **Administrator guide** — How to configure your JD Edwards EnterpriseOne environment so [Unified.to](https://unified.to/) can securely read data through the Application Interface Services (AIS) Server REST API. > **Audience:** JDE / CNC administrator · **Applies to:** EnterpriseOne 9.2 · **Access level:** Read-only --- ## Overview [Unified.to](https://unified.to/) connects to JD Edwards the same way any external REST client does — through the **Application Interface Services (AIS) Server**, the lightweight JSON-over-REST gateway that ships with EnterpriseOne. **Nothing is installed inside your JD Edwards environment.** [Unified.to](https://unified.to/) makes outbound HTTPS calls to your AIS Server, signs in with a JD Edwards user account you provide, and reads data using the standard AIS services. Every request is authenticated by EnterpriseOne and constrained by that user's security. This guide walks a JDE or CNC administrator through the one-time setup: confirming the AIS Server, securing it, creating a dedicated service account, granting the right access, opening the network path, and verifying the connection before handing the sign-in details to [Unified.to](https://unified.to/). ```plain text Unified.to ──HTTPS──▶ AIS Server (/jderest) ──E1 auth──▶ EnterpriseOne (external client) (token & data services) (apps, security, data) ``` --- ## Before you begin - [ ] **An AIS Server is deployed and running.** AIS is managed in Server Manager as part of your EnterpriseOne Tools installation. - [ ] **EnterpriseOne Tools Release 9.2.4 or later.** This is the minimum release documented for the AIS Server REST API. - [ ] **The AIS Server is configured with a Security Server.** From Tools 9.2.2.2 onward, an AIS Server must be associated with an EnterpriseOne Enterprise (Security) Server. - [ ] **You can create users and assign roles** in the EnterpriseOne security tools, and you can reach Server Manager. - [ ] **You know which environment and role** the integration should sign in to (for example a reporting or production environment). --- ## Step 1 — Confirm the AIS Server and note its URL In Server Manager, open your AIS Server instance and confirm it is started. AIS exposes its services under a base URL of the form: ```plain text https://:/jderest ``` Record this base URL — [Unified.to](https://unified.to/) needs it. Individual services live beneath it (for example the token request and data services), so the host and port are all you supply. > **Note:** If AIS sits behind a reverse proxy or load balancer, use the externally reachable URL that terminates TLS, not the internal application-server address. --- ## Step 2 — Require HTTPS Configure the AIS Server (or the proxy in front of it) to use SSL/TLS so all traffic is over HTTPS. The AIS Server supports SSL configuration through Server Manager and the application server it runs on. - Install a certificate signed by a trusted public authority so [Unified.to](https://unified.to/) can validate it. - Avoid self-signed certificates for production; they cause TLS validation failures for external clients. > **⚠ Important:** Credentials are sent on every sign-in. Do not expose the AIS Server over plain HTTP. --- ## Step 3 — Enable the authentication method in AIS security settings In Server Manager, open the AIS Server's **Application Interface Services Security Settings**. The login type used by the client must be enabled there for that client to authenticate. Enable standard EnterpriseOne username/password authentication. Confirm the AIS Server's Security Server host and port are defined so it can validate sign-ins against EnterpriseOne. > **Note:** These settings are shared by all AIS clients. Changing them affects every application that uses this AIS Server, so review them with your CNC team before saving. --- ## Step 4 — Create a dedicated service account Create a JD Edwards EnterpriseOne user reserved for this integration rather than reusing a person's login. A dedicated account keeps the integration's activity auditable and easy to revoke. - Assign it a role scoped to **read/inquiry access** for the data the integration should see. - Set the default **environment** and **role** it signs in with, or plan to supply them explicitly. - Apply your standard password policy; use a strong, unique password. > **✓ Best practice:** Because [Unified.to](https://unified.to/) reads data, a role with inquiry-level access is sufficient. Grant no more than the integration needs. --- ## Step 5 — Grant the account access Two layers govern what the service account can do — make sure both allow the data you intend to share: - **AIS access.** Per your Tools release, grant the user access to the AIS Server — for example by adding it to the AIS user allowlist or assigning the appropriate AIS role — so it is permitted to authenticate as an AIS client. - **EnterpriseOne security.** AIS requests honor EnterpriseOne security. Use Security Workbench to ensure the user's role has access to the applications and data the integration reads, and review any **row** and **column** security that would hide records or fields. > **Note:** If the integration returns fewer records or fields than expected, security on the role is the usual cause — the account can only ever see what EnterpriseOne security permits. --- ## Step 6 — Open the network path [Unified.to](https://unified.to/)'s servers must be able to reach your AIS Server over HTTPS. - Allow inbound HTTPS to the AIS host/port through your firewall — restricted to [Unified.to](https://unified.to/)'s egress IP addresses listed in the [appendix](https://app.notion.com/p/unified-to/bf8154eb04f84537b972c6170fb94a79?v=e7df13bafc244e839a8b60b15ec0f96d&p=3bf6207b838480c78091fc0cb6d883de&pm=s#appendix--unifiedto-egress-ip-addresses) below. - If you expose AIS through a gateway or DMZ proxy, forward the `/jderest` path to the AIS Server. - The AIS Server's **allowed origins** (CORS) setting governs browser-based callers. Server-to-server calls are not subject to browser CORS, but confirm no other network policy blocks the caller. --- ## Step 7 — Review the session timeout When a client signs in, the AIS Server issues a session token that expires after an idle period you control in Server Manager. The default is short; keep it reasonable so routine syncs don't expire mid-request, while still meeting your security policy. > **Note:** [Unified.to](https://unified.to/) re-authenticates automatically when a token expires, so no coordination on exact timing is required — this is just to avoid an unusually aggressive timeout. --- ## Step 8 — Verify the connection From a machine that can reach the AIS Server, request a token with the service account. A successful response confirms the URL, TLS, security settings, and account are all working together: ```bash # Request an AIS session token curl -X POST -H "Content-Type: application/json" \ https://:/jderest/v2/tokenrequest \ -d '{ "username": "", "password": "", "environment": "", "role": "", "deviceName": "unifiedto" }' ``` A JSON response containing user information and a token means the account can authenticate. An error points to the layer to revisit — see the table below. --- ## Troubleshooting | Symptom | Likely cause | Where to look | | ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------- | | Connection refused / timeout | Network path or TLS not reachable from outside. | Firewall rules, proxy, AIS host/port (Step 6), HTTPS (Step 2). | | Certificate error | Self-signed or untrusted certificate. | Install a publicly trusted certificate (Step 2). | | 401 Unauthorized | Bad credentials, or login type not enabled. | Service account and AIS security settings (Steps 3–4). | | 403 / access denied | User not permitted as an AIS client. | AIS access / allowlist or AIS role (Step 5). | | Signs in, no data | EnterpriseOne security hides the records or fields. | Security Workbench: application, row, and column security (Step 5). | | Works, then fails later | Session token expired. | AIS session timeout (Step 7). | --- ## Hand off to [Unified.to](https://unified.to/) Once the token test succeeds, provide these values to [Unified.to](https://unified.to/) when creating the connection. Share the password through a secure channel. | Field | Value | | ------------------ | --------------------------------------------------------------------------------- | | **AIS Server URL** | The base HTTPS URL, e.g. `https://jde.example.com:9302` | | **Username** | The dedicated service account you created | | **Password** | That account's password | | **Environment** | _Optional_ — the environment to sign in to (defaults to the AIS Server's setting) | | **Role** | _Optional_ — the role to sign in with (defaults to the AIS Server's setting) | --- ## References - [Managing the AIS Server through Server Manager](https://docs.oracle.com/en/applications/jd-edwards/interoperability/9.2.x/eoiis/managing-the-ais-server-through-server-manager.html) - [Application Interface Services (AIS) Server and AIS Client Security](https://docs.oracle.com/en/applications/jd-edwards/administration/9.2.x/eotsc/application-interface-services-ais-server-and-ais-client.html) - [REST API for the JD Edwards EnterpriseOne AIS Server — Authentication](https://docs.oracle.com/en/applications/jd-edwards/cross-product/9.2/rest-api/Authentication.html) - [REST API for the JD Edwards EnterpriseOne AIS Server — Quick Start](https://docs.oracle.com/en/applications/jd-edwards/cross-product/9.2/rest-api/QuickStart.html) - [JD Edwards EnterpriseOne Tools: Application Interface Services Server Reference Guide](https://docs.oracle.com/en/applications/jd-edwards/interoperability/9.2.x/eoiis/) --- ## Appendix — [Unified.to](https://unified.to/) egress IP addresses [Unified.to](https://unified.to/) calls your AIS Server from the fixed IP addresses below. Allow inbound HTTPS from these addresses to the AIS host/port in your firewall or reverse proxy (Step 6). Allowlisting all three keeps the connection working regardless of which [Unified.to](https://unified.to/) region serves it. ```plain text 44.199.69.244 3.65.142.239 13.239.151.208 ``` | IP address | Region | | ---------------- | ------------------------ | | `44.199.69.244` | United States | | `3.65.142.239` | European Union | | `13.239.151.208` | Australia / Asia-Pacific | > **Note:** Allow these addresses **inbound** to your AIS Server. They are the source addresses of [Unified.to](https://unified.to/)'s outbound calls — no changes to your own outbound firewall rules are required. If your [Unified.to](https://unified.to/) connection uses a single region, you may allowlist only that region's address, but permitting all three avoids breakage if the serving region changes. --- _This guide describes standard JD Edwards EnterpriseOne administration using Oracle's published documentation. Exact menu labels and available settings vary by Tools Release; consult the references above for your version. Verify each step in a non-production environment first._ ## How to Setup Quickbooks Desktop URL: https://docs.unified.to/guides/how_to_setup_quickbooks_desktop # How to Setup Quickbooks Desktop ------ _April 2, 2026_ Quickbooks Desktop/Enterprise is a desktop application and not a web/online application, and thus needs additional setup to enable API access. 1. [Download QBWC](https://quickbooks.intuit.com/learn-support/en-ca/help-article/install-products/set-quickbooks-web-connector/L4Vp7VI44_CA_en_CA) (Web Connector) from Quickbooks and install the program on the same computer where your Quickbooks Desktop is running. 2. Activate Quickbooks Desktop Integration in your workspace ![Screenshot_2026-04-02_at_10.37.30_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_quickbooks_desktop-0.png) 3. Create a Quickbooks Desktop connection within Unified (enter your username and password you will use in the QBWC) ![Screenshot_2026-04-02_at_10.39.37_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_quickbooks_desktop-1.png) 4. Create a .qwc file, it should be in the following format: ```javascript Unified https://api.unified.to/webhook/workspace/quickbooksdesktop?workspace_id=your_workspace_id&connection_id= your_quickbooks_desktop_connection_id A short description for WCWebService1 https://unified.ngrok.dev/support your_username {57F3B9B1-86F1-4fcc-B1EE-566DE1813D21} {90A44FB5-33D9-4815-AC85-BC87A7E7D1EZ} QBFS number_of_minutes_between_each_sync_or_remove_this_if_you_want_to_sync_manually ``` 5. Once the QBWC has installed, first open your Quickbooks Desktop with the company you wish to sync 6. Then open QBWC and click 'Add an application', QBWC will then prompt you to select a file, select the newly created .qwc file from above. ![Screenshot_2026-04-02_at_10.41.19_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_quickbooks_desktop-2.png) 7. Click 'Ok' to grant access ![Screenshot_2026-04-02_at_10.43.58_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_quickbooks_desktop-3.png) 8. Select 'Yes, whenever my Quickbooks company file is open' and 'Continue' ![aaplication_permission_%281%29.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_quickbooks_desktop-4.png) 9. 'Confirm' ![confirm_%281%29.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_quickbooks_desktop-5.png) 10. QBWC will normally close after this, you can reopen it and you should now see the app is added. Enter the password and to perform your first sync click 'Update Selected'. ![app_added_%281%29.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_setup_quickbooks_desktop-6.png) ## How to setup Sage 100 with Unified.to URL: https://docs.unified.to/guides/how_to_setup_sage_100_with_unified # How to setup Sage 100 with Unified.to ------ _July 17, 2026_ # Connecting Sage 100 — How to get your API credentials This guide walks a Sage 100 administrator through everything needed to connect Sage 100 to Unified. Sage 100 is on-prem software, so the connection is made through **eBusiness Web Services**, a module that runs on your own Sage 100 server. When you finish, you will have the **four values** Unified asks for: 1. **Web Services URL** 2. **Company Code** 3. **Web Services Username** 4. **Web Services Password** **Who should do this:** a Sage 100 system administrator (or your Sage business partner). Several steps happen on the Windows server that hosts Sage 100 and in Internet Information Services (IIS), so server access is required. --- ## Before you start - Sage 100 (Advanced or Premium; MAS 90/200 lineage) installed and running. - Administrator access to the **Sage 100 server** and to **IIS** on that server. - The **eBusiness Web Services** module — it ships with Sage 100 but is installed separately from the Sage 100 installation program. If it is not installed yet, do that first (see Step 1). - The server must be **reachable from Unified over HTTPS**. Sage 100 typically lives inside a private network, so you will usually publish the endpoint through a reverse proxy, VPN, or secure tunnel and give it a valid TLS certificate. Coordinate this with your IT/network team. --- ## Step 1 — Install eBusiness Web Services (if not already installed) 1. Run the **Sage 100 installation program** on the server. 2. Choose to install **eBusiness Web Services** and complete the wizard. It installs a web application into IIS under a virtual directory named `ebusinesswebservices`. 3. After installation, open the **Sage 100 eBusiness Web Services** configuration utility on the server (installed alongside the module). You will use it in later steps. If eBusiness Web Services is already installed, skip to Step 2. --- ## Step 2 — Confirm the endpoint URL and that it is reachable The service exposes a single SOAP endpoint: ```plain text https:///ebusinesswebservices/masservice.svc ``` Verify it works by opening this URL in a browser **from a machine that can reach the server**: ```plain text https:///ebusinesswebservices/masservice.svc?wsdl ``` You should see the WSDL (an XML document). If it loads, the endpoint is live. - Replace `` with the public host name that Unified will use to reach the server. - Make sure the host name **matches the TLS certificate** on the site, and that the port (usually 443) is open to Unified. If the WSDL does not load, see **Troubleshooting** below. **This full URL is the "Web Services URL" value** you will enter in Unified. (Unified also accepts just the base `https:///ebusinesswebservices` and will add `/masservice.svc` for you.) --- ## Step 3 — Enable the company for web services and note the Company Code 1. In Sage 100, open **Library Master → Main → Company Maintenance**. 2. Select the company you want to connect. 3. Ensure the company is **enabled for web services** (eBusiness Web Services must be turned on for this company). If you are unsure where this setting is in your version, your Sage business partner can confirm it — the eBusiness Web Services guide requires the company to be enabled or every call returns _"Web services are not enabled for this company."_ 4. Note the **Company Code** — the short code (for example `ABC`) that identifies this company. **This is the "Company Code" value** you will enter in Unified. --- ## Step 4 — Create (or designate) a web-services user Unified authenticates as a Sage 100 user on every call, so create a dedicated user for the integration. 1. In Sage 100, open **Library Master → Main → User Maintenance**. 2. Create a new user (recommended: a dedicated integration account, e.g. `WEBSVC`) or choose an existing one, and set a strong **password**. 3. Ensure the user is **enabled for web services**. If a user is not web-services-enabled, calls fail with _"Web services are not enabled for the system."_ / an authorization error. 4. Make sure the account is not locked. A locked account returns _"This user account has been locked."_ **The user name and password are the "Web Services Username" and "Web Services Password"** you will enter in Unified. ### Give the user the right permissions (roles) The web-services user must belong to a **role** (Library Master → Main → Role Maintenance) that grants the tasks the integration uses. For the Customer and Sales Order features Unified supports, enable these tasks on the role's **Tasks** tab: | Feature (Unified) | Sage 100 task to allow | | ------------------- | -------------------------------------------------------------- | | Read customers | Accounts Receivable → Main → Customer Maintenance → **View** | | Update customers | Accounts Receivable → Main → Customer Maintenance → **Modify** | | Delete customers | Accounts Receivable → Main → Customer Maintenance → **Remove** | | Read sales orders | Sales Order → Main → Sales Order Entry → **View** | | Create sales orders | Sales Order → Main → Sales Order Entry → **Create** | | Update sales orders | Sales Order → Main → Sales Order Entry → **Modify** | | Delete sales orders | Sales Order → Main → Sales Order Entry → **Remove** | Grant only what you need. If a task is not allowed, the matching Unified operation returns an authorization error. --- ## Step 5 — (Optional) Expose extra fields (UDFs) By default, User-Defined Fields (UDFs) and Development Partner fields are **not** returned. If you need them, open the **Sage 100 eBusiness Web Services** configuration utility → **Advanced Settings** → **Additional Fields** tab and add the fields you want to expose. This step is optional and only needed for custom fields. --- ## Step 6 — Enter the values into Unified In the Sage 100 connection form in Unified, provide: | Field | Value | | ------------------------- | ----------------------------------------------------------- | | **Web Services URL** | `https:///ebusinesswebservices/masservice.svc` | | **Company Code** | e.g. `ABC` | | **Web Services Username** | the user from Step 4 | | **Web Services Password** | that user's password | Save the connection. Unified will begin talking to your Sage 100 server directly over HTTPS. --- ## What Unified can and cannot do with Sage 100 eBusiness Web Services is a focused API. It is important to set expectations: - **Supported:** looking up, updating, and removing **Customers**; and creating, reading, updating, and removing **Sales Orders**. - **Not available:** the API has **no "list everything" operation** — records are fetched one at a time by their key (customer number, or sales order number). There is also **no API** for invoices, inventory items, vendors, purchase orders, or general-ledger accounts, so those are not available through this connection. --- ## Troubleshooting **The WSDL will not load (wrong host name).** IIS uses the server's full computer name in the WSDL's internal URLs. If that name does not match your TLS certificate or is not reachable from outside, the WSDL fails to download. Fix the server's full computer name, or set SSL host headers in IIS so the correct host name is used. **"Web services are not enabled for the system / this company."** The system and/or the specific company is not enabled for web services (Steps 3–4). Enable them. **Authorization errors on specific operations.** The web-services user's role is missing the required task permission (Step 4). Add it. **"This user account has been locked."** Unlock the account in User Maintenance. **Connectivity/timeout errors.** Confirm the server is reachable from the internet on HTTPS and that any proxy/tunnel is up. --- ## References (official Sage documentation) - Sage 100 Web Services Guide (2024, PDF): [https://docs.sage.com/docs/en/customer/100erp/2024/open/WebServices.pdf](https://docs.sage.com/docs/en/customer/100erp/2024/open/WebServices.pdf) - Programming with eBusiness Web Services: [https://help-sage100.na.sage.com/WebServicesGuide/2025/Content/WebServices/Programming.htm](https://help-sage100.na.sage.com/WebServicesGuide/2025/Content/WebServices/Programming.htm) - API reference (operations): [https://help-sage100.na.sage.com/WebServicesGuide/2025/Content/WebServices/APIs.htm](https://help-sage100.na.sage.com/WebServicesGuide/2025/Content/WebServices/APIs.htm) ## How to troubleshoot unhealthy connections URL: https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections # How to troubleshoot unhealthy connections ------ _September 25, 2024_ Connections allow your application to securely access data from third-party API platforms. They serve as links between your Unified.to workspace, your customers' data, and your app. Whether you're encountering issues during connection creation or debugging an existing connection that has become unhealthy, this guide will help you identify and resolve the problem. We'll walk you through how to interpret error messages, check connection status, and implement solutions to get your integrations running smoothly again. ## Before you begin This guide assumes you have a basic understanding of: - [Scopes](https://docs.unified.to/concepts/scopes) ## Connection lifecycle Understanding the connection lifecycle can help in troubleshooting: 1. Your customer selects an integration from your app to authorize. 2. Your customer is directed to the authorization page for that integration. 3. Unified.to determines what to display on the page (e.g. OAuth vs API token) 4. Your customer grants access to their account. 5. Unified.to's server creates a connection in memory and attempts to test it immediately. 1. If tests pass, the connection is created in our database and your customer is redirected to the success URL you specified (defaults to the page they were on when they started the auth flow). 2. If tests fail, the customer is redirected to the failure URL you specified with error details in the query parameters. See the below section: **Handle errors during connection creation** When a connection later stops working, your customer can go through the same flow again to **re-authorize the existing connection**. The connection keeps its ID. See **Re-authorize an unhealthy connection** below. ## Understanding connection health Connections in Unified.to have different health statuses based on recent credential and access checks: - **Healthy**: A recent API call or OAuth token refresh succeeded. - **Unhealthy**: The integration rejected the connection's credentials or access, and it has not successfully completed a call or token refresh since. - **Unhealthy now**: A recent API call or token refresh showed that the connection's credentials or access are no longer accepted, after previous successful activity. - **New**: The connection has been created but has not yet been used. Unified marks a connection unhealthy when a integration no longer accepts its credentials or access. This may mean you need to review permission scopes and re-authorize the connection. Other errors do not change connection health. This includes request-validation errors (`400`, `422`), missing records (`404`), rate limits (`429`), and temporary integration errors (`5xx`). A connection can represent multiple objects and endpoints, so these errors may be specific to a request rather than an indication that the connection needs to be re-authorized. ## Handle errors during connection creation When a connection fails to be created, we redirect users to the failure URL (this defaults to the page where the auth flow was initiated). The redirect URL includes two query parameters: - `error`: An error message - `log_id`: The ID of the API call log entry for this failure To properly handle these errors in your application: 1. **Review the API call log:** - Go to [**API Call Logs**](https://app.unified.to/logs) in your Unified.to dashboard - Look up the log entry using the `log_id` from the URL - Examine the error message and status code to understand what went wrong 2. **Implement error handling in your app** 1. For example, you may want to display a friendly message to your users, log the error in your monitoring tool, or invite them to contact support Review the section below, 'Troubleshooting by connection error codes', for a detailed walkthrough of the possible error codes and how to handle them. ## Handle errors after a connection is created When a connection is created successfully but becomes unhealthy later on, the first place to look for debugging information is in the API call logs. ### 1. Check the connection status 1. Navigate to the [**Connections**](https://app.unified.to/connections) page 2. Look for connections marked as **Unhealthy** or **Unhealthy now**. ### 2. Review API call logs 1. Go to [**API Call Logs**](https://app.unified.to/logs). 2. Filter the logs by the connection ID of the unhealthy connection (retention depends on your plan; see [How long are logs retained](https://docs.unified.to/guides/how_long_are_logs_retained)). 3. Look for recent credential or access errors, especially status codes `401`-`403`. 4. Click on an error log to view details about the failed API call. You can also use the Unified API to retrieve a list of your connections and API call logs. Pass the `connection_id` to the API calls endpoint to filter by that connection. **API reference:** [List all connections](https://docs.unified.to/unified/connection/List_all_connections), [List all API calls](https://docs.unified.to/unified/apicall/Returns_API_Calls) ### 3. Re-authorize an unhealthy connection Once you've fixed the cause (for example, restored your app's access or corrected scopes), ask your customer to re-authorize the **existing** connection rather than creating a new one. Re-authorizing keeps the same connection ID, so the connection IDs you've stored, your webhooks, and the connection's `external_xref` keep working. 1. From your backend, create a re-authorization token with your API key: ```javascript POST Authorization: bearer {YOUR_API_KEY} // Response { "reauth_token": "...", "expires_at": "..." } ``` 2. Send your customer to the usual authorization URL, adding `connection_id` and `reauth_token`: ```javascript {WORKSPACE_ID}/{INTEGRATION e.g. hubspot}?redirect=true &connection_id={CONNECTION_ID}&reauth_token={REAUTH_TOKEN} &success_redirect={SUCCESS_URL}&failure_redirect={FAILURE_URL} ``` 3. Your customer signs in again. We test the new credentials the same way as when the connection was created. 4. On success, the connection is updated in place and your customer is redirected to the success URL with the **same** `id`. You'll receive `CONNECTION_UPDATED` (and `CONNECTION_HEALTHY` if the connection was unhealthy) instead of `CONNECTION_CREATED`. Re-authorizing without a `scopes` parameter asks for the same permissions the connection already has, and keeps its permissions and categories unchanged. To change them (for example, to add a scope that was missing), include `scopes` on the authorization URL with the full list you need. The token is single-use and expires after 30 minutes, so create it right before you send your customer to the authorization URL. If the token is missing, expired, or already used, the failure URL receives `error=authorization error: invalid or expired re-authorization link`; create a new token and try again. [Learn more about re-authorizing connections](https://docs.unified.to/concepts/embedded-components#re-authorize-an-existing-connection) ## Troubleshooting by connection error codes ### Authentication (401) A 401 error is likely indicative that the connection's credentials are no longer valid and the connection needs to be re-authorized. **Possible causes:** - Your app's access to the integration has been revoked. - In non-OAuth flows that ask your customers to input an API key or other information, they may have input the wrong values. **Solutions:** - Double check that your app still has access to the integration's API. - Ensure that your customers are entering the correct values during the authorization flow. - After doing the above, [re-authorize the connection](https://app.notion.com/p/unified-to/How-to-troubleshoot-unhealthy-connections-10c6207b838480ae8856d12205da8598#3-re-authorize-an-unhealthy-connection) to refresh the authentication. - If the issue persists for specific endpoints, contact Unified.to support for assistance. ### Permissions (403) A 403 error is likely indicative that one or more data objects is not working as expected, usually due to misconfigured [scopes](https://docs.unified.to/concepts/scopes). **Possible causes:** - Mismatch between the scopes that the integration expects and what [Unified.to](https://unified.to/) is requesting. This can happen when: - Scopes have been changed or disabled on the integration's end i.e. in your developer account, or - The proper scopes are not being requested when setting up the [Authorization embedded component](https://app.unified.to/settings/embed?tab=Authorization) or when generating an [authorization URL](https://docs.unified.to/unified/integration/Authorize_or_re-authorize_a_connection). **Solutions:** 1. Double check your scope settings in your developer account 1. Log into your developer account for the API you're trying to access. 2. Navigate to the settings page where your app's authorization options are found. 3. Ensure that all necessary scopes are enabled (see note below on different integrations and how they handle scopes) 4. Make a note of the scopes that are enabled. 2. Double check your scope settings on Unified.to If you are using our embedded components to create connections, you'll need to ensure that the scopes on [Unified.to](https://unified.to/) match the ones you enabled in your developer account. 1. Navigate to the [Embedded components](https://app.unified.to/embed?tab=Authorization) page 2. Click **Permission Scopes** 3. Select the scopes that match the scopes you enabled in the developer account (see note below on mappings) 4. (Optional, only if you are using webhooks) Select the **webhook** scope 5. If the issue still persists, contact Unified.to support for assistance If you are generating an auth URL to redirect your customers to our auth flow, include `scopes` as a query parameter and list all the Unified scopes you need. Behind the scenes, [Unified.to](https://unified.to/) will map the Unified scopes you pass to the integration's scopes (see note below on mappings). For example: ```javascript https://api.unified.to/unified/integration/auth/ {WORKSPACE_ID}/{INTEGRATION e.g. hubspot}?redirect=true &scopes=webhook,{YOUR_SCOPES e.g. crm_deal_read, crm_event_read} ``` **API reference:** [Create an authorization URL](https://docs.unified.to/unified/integration/Authorize_or_re-authorize_a_connection) After addressing these issues, [re-authorize the connection](https://app.notion.com/p/unified-to/How-to-troubleshoot-unhealthy-connections-10c6207b838480ae8856d12205da8598#3-re-authorize-an-unhealthy-connection) and include the full list of `scopes` you need on the authorization URL. Re-authorizing without `scopes` keeps the connection's current permissions, so a missing scope would stay missing. Re-create the webhook if needed, then try again. **A note on mappings:** A mapping of the Unified scopes to the integration's scopes can be found on the integration details page under the **OAuth 2 Credentials tab** e.g. [here](https://app.unified.to/integrations/hubspot?tab=oauth2) are the mappings for HubSpot scopes. **A note on integration-specific scopes:** Different integrations may handle scopes in different ways - sometimes you can request a subset of the scopes enabled in the developer account, other times you must request all the scopes that have been enabled, and in the case of Hubspot, scopes are divided into Required and Optional scopes. For these cases, we have written how-to guides on generating OAuth credentials and configuring scopes. Please consult the Unified.to docs for more information about these integrations. ### Not found (404) **Note:** For Enrichment category integrations, 404 errors are treated as successful responses. They indicate that no data was found for the provided input, not that the connection is broken. **Solution:** - Verify that the input data (e.g., email, company name) is correct. ### Rate limiting (429) 429 errors do not change connection health because they indicate the connection is active but temporarily limited. **Possible cause:** - Exceeding the API rate limits of the third-party service **Solutions:** - (Recommended) Use [webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks) to read data and Unified.to will take care of rate limiting for you. - Implement request throttling in your application. - Contact the third-party service to request increased rate limits. ### Server errors (500 series e.g. 502, 503, etc) 500 series errors do not change connection health because they usually indicate temporary issues with the third-party service. In cases where the issue is on our end, however, we will attempt to make that clear in the error message. **Possible causes:** - Temporary issues with the third-party service - Bugs **Solutions:** - Check the status page of the third-party service for any ongoing issues. - Retry the request after a short delay. - If the issue still persists or if you've discovered a bug, contact Unified.to support for assistance. Thank you! ### Not implemented (501) 501 errors do not change connection health. They indicate that a specific functionality is not implemented for the integration, not that the connection is broken. **Suggestion actions:** - Check the Unified.to documentation for the specific integration to understand its supported features. - If you believe the functionality should be supported, contact Unified.to support and we'd be happy to look into it. ## Monitor and receive updates about your connections To stay informed about changes to your connections: 1. Navigate to **Settings >** [**Workspace**](https://app.unified.to/settings/workspace). 2. Under **Notifications webhook URL**, enter the URL where you want to receive notifications (this should be an endpoint on your server that can receive POST requests). 3. Under **Notifications webhook events**, select events related to connections: - `CONNECTION_HEALTHY` - `CONNECTION_UNHEALTHY` - `CONNECTION_CREATED` - `CONNECTION_UPDATED` (also sent when a connection is re-authorized) - `CONNECTION_DELETED` 4. Implement a handler for these events in your app to monitor connection health proactively. Unified sends `CONNECTION_UNHEALTHY` when an integration rejects a connection's credentials or access. For API requests, the event description includes the integration's HTTP status when available, such as `HTTP 401`. If the issue continues, notifications are rate-limited to at most one per connection per hour. Unified sends `CONNECTION_HEALTHY` when a previously unhealthy connection successfully completes an API call or OAuth token refresh, or is re-authorized. Treat these events as signals to review credentials and permission scopes, then [re-authorize the connection](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections#3-re-authorize-an-unhealthy-connection) if needed. For example, when you receive `CONNECTION_UNHEALTHY`, show the affected customer a "Reconnect" button in your app that creates a re-authorization token and sends them through the authorization URL. Your handler should be idempotent because an unhealthy connection can continue to generate periodic notifications until it is fixed. ## Next steps - Join our [Discord community](https://discord.gg/2nsAPmbx) for support and updates. - If issues persist, [contact our support team](https://unified.to/contact) for further assistance. Remember, connection health is dynamic and can change after an authentication failure or a subsequent successful API call or OAuth token refresh. Not all errors indicate a broken connection. ## How to troubleshoot unhealthy webhooks URL: https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks # How to troubleshoot unhealthy webhooks ------ _September 4, 2024_ Webhooks deliver events about changes to the data you care about. When something goes wrong, however, we will mark those webhooks as unhealthy. This guide explains how to diagnose and troubleshoot unhealthy webhooks at [Unified.to](https://unified.to/). ## Before you begin This guide assumes you have a basic understanding of: - [Webhooks](https://docs.unified.to/reference/webhooks) and [virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks) - [Scopes](https://docs.unified.to/concepts/scopes) ## Diagnose the webhook The first place to look for debugging information is in the API call logs. ### 1. Check the webhook status 1. Log in to your [Unified.to account](https://app.unified.to/). 2. Navigate to [Webhooks](https://app.unified.to/connections). 3. Make note of the unhealthy webhook and its **connection ID**. ### 2. Review API call logs 1. Go to [**API Call Logs**](https://app.unified.to/logs). 2. Filter by the connection ID of the webhook in question. You should see a list of API calls attempted by the webhook (retention depends on your plan; see [How long are logs retained](https://docs.unified.to/guides/how_long_are_logs_retained)). 3. Click on the latest log entry that threw an error - the **status** column will display an error code. 4. Details about the API call will be shown. The **Description** field will detail the reason the webhook failed and the **Status** field will show the specific error code itself. For example: ![Screenshot_2024-09-05_at_1.33.44_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_troubleshoot_unhealthy_webhooks-0.png) You can also use the Unified API to retrieve a list of your webhooks and API call logs. **API reference:** [List all webhooks](https://docs.unified.to/unified/webhook/Returns_all_registered_webhooks), [List all API calls](https://docs.unified.to/unified/apicall/Returns_API_Calls) ## Troubleshooting webhook error codes ### Bad request (400) errors These errors indicate that something is wrong with the request - in this case, it is likely not a scope or authentication issue but a problem with the integration itself. Please [reach out to us](https://unified.to/contact) or let us know in our [Discord](https://discord.gg/2nsAPmbx) server. Thank you! ### Authentication (401) and permission (403) errors A 401 error may indicate that the connection's auth credentials were entered incorrectly, whether it be the API token, a secret, or another value depending on the integration. A 403 error is often indicative of scope mismatches with the API provider, with Unified.to, or both. Both of these errors are indicative of an issue with the connection itself, rather than the webhook. Please see: [How to troubleshoot unhealthy connections ](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections#troubleshooting-the-error-codes) ## Subscribe to the notifications webhook to be alerted when your webhooks fail To learn about any webhook errors as soon as they occur, we recommend that you subscribe to our **Notifications webhook** to be notified as soon as a webhook's status changes. 1. Navigate to **Settings** > [**Workspace**](https://app.unified.to/settings/workspace)**.** 2. Under **Notifications webhook URL**, enter the URL of the endpoint where you would like to receive notifications. 1. The payload that will be sent to this endpoint contains: ```javascript id: string // the id of the object or the integration's type nonce: number // random number sig: string // @deprecated; use sig256. base64(hmac-sha1(id + nonce + event, workspace.secret)) sig256: string // v1 (existing validators): base64(hmac-sha256(id + nonce + event, workspace.secret)) data: object // { id, event, description?, environment? } sig256v2: string // v2: HMAC-SHA256(workspace.secret, JSON.stringify(data) + String(nonce)) — same formula as object / data webhooks event: enum // the event that was triggered description?: string // optional longer description environment?: string // included for connection, webhook, or integration notifications ``` **Signature validation:** Existing integrations can keep verifying `sig256` with `id + nonce + event`; that formula is unchanged. To use the same check as object / data webhooks, verify `sig256v2` as HMAC-SHA256(workspace.secret, JSON.stringify(data) + String(nonce)). See [Best practices for signature validation](https://docs.unified.to/reference/webhooks) in the Webhooks reference. 3. Under **Notifications webhook events**, select the events you want to be alerted to e.g. `WEBHOOK_UNHEALTHY` 4. On your server, handle incoming notifications as you see fit e.g. set up logging, send a message to the engineering team, post on Slack or Discord, etc. ## Understanding why webhooks fail This section will go over why webhooks fail to provide a deeper understanding of how our webhooks work. ### Failed to subscribe Failing to subscribe happens during webhook creation and can be due to multiple reasons: the integration isn't available, insufficient permissions (i.e. scopes mismatch), or bugs. Note: This only applies to native webhooks. ### Failed to refresh token Before checking for changes, subscribing, or processing a native event, [Unified.to](https://unified.to/) refreshes the connection's auth token when needed. If the refresh fails after retries, the webhook is marked unhealthy so no calls run against an expired token. This applies to both virtual and native webhooks. In this case, it is likely an error with the connection itself. Double-check that the auth credentials for the connection are correct, that the scopes are set correctly (i.e. they haven't been revoked), and then recreate both the connection and its associated webhook. ### Failed to read Failing to read happens when we attempt to retrieve new data from the API provider and are prevented from doing so, whether due to configuration errors (i.e. 401 or 403 errors) or problems with the integration itself. Double-check that the auth credentials for the connection are correct, that the scopes are set correctly (i.e. they haven't been revoked), and then recreate both the connection and its associated webhook. ### Failed to dispatch data Failing to dispatch data happens when we attempt to send webhook data to your server but the server does not respond or responds with an error. Make sure your webhook URL is configured correctly when creating the webhook. Respond with a `2xx` status once your endpoint has accepted the data, and do heavy processing afterwards. Otherwise, retry behaviour depends on the webhook type. For virtual webhooks, we keep trying to POST to your server for up to approximately 2 weeks before marking the webhook as unhealthy. For native webhooks, delivery is attempted 3 times immediately; if those fail, the webhook is marked unhealthy and the event isn't redelivered unless the integration sends it again. Keep your endpoint reachable during deploys, or use a virtual webhook where longer recovery matters. After checking the above, recreate the webhook and try again. Monitoring webhook health and recreating unhealthy webhooks is your responsibility. [Unified.to](https://unified.to/) does not resume an unhealthy webhook on its own. Subscribe to the notifications webhook (above) for `WEBHOOK_UNHEALTHY` so you find out when it happens. ### Failed to process data Failing to process data indicates a problem with either the reading or dispatching steps. Refer to the troubleshooting steps above and then recreate the webhook and try again. ### A note on our webhook retry mechanism [Unified.to](https://unified.to/) retries failed webhook deliveries automatically. Here's how it works. **When your webhook endpoint is unavailable** 1. If your endpoint is unavailable or returns an error, we'll retry up to 3 times immediately, with a 1-second delay between attempts. 2. If the error persists, we back off progressively for up to about 2 weeks, maximizing the chance of successful delivery. The backoff schedule applies to virtual webhooks. Each delayed retry re-reads the page from the API provider rather than resending a stored payload, and the read position advances only after a successful delivery. If retries are exhausted, the webhook is marked unhealthy and scheduling stops. Native webhooks stop after the 3 immediate retries and are marked unhealthy. **When the API provider is unavailable** If we can't reach the API provider or have hit their rate limits, we will back off progressively and retry (outlined above). On virtual webhooks, we honour the provider's retry-after hints when it sends them. **Billing for failed and empty runs** You aren't billed for failed reads from the API provider, failed dispatches to your webhook endpoint, or virtual webhook polling runs that find no new data. ## See also - [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks) - [How to troubleshoot unhealthy connections](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections) - [Understanding virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks) ## How to use SCIM with the Unified API URL: https://docs.unified.to/guides/how_to_use_scim_with_the_unified_api # How to use SCIM with the Unified API ------ _October 3, 2024_ [Unified.to](https://unified.to/)'s SCIM API gives you SCIM 2.0 Users and Groups endpoints over its HR & Directory integrations, so a SCIM client can list, create, update and delete users in a customer's HR system or directory, including systems with no SCIM support of their own. This guide is for developers who already use SCIM (System for Cross-domain Identity Management, the RFC 7643 and RFC 7644 standard for user and group provisioning). If your product already provisions users over SCIM, you can reuse that client across more systems. ## Before you begin This guide assumes you already have a basic understanding of SCIM and its purpose in identity management. You can read more about it in the [official specification](https://scim.cloud/). ## What endpoints does [Unified.to](https://unified.to/)'s SCIM API provide? [Unified.to](https://unified.to/)'s SCIM endpoints are based on RFC 7644, and each is scoped to one connection: - **Users:** `POST /scim/{connection_id}/users` (create), `GET /scim/{connection_id}/users` (list), `GET /scim/{connection_id}/users/{id}` (get), `PUT` or `PATCH /scim/{connection_id}/users/{id}` (update), `DELETE /scim/{connection_id}/users/{id}` (delete) - **Groups:** the same operations on `/scim/{connection_id}/groups` List requests accept `filter`, `startIndex` and `count` for paging, and `sortBy` and `sortOrder`. Responses use the SCIM schemas; send `Accept: application/scim+json` for the SCIM media type. View the API references for: - [Users](https://docs.unified.to/scim/users/model) - [Groups](https://docs.unified.to/scim/groups/model) An existing SCIM client can call these endpoints with the connection ID in the path. Which operations work depends on the integration (see below). ## Which SCIM extensions does [Unified.to](https://unified.to/) support? In addition to the core SCIM schema, Unified.to supports the following extensions: ```plain text 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' 'urn:ietf:params:scim:schemas:extension:lattice:attributes:1.0:User' 'urn:ietf:params:scim:schemas:extension:peakon:2.0:User' ``` The enterprise extension carries employee number, cost centre, organization, division, department and manager; the Lattice and Peakon extensions carry the attributes those tools expect. ## Which integrations work with the SCIM API? Any HR & Directory integration that supports the Employee object works with [Unified.to](https://unified.to/)'s SCIM API, whether or not the vendor supports SCIM itself: [Unified.to](https://unified.to/) translates each SCIM call into that integration's own API. That covers most of [Unified.to](https://unified.to/)'s 698 [HR & Directory integrations](https://docs.unified.to/hris/integrations). Reads work wherever the integration lists employees; creates, updates and deletes work only where it writes them. As of 19 October 2026, Okta, Microsoft Entra ID, Google Workspace Directory, JumpCloud, Personio and Ceridian Dayforce create, update and remove employees; BambooHR and HiBob create and update them; Workday and [Remote.com](https://remote.com/) are read-only for employees. An operation an integration doesn't support returns 501. Each integration's Feature Support tab lists its methods. ## Should I use the SCIM API or the HR & Directory API? Use the SCIM API when your product already speaks SCIM and you want to create, update or remove accounts with it. Use the [HR & Directory API](https://docs.unified.to/hris/overview) when you need more than identity: employment status, start and end dates, managers, locations, time off or payroll. Both read the same employee records. ## How do I find out when users change? The SCIM API itself doesn't send webhooks. Subscribe to HR & Directory employee and group webhooks on supported integrations. They're native on integrations such as HiBob, Personio, Google Workspace Directory and Microsoft Entra ID, and come from [virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks) on integrations such as BambooHR, Workday and Okta, which check at an interval you set, as often as every minute on paid plans. The SCIM API is on every plan. It isn't available to enterprise-managed (`ema_`) tokens. Each request goes to the source system; [Unified.to](https://unified.to/) keeps no customer records at rest, and connection credentials and operational metadata are encrypted at rest. ## See also - [HR & Directory API overview](https://docs.unified.to/hris/overview) - [SCIM glossary entry](https://docs.unified.to/concepts/glossary/scim) - [SCIM API overview](https://docs.unified.to/scim/overview) - [Official SCIM specifications](https://datatracker.ietf.org/doc/html/rfc7644) ## How to use the Passthrough API URL: https://docs.unified.to/guides/how_to_use_the_passthrough_api # How to use the Passthrough API ------ _November 25, 2024_ This guide shows you how to make requests using Unified.to's Passthrough API with practical examples. ## Before you begin You should have: - At least one active connection for an integration - The connection ID for the integration you want to access ## Making Passthrough requests The following examples demonstrate calls to mock endpoints with the Passthrough API. **NOTE:** There is only one reserved URL parameter, and that is `__domain`. Use it to override the default API URL for that integration. ### Using the REST API directly ```javascript // GET request example const response = await fetch('https://api.unified.to/passthrough/{connection_id}/v2/customers', { method: 'GET', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' } }); // POST request example const response = await fetch('https://api.unified.to/passthrough/{connection_id}/v2/customers', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'John Doe', email: 'john@example.com' }) }); ``` ### Using the [Unified.to](https://unified.to/) SDK ```javascript // GET request const response = await unified.passthrough.listPassthroughs({ connectionId: 'YOUR_CONNECTION_ID', path: '/v2/customers' }); // POST request const response = await unified.passthrough.createPassthrough({ connectionId: 'YOUR_CONNECTION_ID', path: '/v2/customers', data: { name: 'John Doe', email: 'john@example.com' } }); // With custom headers const response = await unified.passthrough.listPassthroughs( { connectionId: 'YOUR_CONNECTION_ID', path: '/v2/customers' }, { fetchOptions: { headers: { 'x-api-version': '2023-06-01' } } } ); ``` ## Real-world examples ### Example 1: Fetching HubSpot properties This example demonstrates how to fetch custom properties for HubSpot contacts. ```javascript const response = await unified.passthrough.listPassthroughs({ connectionId: HUBSPOT_CONNECTION_ID, path: '/crm/v3/properties/{objectType}/batch/read' }); // Response will contain raw HubSpot property definitions console.log(response.data); ``` **API reference:** [Hubspot CRM Properties](https://developers.hubspot.com/beta-docs/reference/api/crm/properties#get-%2Fcrm%2Fv3%2Fproperties%2F%7Bobjecttype%7D) ### Example 2: Creating a Slack channel This example demonstrates creating a channel in Slack. ```javascript // Create a private Slack channel const response = await unified.passthrough.createPassthrough({ connectionId: SLACK_CONNECTION_ID, path: '/conversations.create', data: { name: 'project-discussion', is_private: true } }); // Check the response if (response.data.ok) { console.log('Channel created:', response.data.channel.id); } ``` **API reference**: [conversations.create](https://api.slack.com/methods/conversations.create) ### Example 3: Creating a Salesforce record This example demonstrates two ways of creating a new Account record in Salesforce using their sObject API. ```javascript // Using the SDK const response = await unified.passthrough.createPassthrough({ connectionId: SALESFORCE_CONNECTION_ID, // Note: path starts with 'services/data/v62.0' as required by Salesforce path: 'services/data/v62.0/sobjects/Account/', data: { "Name": "Express Logistics and Transport" } }); const response = await fetch('https://api.unified.to/passthrough/{connection_id}/services/data/v62.0/sobjects/Account/', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_UNIFIED_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ "Name": "Express Logistics and Transport" }) }); // The response will contain the new record ID if successful: // { // "id": "001D000000IqhSLIAZ", // "errors": [], // "success": true // } ``` **API reference:** [Create a Record](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_sobject_create.htm) ## See also - [Passthrough API Overview](https://docs.unified.to/passthrough/overview) ## How to Use Unified.to's Generative AI API with OpenAI and Claude URL: https://docs.unified.to/guides/how_to_use_unified_generative_ai_api_with_openai_and_claude # How to Use Unified.to's Generative AI API with OpenAI and Claude ------ _May 29, 2024_ ![Unified.to_-_Unified_GenAI_API.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-0.png) This guide shows you how to get started with [Unified.to](https://unified.to/)'s GenAI (generative AI) API, which gives you one model and API for large language models (LLMs) across 57 AI integrations, including: - [Anthropic Claude](https://www.anthropic.com/api) - [Anyscale](https://www.anyscale.com/platform) - [Google Gemini](https://ai.google.dev/) - [Groq](https://console.groq.com/docs/) - [Mistral AI](https://docs.mistral.ai/api/) - [OpenAI GPT](https://openai.com/api/) To illustrate its power, we will work through a sample application using your own GenAI API credentials, although Unified.to's use cases always target a SaaS company's customer accounts. To follow the steps in this guide, you'll need the following: - **A** [**Unified.to**](https://unified.to/) **account.** If you don't have one, start a [30-day free trial](https://app.unified.to/login) on Grow or Pro. - Accounts for the [OpenAI API](https://platform.openai.com/docs/overview) and the [Anthropic Claude API](https://www.anthropic.com/api). - Python (preferably version 3.10 or later). - [Jupyter Notebook](https://jupyter.org/) (included with the [Anaconda Python distribution](https://www.anaconda.com/download) or easily installed with the command `pip install notebook`. It's a great tool for experimenting with Python and exploring APIs and libraries. ## Activate the integrations The first step is to activate the Claude and OpenAI integrations. [Log in to Unified.to](https://app.unified.to/login) and navigate to the **Integrations** page by selecting **Integrations** → **Active Integrations** from the menu bar in the Unified.to dashboard: ![Initial_GenAI_integrations_page.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-1.png) Narrow down the integrations to only the generative AI ones by selecting **GENAI** from the **Category** menu. Click on the **Anthropic Claude** item, which will take you to its integration page: ![Integrate_Anthropic_Claude.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-2.png) Activate the Claude integration by clicking the **ACTIVATE** button. This will return you to the **Integrations** page, where you'll see that the **Anthropic Claude** item is now marked 'active:' ![Updated_GenAI_integrations_page.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-3.png) Now click on the **OpenAI** item, which will take you to its integration page: ![Integrate_OpenAI.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-4.png) Activate the OpenAI integration by clicking the **ACTIVATE** button. Once again, you'll return to the **Integrations** page, where you'll see that the **OpenAI** item is also marked 'active:' ![Final_GenAI_integrations_page.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-5.png) ## Create the connections and copy the connection IDs The next step is to create connections for Claude and OpenAI. Navigate to the **Embedded Authorization** page by selecting **Settings** → **Embedded Authorizations** from the menu bar: ![Embedded_authorization_page_1.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-6.png) First, create a Claude connection by clicking the **Anthropic Claude** item in the **End-User Preview.** When you arrive at the Claude authorization page, paste your Claude API key into the text field provided, then click the **Authorize** button: ![Paste_Anthropic_API_token.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-7.png) This will create a Claude connection and send you to the **Connections** page, where you'll see a list of your connections, with the newly-created Claude connection at the top: ![New_Claude_connection.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-8.png) Copy the ID value for the Claude connection you just created; you'll need it after creating the connections. Return to the **Embedded Authorization** page (**Settings** → **Embedded Authorizations**)… ![Embedded_authorization_page_2.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-9.png) …then create an OpenAI connection by clicking the **OpenAI** item in the **End-User Preview.** You'll repeat the same steps you took for the Claude connection, where you'll paste your OpenAI API key into the text field provided and click **Authorize** to create the connection: ![Paste_OpenAI_API_token.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-10.png) This will create an OpenAI connection and once again, you will be sent to the **Connections** page, where you'll see the newly-created OpenAI connection at the top of the list: ![New_OpenAI_connection.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-11.png) As you did with the Claude connection, copy the ID value for the new OpenAI connection. ## Copy your Unified.to API token In order to access the [Unified.to](https://unified.to/) API, you need the Unified.to API token for your account. You can copy this value from the **API Information** page, which you can navigate to by selecting Settings → API Information from the Unified.to menu bar: ![settings_page.png](https://s3.us-east-2.amazonaws.com/unified-article-images/how_to_use_unified_generative_ai_api_with_openai_and_claude-12.png) ## Create an .env file with your Unified.to API token and generative AI connection IDs While it would be simplest to hard-code your Unified.to API token and the IDs for your generative AI connections, it doesn't take significantly more work to put these values into a .env (environment variable) file. By putting these sensitive values into their own file and separate from the code, you reduce the risk of accidentally exposing sensitive data when sharing code or checking your code into version control. Create a file named **.env** in a new directory with the following content: ```plain text # .env file UNIFIED_API_TOKEN={Paste your Unified.to API token here} OPENAI_CONNECTION_ID={Paste your OpenAI connection ID here} CLAUDE_CONNECTION_ID={Paste your Claude connection ID here} ``` Replace the text in `{` braces `}` with the appropriate values. ## Install the Unified.to Python SDK package While it's possible to call the unified API by using the requests library and assembling the headers yourself, your code will be much simpler and more readable if you use Unified.to's Python SDK package. [You can find its code on GitHub](https://github.com/unified-to/unified-python-sdk), and you can install it with `pip`, the Python package installer, using the following command: ```shell pip install Unified-python-sdk ``` ## Chat with Claude and OpenAI via Unified.to's unified API Now that all the preliminary work is done, you can start coding. You have a couple of options at this point: - You can create a new Jupyter notebook and start entering code into it, adding each bit of code presented below into its own cell, or - you can create a new Python file, adding each bit of code presented below into the file. **Define constants and the API object** Create a new Jupyter Notebook in the directory where you saved the **.env** file and enter the following into a new cell. This code defines the constants that hold your Unified.to API key and your connection IDs as well as the object for calling the unified API: ```python import os import unified_to from unified_to.models import operations, shared # Load the contents of the .env file %reload_ext dotenv %dotenv UNIFIED_API_TOKEN = os.environ.get('UNIFIED_API_TOKEN') OPENAI_CONNECTION_ID = os.environ.get('OPENAI_CONNECTION_ID') CLAUDE_CONNECTION_ID = os.environ.get('CLAUDE_CONNECTION_ID') api = unified_to.UnifiedTo( security=shared.Security( jwt=os.environ.get('UNIFIED_API_TOKEN'), ), ) ``` You'll use the `api` object to send requests to and receive responses from the Unified.to API. **Create a prompt and send it to Claude** Enter the following into its own Jupyter notebook cell. This code builds a prompt and sends it to Claude, then displays the resulting response: ```python my_request = operations.CreateGenaiPromptRequest( connection_id = CLAUDE_CONNECTION_ID, genai_prompt = { 'messages' : [ { 'role': 'user', 'content': "Which LLM model am I talking to right now?" } ] } ) response = api.genai.create_genai_prompt(request=my_request) if response.genai_prompt is not None: print(response) else: print("No response.") ``` - The `my_request` variable is assigned a request object using the `CreateGenaiPromptRequest()` method of the operations class, which contains methods for creating requests and responses for the Unified API. - `CreateGenaiPromptRequest()` takes two arguments: - `connection_id`: The ID of the connection for the generative AI that will receive the prompt - `genai_prompt`: A dictionary containing the parameters defining the prompt to be sent to the generative AI - At the very least, the `genai_prompt` dictionary must contain a key named `'messages'`. The corresponding value must be an array of dictionaries with the following keys: - `'role'`: The corresponding value can be either `'user'`, which means that the message is from the human interacting with the AI, or `'system'`, which means that the message is meant as instructions for the AI. - `'content'`: The corresponding value is the actual content of the message. If the `'role'` value is `'user'`, this value is the text of the _user's_ message to the AI. If the `'role'` value is `'system'`, this value is the text of the _application's_ message to the AI. - The response variable gets the AI's response using the `genai.create_genai_prompt()` method, which takes the request contained in `my_request`, sends it to Unified.to, and returns Unified.to's response. Run the cell. You should get output that looks like this (the output below has been formatted with line breaks to make it easier to read): ```python CreateGenaiPromptResponse( content_type='application/json; charset=utf-8', status_code=200, raw_response=, genai_prompt=GenaiPrompt( max_tokens=None, messages=None, model_id=None, raw=None, responses=['I am an AI assistant called Claude. I was created by Anthropic, PBC to be helpful, harmless, and honest.'], temperature=None ) ) ``` To get only the responses, access the responses property of the GenAiPrompt object. Here's a quick example: ```python print(response.genai_prompt.responses) ``` You should a result similar to this: ```python ['I am an AI assistant called Claude. I was created by Anthropic, PBC to be helpful, harmless, and honest.'] ``` **Create a prompt and send it to OpenAI** With a single change, you can send a message to OpenAI instead of Claude. Copy the code from the previous cell, paste it into a new cell and change this line… ```python connection_id = CLAUDE_CONNECTION_ID, ``` …to this: ```python connection_id = OPENAI_CONNECTION_ID, ``` The new cell should now look like this: ```python request = operations.CreateGenaiPromptRequest( connection_id = OPENAI_CONNECTION_ID, genai_prompt = { 'messages' : [ { 'role': 'user', 'content': "Which LLM model am I talking to right now?" } ] } ) response = api.genai.create_genai_prompt(request) if response.genai_prompt is not None: print(response) else: print("No response.") ``` Run the cell. You should get output that looks like this (the output below has been formatted with line breaks to make it easier to read): ```python CreateGenaiPromptResponse( content_type='application/json; charset=utf-8', status_code=200, raw_response=, genai_prompt=GenaiPrompt( max_tokens=None, messages=None, model_id=None, raw=None, responses=["As an AI developed by OpenAI, I don't have a specific LLM model version. I'm based on GPT-3, a language prediction model."], temperature=None ) ) ``` As with the Claude version, you can get only the responses with code like this… ```python print(response.genai_prompt.responses) ``` …which will produce results like this: ```python ["As an AI developed by OpenAI, I don't have a specific LLM model version. I'm based on GPT-3, a language prediction model."] ``` **Add a system prompt for OpenAI** Copy the cell above (the one that sent a prompt to OpenAI) and update the code as shown below so that the `'messages'` array contains a system prompt: ```python request = operations.CreateGenaiPromptRequest( connection_id = OPENAI_CONNECTION_ID, genai_prompt = { 'messages' : [ # New code 👇 { 'role': 'system', 'content': "Provide answers as if you were a carnival barker." }, # New code 👆 { 'role': 'user', 'content': "Which LLM model am I talking to right now?" } ] } ) response = api.genai.create_genai_prompt(request=request) if response.genai_prompt is not None: print(response) else: print("No response.") ``` Run the cell. You should get output that looks like this (the output below has been formatted with line breaks to make it easier to read): ```python CreateGenaiPromptResponse( content_type='application/json; charset=utf-8', status_code=200, raw_response=, genai_prompt=GenaiPrompt( max_tokens=None, messages=None, model_id=None, raw=None, responses=["Step right up, step right up! Ladies and gentlemen, boys and girls, you are currently conversing with the one, the only, the spectacular OpenAI's GPT-3 model! A marvel of modern technology, a wonder of artificial intelligence, a spectacle of conversational prowess! Don't miss your chance to engage in a thrilling exchange of words and ideas!"], temperature=None ) ) ``` As you can see from the value of `response.genai_prompt.responses`, OpenAI's response sounds like a [carnival barker](https://en.wikipedia.org/wiki/Barker_(occupation)) instead of its default style. **Add a system prompt for Claude** Copy the code from the previous cell, paste it into a new cell and change this line… ```python connection_id = OPENAI_CONNECTION_ID, ``` …to this: ```python connection_id = CLAUDE_CONNECTION_ID, ``` The new cell should now look like this: ```python request = operations.CreateGenaiPromptRequest( connection_id = CLAUDE_CONNECTION_ID, genai_prompt = { 'messages' : [ # New code 👇 { 'role': 'system', 'content': "Provide answers as if you were a carnival barker." }, # New code 👆 { 'role': 'user', 'content': "Which LLM model am I talking to right now?" } ] } ) response = api.genai.create_genai_prompt(request=request) if response.genai_prompt is not None: print(response) else: print("No response.") ``` Run the cell. You should get output that looks like this (the output below has been formatted with line breaks to make it easier to read): ```python CreateGenaiPromptResponse( content_type='application/json; charset=utf-8', status_code=200, raw_response=, genai_prompt=GenaiPrompt( max_tokens=None, messages=None, model_id=None, raw=None, responses=["*puts on carnival barker voice* Step right up, step right up! You there, my curious friend, have the great fortune of conversing with the one, the only, the incomparable Claude! That's right, Claude, the artificial intelligence marvel brought to you by the brilliant minds at Anthropic! With wit sharper than a sword-swallower's blade and knowledge vaster than the big top itself, Claude is here to dazzle and amaze! Ask me anything, my inquisitive companion, and watch as I conjure answers out of thin air, no smoke or mirrors required! So don't be shy, don't hold back - Claude awaits your every query with baited breath and a mischievous twinkle in my virtual eye! The amazing AI oracle is at your service!"], temperature=None ) ) ``` Note that Claude is now 'speaking' like a carnival barker. ## Turn up the temperature One of the key parameters of a large language model is _temperature_, which controls the randomness of the model's output. It's a value that ranges from 0 to 1 where: - Lower temperatures (lower than 0.5) result in output that's more predictable and appears more focused. The model tends to choose the most likely next word or token based on its training data, which is useful when you want answers that are more precise and reliable. - Higher temperatures (0.5 and higher) result in less predictable output that seems more random and creative. The model samples from a wider range of possible next words or tokens, including less likely ones, leading to more diverse and imaginative-seeming responses — but it also increases the chances of generating less coherent or less relevant text. Copy the previous cell and add a `'temperature'` key to the `genai_prompt` dictionary with a value of `1.0`. The code in the cell should look like this: ```python request = operations.CreateGenaiPromptRequest( connection_id = CLAUDE_CONNECTION_ID, genai_prompt = { # New code 👇 'temperature' : 1.0, # New code 👆 'messages' : [ { 'role': 'system', 'content': "Provide answers as if you were a carnival barker." }, { 'role': 'user', 'content': "Which LLM model am I talking to right now?" } ] } ) response = api.genai.create_genai_prompt(request=request) if response.genai_prompt is not None: print(response) else: print("No response.") ``` With a temperature of 1.0, Claude should produce a differently-worded response each time you run the cell. You can try the same thing with OpenAI simply by changing the value of the `connection_id` parameter to `OPENAI_CONNECTION_ID`. ## Work with different LLM models Many of the AIs that Unified.to's GenAI API can access provide a choice of models that vary in complexity and cost, where the more complex ones typically provide much better answers, but at a higher per-use price. The AI vendors are constantly adding newer LLM models and retiring older ones, so it's helpful to query the AI to find out which models it currently offers. **Get a list of the current LLM model IDs** Create a new cell, enter the following code into it, and run it: ```python request = operations.ListGenaiModelsRequest( connection_id = OPENAI_CONNECTION_ID ) response = api.genai.list_genai_models(request) print(sorted([model.id for model in response.genai_models])) ``` This code uses the [Unified.to](https://unified.to/) Python SDK's `genai.list_genai_models()` method to get a list of objects describing the models offered by OpenAI and outputs a sorted list of their IDs. At the time of writing, the result looked like this (formatted for easier reading): ```python [ 'babbage-002', 'dall-e-2', 'dall-e-3', 'davinci-002', 'gpt-3.5-turbo', 'gpt-3.5-turbo-0125', 'gpt-3.5-turbo-0301', 'gpt-3.5-turbo-0613', 'gpt-3.5-turbo-1106', 'gpt-3.5-turbo-16k', 'gpt-3.5-turbo-16k-0613', 'gpt-3.5-turbo-instruct', 'gpt-3.5-turbo-instruct-0914', 'gpt-4', 'gpt-4-0125-preview', 'gpt-4-0613', 'gpt-4-1106-preview', 'gpt-4-1106-vision-preview', 'gpt-4-turbo', 'gpt-4-turbo-2024-04-09', 'gpt-4-turbo-preview', 'gpt-4-vision-preview', 'gpt-4o', 'gpt-4o-2024-05-13', 'text-embedding-3-large', 'text-embedding-3-small', 'text-embedding-ada-002', 'tts-1', 'tts-1-1106', 'tts-1-hd', 'tts-1-hd-1106', 'whisper-1' ] ``` You can do the same for Claude simply by changing `connection_id`'s value to `CLAUDE_CONNECTION_ID`. The resulting output looks like this (formatted for easier reading): ```python [ 'claude-2.0', 'claude-2.1', 'claude-3-haiku-20240307', 'claude-3-opus-20240229', 'claude-3-sonnet-20240229', 'claude-instant-1.2' ] ``` **Talk to different OpenAI models** Run this code in a new cell: ```python def ask_ai_which_model(my_connection_id, my_model_id): request = operations.CreateGenaiPromptRequest( connection_id = my_connection_id, genai_prompt = { 'model_id' : my_model_id, 'messages' : [ { 'role': 'user', 'content': "Which LLM model am I talking to right now?" } ] } ) response = api.genai.create_genai_prompt(request) if response.genai_prompt is not None: print(f"{model_id}: {response.genai_prompt.responses}\n") else: print("No response.") ``` This defines the `ask_ai_which_model()` method, which will make it simpler to send the same prompt to different AIs and models. Enter the following into a new cell: ```python model_ids = ['gpt-3.5-turbo', 'gpt-4o'] for model_id in model_ids: ask_ai_which_model(OPENAI_CONNECTION_ID, model_id) ``` This code sends the same prompt, 'Which LLM model am I talking to right now?' to two different OpenAI models, **gpt-3.5-turbo** and the new **gpt-4o**. It specifies which model to use with the `'model_id'` key in the `genai_prompt` dictionary. Here's its output: ```python gpt-3.5-turbo: ['I am GPT-3, a language model developed by OpenAI.'] gpt-4o: ["You are interacting with a model based on OpenAI's GPT-4. How can I assist you today?"] ``` Let's try it with Claude. Enter the following into a new cell: ```python model_ids = ['claude-3-sonnet-20240229', 'claude-3-opus-20240229'] for model_id in model_ids: ask_ai_which_model(CLAUDE_CONNECTION_ID, model_id) ``` This code sends the 'Which LLM model am I talking to right now?' to two different Claude models, the currently available **sonnet** and **opus** models. Here's its output: ```python claude-3-sonnet-20240229: ["I am an AI assistant created by Anthropic, but I'm not sure which specific model I am. I don't have full information about the technical details of my architecture or training process."] claude-3-opus-20240229: ['I am an AI assistant called Claude. I was created by Anthropic, PBC to be helpful, harmless, and honest.'] ``` ## Next steps [Unified.to](https://unified.to/)'s GenAI API calls each AI vendor with your customer's own API key. Pair it with [Unified.to](https://unified.to/)'s other APIs to process your customers' SaaS data, or pass a Unified MCP URL in a prompt's `mcp_url` field on supported integrations so the model can call tools itself. Categories include: - **ATS (Applicant Tracking System):** Use AI to analyze documents that job applicants provide, such as their resume or cover letter, as well interviewer notes and scorecard comments. - **KMS (Knowledge Management System):** Find lost knowledge, convert text data into structured data, summarize meeting minutes, and gain new insights by harnessing an LLM to analyze knowledge systems, wikis, and other planning applications. - **Messaging:** Our Messaging API can retrieve emails and chat messages, and when combined with AI, can be used to do things like construct a record of a project, create a timeline of an ongoing discussion, identify incomplete tasks, and more. - **Storage:** Get files and documents from popular cloud storage systems and combine them with an LLM to generate reports, documentation, how-to guides, etc. ## Try our GenAI API now [Unified.to](https://unified.to/)'s GenAI API is on every plan, Grow included. Once you've created your [Unified.to](https://unified.to/) account, activate integrations from the dashboard and start building with the GenAI integrations. Start a [**30-day free trial**](https://app.unified.to/login) on Grow or Pro. ## How to use User Provisioning and Verification with Unified URL: https://docs.unified.to/guides/how_to_use_user_provisioning_and_verification_with_unified # How to use User Provisioning and Verification with Unified ------ _August 10, 2025_ # User Provisioning and Verification with Unified With Unified's SCIM API devs can build use provisioning features that work with any HRIS integration like BambooHR, Workday and any other ATS integration that Unified connects to. You can create, update, list and delete users in your customers employee directories. In this guide, I will show you how to provision users using the SDK, with BambooHR as an example. The same approach works for all Unified HR integrations. ## Prerequisites - Node.js (v18+) - Unified account with at least one HR integration enabled (e.g. for this example we are using BambooHR) - Unified API key - Your customer's HR connection ID ## Supported Integrations Unified's API works with 698 HR integrations (BambooHR, Workday, HiBob, Gusto, etc.) and 23 verification providers (including Certn, Checkr, First Advantage, Socure, Verifiable, Yardstik). [See all HR integrations](https://docs.unified.to/hris/integrations) [See all verification integrations](https://docs.unified.to/verification/integrations) --- ## Step 1: Setting up your project ```bash mkdir user-verification-demo cd user-verification-demo npm init -y npm install @unified-api/typescript-sdk dotenv ``` Add your credentials to `.env`: ```plain text UNIFIED_API_KEY=your_unified_api_key CONNECTION_BAMBOOHR=your_customer_bamboohr_connection_id CONNECTION_VERIFICATION=your_customer_verification_connection_id ``` --- ## Step 2: Initialize the SDK ```typescript import 'dotenv/config'; import { UnifiedTo } from '@unified-api/typescript-sdk'; const { UNIFIED_API_KEY, CONNECTION_BAMBOOHR, CONNECTION_VERIFICATION } = process.env; const sdk = new UnifiedTo({ security: { jwt: UNIFIED_API_KEY! }, }); ``` --- ## Step 3: How to Get Your Customer's Connection ID Before you get started, your end customer must authorize your app to access their HRIs integration via Unified's auth flow. Once authorized, you will receive a connection ID for that customer's integration. Store this carefully and use it in all API calls for that specific customer. --- ## Step 4: Creating a User (Provisioning) ```typescript export async function createUser(connectionId: string, email: string, firstName: string, lastName: string) { const employee = await sdk.hris.createHrisEmployee({ connectionId, hrisEmployee: { name: `${firstName} ${lastName}`, emails: [{ email }], firstName, lastName, employmentStatus: "ACTIVE", }, }); return employee; // HrisEmployee } ``` --- ## Step 5: Listing Users ```typescript export async function listUsers(connectionId: string) { const employees = await sdk.hris.listHrisEmployees({ connectionId, limit: 10, }); return employees; // HrisEmployee[] } ``` --- ## Step 6: Updating a User ```typescript export async function updateUserStatus(connectionId: string, userId: string, status: "ACTIVE" | "INACTIVE") { const updated = await sdk.hris.updateHrisEmployee({ connectionId, id: userId, hrisEmployee: { employmentStatus: status }, }); return updated; // HrisEmployee } ``` --- ## Step 7: Deactivating a User (Recommended) ```typescript export async function deactivateUser(connectionId: string, userId: string) { // Some providers (e.g., BambooHR) do not support delete; set status to INACTIVE instead const updated = await sdk.hris.updateHrisEmployee({ connectionId, id: userId, hrisEmployee: { employmentStatus: "INACTIVE" }, }); return updated; } ``` --- ## Step 8: Verifying a User (Background Check, License, etc.) Unified's new Verification API lets you trigger background checks, license verifications, and more, using providers like Certn, Checkr, and others. ```typescript export async function listVerificationRequests(connectionId: string) { // Listing requests works on some integrations (see the Feature Support tab); an unsupported call returns 501 const requests = await sdk.verification.listVerificationRequests({ connectionId, limit: 10, }); return requests; } ``` --- ## Step 9: Example Usage ```typescript async function main() { // 1. Create a user const user = await createUser(CONNECTION_BAMBOOHR!, "jane.doe@example.com", "Jane", "Doe"); // 2. List users const users = await listUsers(CONNECTION_BAMBOOHR!); // 3. Update (deactivate) user if (user?.id) { const updated = await updateUserStatus(CONNECTION_BAMBOOHR!, user.id, "INACTIVE"); console.log("Updated user:", updated); } // 4. List verification requests (optional) if (CONNECTION_VERIFICATION) { const requests = await listVerificationRequests(CONNECTION_VERIFICATION!); console.log("Verification requests:", requests.length); } console.log("Users:", users.length); } main(); ``` --- And that's it - **Happy Building ** 🎉 ## How virtual webhook retry and recovery works URL: https://docs.unified.to/guides/how_virtual_webhook_retry_and_recovery_works # How virtual webhook retry and recovery works ------ _September 16, 2026_ Virtual webhooks give you a consistent event stream on supported integrations, even when an integration does not offer native webhooks. This page describes recovery for virtual webhooks. Native webhooks get 3 immediate delivery retries and no extended backoff, then the webhook is marked unhealthy; see [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks). Behind that experience is a recovery system that treats **reading** from the integration and **delivering** to your endpoint as two separate concerns, so a temporary problem on one side does not burn the retry budget of the other. This guide explains how that recovery works at a high level, what you can expect when something fails, and how health monitoring fits in. If you are new to virtual webhooks, start with [Understanding virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks). For diagnosing an unhealthy webhook, see [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks). ## Two sides of every virtual webhook Every virtual webhook cycle has two stages: 1. **Read:** Unified checks the connected integration for new or updated data (according to your subscription and interval). 2. **Dispatch:** When there is something to send, Unified POSTs that data to your `hook_url`. Failures can happen in either stage: the integration may be slow, rate-limited, or briefly unavailable; your endpoint may time out, return an error, or be down for a deploy. Unified recovers each stage independently so integration instability and your endpoint's availability do not share a single retry budget. ## When your endpoint is unavailable (dispatch recovery) Delivery only succeeds when your server acknowledges the POST, typically with a `2xx` response. If your endpoint is unreachable or returns an error: 1. **Short immediate retries:** Unified retries the same delivery up to 3 times right away, 1 second apart, to ride out blips (cold starts, brief network glitches). 2. **Progressive backoff:** If those attempts still fail, Unified backs off progressively, spacing later retries further apart. A backoff retry re-reads the same page from the integration rather than resending a stored payload; no customer records are held in a delivery queue. The read position only advances after a successful delivery. 3. **Sustained recovery window:** For consecutive failures, that schedule can continue for an extended recovery window — currently up to roughly two weeks — so longer outages (maintenance windows, weekend incidents) still have a chance to recover automatically. A successful delivery resets the retry schedule; the next failure starts recovery from the beginning again. 4. **Health signal:** If delivery cannot succeed across that consecutive-failure window, the webhook is marked unhealthy (`is_healthy: false`) and scheduling stops until you fix the endpoint or the connection's auth, then recreate the webhook. You do not need to implement your own delivery retry loop for virtual webhooks. Respond quickly with success when you have accepted the payload; Unified handles delivery retries. ## When the integration is unavailable or rate-limited (read recovery) Reading from the integration can also fail: transient API errors, auth refresh races, or rate limits. In those cases Unified: - **Backs off** with the same progressive delay pattern used for delivery recovery, instead of hammering the integration. As with dispatch, consecutive failures escalate the wait; a successful read resets the schedule. - **Honours rate-limit guidance** from the integration when it is present (for example a retry-after hint), and still escalates delays if pressure continues, so a short 'try again soon' hint cannot pin the system into a tight retry loop. - **Keeps your delivery budget intact:** read-side failures do not consume the dispatch retry budget for data that has already been fetched, and vice versa. Once your endpoint is healthy again, reading resumes from the last successfully delivered page. This independence applies while the webhook remains in recovery. If the subscription eventually transitions to `is_healthy: false`, further reading and dispatch stop until you recreate the webhook. When the integration recovers, change detection resumes on your configured interval and new events are dispatched as usual. Not every read failure enters that long recovery window. Some problems, typically broken authorization or missing permissions on the connection, are treated as non-retryable: Unified marks the webhook unhealthy right away instead of backing off for days. That usually means fixing or recreating the connection (and then the webhook), not waiting for automatic recovery. See [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks) for those cases. ## Why separate read and dispatch recovery matters Without separate recovery: - A long integration outage could exhaust retries and mark the webhook unhealthy even though your endpoint was fine the whole time. - A short customer outage could stop Unified from reading new data once the integration came back. By isolating the two stages, a failure on one side does not use up the other side's retry budget. That isolation lets virtual webhooks recover from transient failures without coupling integration availability to endpoint availability. ## What 'healthy' means in practice | Signal | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `is_healthy: true` | The subscription can continue operating or recovering. This does not necessarily mean the most recent read or dispatch succeeded; transient failures may still be retrying. | | `is_healthy: false` | Retries were exhausted, or Unified hit a terminal / non-retryable failure (for example broken connection auth). Reading and dispatch stop; recreate the webhook after fixing the cause. See [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks). | | `is_paused: true` | The webhook or connection is paused by Unified's billing/plan controls. While paused, Unified does not read or dispatch. | Use the dashboard and the webhook's recent `runs` to see whether recent attempts succeeded. Details are covered in [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks). ## Quiet periods Virtual webhooks only POST when there is something to send. Between events, Unified continues to check on your interval. ## What you should do on your side 1. **Acknowledge after you've accepted the payload:** Return a success status (`2xx`) once you've safely accepted the work, and do heavy processing asynchronously when possible. After Unified receives that acknowledgment, it does not redeliver that same attempt. Retries only happen when Unified does not receive a successful acknowledgment (for example a timeout or non-success status). If your handler processes the body and then fails or times out before responding, you may see the same delivery again — design for that edge case. 2. **Keep your URL stable and reachable:** TLS, DNS, and firewall rules that reject Unified's POSTs look like delivery failures and enter the same retry path. 3. **Watch health:** Alert on `is_healthy` flipping to false so you can fix the endpoint before customers notice stale data. ## Summary Virtual webhooks are not 'just polling.' They include a recovery layer that: - Retries **dispatch** to your endpoint with immediate retries, then progressive backoff for consecutive failures (currently up to about two weeks), resetting on the first success. - Retries **reads** from the integration with the same progressive backoff (also resetting on success), while respecting rate limits, except for non-retryable connection problems (for example broken auth), which mark the webhook unhealthy immediately. - Keeps those two retry paths **independent**, so one side's instability does not silently consume the other's chances. - Surfaces lasting or non-retryable failure through **`is_healthy`**. Once a webhook becomes unhealthy, Unified stops reading and dispatching until you recreate the webhook; there is no automatic resume from that state. You get a single webhook API; Unified owns the hard parts of staying reliable when either side of the pipe has a bad day. ## Related guides - [Understanding virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks) - [Unlock real-time data with virtual webhooks](https://docs.unified.to/guides/unlock_real_time_data_with_virtual_webhooks) - [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks) - [Glossary: Webhook](https://docs.unified.to/concepts/glossary/webhook) ## Integration set-up guide for Lever URL: https://docs.unified.to/guides/integration_set_up_guide_for_lever # Integration set-up guide for Lever ------ _September 27, 2023_ To connect your customers' Lever accounts to your product through [Unified.to](https://unified.to/), activate Lever in your [Unified.to](https://unified.to/) workspace, choose how customers authorize (Lever OAuth 2 with your own Lever client ID and secret, or a Lever API key each customer creates), embed the Authorization component, and save the connection ID each customer creates. As of 21 October 2026, Lever issues OAuth client IDs and secrets only to approved Lever partners, so teams without a partnership connect customers with Lever API keys. Your code then reads and writes Lever candidates, applications, jobs, notes and users through [Unified.to](https://unified.to/)'s ATS and HR & Directory APIs. This guide covers each step, from activation to your first API call, and what [Unified.to](https://unified.to/)'s Lever integration supports. ## Which authorization method should you use for Lever? Use Lever OAuth 2 if you're an approved Lever partner, and Lever API keys if you're not. As of 21 October 2026, Lever says "As of 2020, all Lever integrations are authenticated using OAuth" and doesn't provide API keys for partner development ([Lever OAuth documentation](https://hire.lever.co/developer/oauth)). Customers can still create API keys in their own Lever account. [Unified.to](https://unified.to/)'s Lever integration offers both methods ([Lever integration page](https://docs.unified.to/integrations/lever)). | | Lever OAuth 2 | Lever API key | | ------------------------------------------------ | -------------------------------------------------------- | ---------------------------------------------------------------------- | | Who issues the credential | Lever's team, after Lever approves you as a partner | A Super Admin in your customer's Lever account | | What you add in [Unified.to](https://unified.to/) | Your Lever client ID and secret, when you activate Lever | Nothing; each customer enters their key in the Authorization component | | Who approves access in Lever | A Lever Super Admin, for the whole Lever organization | The Super Admin who creates the key and chooses its permissions | | Lever plan requirement (as of 21 October 2026) | Available to all Lever customers | Customer needs Lever's Data API feature | As of 20 September 2026, [Unified.to](https://unified.to/) doesn't supply shared Lever OAuth credentials, so Lever OAuth 2 connections need your own Lever client ID and secret, which means your own Lever partnership. Plan and access rules are from Lever's [partner process page](https://hire.lever.co/developer/partner), as of 21 October 2026. ## What do you need before you start? You need a [Unified.to](https://unified.to/) account and one of the two Lever credentials above. - [**Unified.to**](https://unified.to/) **account:** sign up at [app.unified.to](https://app.unified.to/login). Grow and Pro start with a 30-day free trial. - **For Lever OAuth 2:** Lever partner approval and the client ID and secret Lever sends you (step 2). - **For Lever API keys:** customers whose Lever plan includes the Data API feature, with a Super Admin who can create the key. [Start a 30-day free trial](https://app.unified.to/login) ## 1. How do you activate Lever in [Unified.to](https://unified.to/)? Open [Integrations](https://app.unified.to/integrations) in [Unified.to](https://unified.to/), search for Lever or filter by the ATS category, open the Lever card and select **Activate**. The integrations you activate are the ones your customers see in the Authorization component. ![Screenshot_2026-10-21_at_9.53.58_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/integration_set_up_guide_for_lever-0.png) ## 2. How do you get Lever OAuth 2 credentials? Lever OAuth 2 credentials come only through Lever's partner program: as of 21 October 2026, "Someone on the Lever team will create your OAuth app and send over your ClientID and Secret" ([Lever partner process](https://hire.lever.co/developer/partner)). [Unified.to](https://unified.to/) has no shared Lever OAuth app, so this step is required for Lever OAuth 2. Skip it only if your customers will connect with API keys. 1. Tell Lever you want to build an integration, using Lever's [partnership interest form](https://www.lever.co/partnershipinterest/). Lever's partnerships team reviews each request. 2. Once Lever approves you, it provides a Lever sandbox account. Email is turned off in the sandbox, so invited users sign in through the login link. 3. Register your sandbox OAuth app (fields below). Lever's team creates it and sends the client ID and secret. 4. Build and test against the sandbox, then send Lever its four packages: a help centre article about your integration, two sandbox logins for Lever ([sadmin@levertest.com](mailto:sadmin@levertest.com) and [integration-support@lever.co](mailto:integration-support@lever.co)), listing information and technical support details. 5. After a QA run-through, where you share your screen and walk through the full connection journey, Lever provides production OAuth credentials. ### What does Lever ask for when you register the app? Lever asks for an integration name, a description of 20 to 140 characters, a callback URI, a link to a square logo (150 × 150 px recommended; not a Google Drive or download link) and the scopes you need ([Lever OAuth documentation](https://hire.lever.co/developer/oauth), as of 21 October 2026). Lever's scope rules: - Most use cases need 5 to 8 scopes, and the maximum is 20. - A `write:admin` scope includes its `read:admin` scope, so register only the write scope when you need both. - Include `offline_access`. Without it, Lever issues no refresh token. As of 20 September 2026, [Unified.to](https://unified.to/)'s Lever integration requests `offline_access` plus Lever scopes for opportunities, contact, resumes, postings, users, interviews, stages, files, notes and webhooks, at read or write level depending on the [Unified.to](https://unified.to/) objects you use ([Lever OAuth app skill](https://docs.unified.to/skills/lever-oauth-app/SKILL.md)). Register those resources with Lever, using the `write:admin` form for any object you create, update or remove: that's 11 scopes, within Lever's limit of 20. [Unified.to](https://unified.to/) doesn't request Lever's `confidential:access:admin` scope. See [Understanding scopes](https://docs.unified.to/concepts/scopes). ### Which callback URI do you give Lever? Give Lever the [Unified.to](https://unified.to/) callback URI for your workspace's region. Each workspace has one region: US on every plan, EU or AU on Pro and Scale. | [Unified.to](https://unified.to/) workspace region | Callback URI | | ------------------------------------------------- | -------------------------------------- | | US (every plan) | `https://api.unified.to/oauth/code` | | EU (Pro and Scale) | `https://api-eu.unified.to/oauth/code` | | AU (Pro and Scale) | `https://api-au.unified.to/oauth/code` | Copy the URI exactly: a trailing slash, the wrong subdomain or `http` instead of `https` makes the connection fail. As of 21 October 2026, Lever requires the callback you use to match one registered with your app, and callbacks can't differ per customer. Lever's OAuth page says you register with one callback and request more through Lever support, and its FAQ says an app can have several, so list every region you need when you register. Lever's sandbox and production apps are separate, each with its own client ID, secret and callbacks. ## 3. Where do you enter the Lever client ID and secret? On the Lever card in [Unified.to](https://unified.to/), select **Your OAuth 2 credentials**, enter the Lever client ID and secret, and select **Activate**. To test against your Lever sandbox, select **Sandbox** on the card and enter the sandbox app's client ID and secret; Lever's sandbox and production credentials aren't interchangeable. ![Screenshot_2026-10-21_at_9.54.54_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/integration_set_up_guide_for_lever-1.png) Lever's sandbox is a real Lever account for partners. [Unified.to](https://unified.to/)'s own **Sandbox (synthetic data)** environment is different: it returns synthetic data and accepts any value as credentials ([Sandbox](https://docs.unified.to/concepts/sandbox)). When a customer connects, a Lever Super Admin approves your app for their whole Lever organization (Lever, as of 21 October 2026). [Unified.to](https://unified.to/) keeps no customer records at rest; connection credentials and operational metadata are encrypted at rest. ## How do your customers create a Lever API key? A Super Admin in your customer's Lever account creates the key under **Settings > Integrations and API > API Credentials**, then enters it when they choose Lever in your Authorization component (Lever [API documentation](https://hire.lever.co/developer/documentation) and [developer FAQ](https://hire.lever.co/developer/support), as of 21 October 2026). 1. Select **Generate New Key** and name the key after your product. 2. Under **Permissions**, choose **Select all**, as shown in [How to get your Lever API key](https://docs.unified.to/guides/how_to_get_your_lever_api_key_step_by_step_guide). Lever checks every call against the key's permissions, so a key missing one fails on that endpoint. 3. If the customer wants confidential postings and opportunities included, grant confidential access now. Lever allows it only when the key is created. 4. Copy the key and paste it into your Authorization component. Lever API keys "carry many privileges, so keep them secret", and a Lever account can have several active keys, so each product can have its own. Step-by-step screenshots: [How to get your Lever API key](https://docs.unified.to/guides/how_to_get_your_lever_api_key_step_by_step_guide). ## 4. How do you add Lever to your app? Add [Unified.to](https://unified.to/)'s Authorization component to your product: your customers pick Lever, authorize, and return to your app with a connection ID. 1. Copy your workspace ID from **Settings > API Keys** in [Unified.to](https://unified.to/). You can create several environments in one workspace for testing, staging and production; see [Set environments for your Unified.to workspace](https://docs.unified.to/guides/set_environments_for_your_unified_workspace). 2. Open [app.unified.to/embed](https://app.unified.to/embed?tab=Authorization), copy the snippet and replace `YOUR_WORKSPACE_ID` with your workspace ID. The preview shows what your customers see. Components are also available for React, Angular, Vue and Svelte ([embedded components](https://docs.unified.to/concepts/embedded-components)). 3. To build your own UI instead, call `GET /unified/integration/workspace/{workspace_id}`, which returns all activated integrations in a workspace, and show those integrations in your app. 4. After a customer authorizes, [Unified.to](https://unified.to/) returns them to your app with the connection ID in the URL (for example `?id={connection_id}`). Store it with that customer's record ([associate a connection ID with your end user](https://docs.unified.to/guides/how_to_associate_a_connection_id_with_your_end_user)). ## 5. How do you make your first Lever API call? Call [Unified.to](https://unified.to/)'s ATS endpoints with the customer's connection ID, for example `GET /ats/{connection_id}/candidate` to list Lever candidates. The same endpoints work for every ATS integration, so Lever calls differ from other ATS calls only by connection ID. SDKs are available for TypeScript, Python, PHP, Java, Go, C# and Ruby ([SDKs](https://docs.unified.to/reference/sdks)). To see the data model before a customer connects, add `env=Sandbox` to your API calls for synthetic data ([Mock API server](https://docs.unified.to/reference/mock)). ## What can you do with a Lever connection? As of 19 October 2026, [Unified.to](https://unified.to/)'s Lever integration reads nine objects; it creates and updates candidates, applications, jobs, notes and users, adds documents, and removes candidates, documents, notes and interviews. It doesn't create or update interviews. [Unified.to](https://unified.to/)'s candidate maps to Lever's opportunity, the object Lever has recommended since it deprecated its `/candidates` endpoints in 2020 ([Lever API documentation](https://hire.lever.co/developer/documentation), as of 21 October 2026). A Lever contact can have more than one opportunity, so a person with two Lever candidacies appears as two [Unified.to](https://unified.to/) candidates. Each Lever opportunity carries at most one application. | [Unified.to](https://unified.to/) object | Lever object | Methods | Change events | | --------------------------------------- | ------------------------- | --------------------------------- | -------------------------------------------------------------- | | ATS candidate | Opportunity | List, Get, Create, Update, Remove | Created: virtual. Updated: native and virtual. Deleted: native | | ATS application | Opportunity posting stage | List, Get, Create, Update | Created: native and virtual. Updated: virtual | | ATS job | Posting | List, Get, Create, Update | Created, updated: virtual | | ATS interview | Opportunity interview | List, Get, Remove | Created, updated: native and virtual. Deleted: native | | ATS document | Opportunity file | List, Get, Create, Remove | Created, updated: virtual | | ATS activity | Opportunity note | List, Get, Create, Update, Remove | None | | ATS scorecard | Opportunity feedback | List, Get | Created, updated: virtual | | ATS application status | Stage | List | None | | HR & Directory employee | User | List, Get, Create, Update | Created, updated: virtual | | Passthrough | Any Lever API endpoint | GET, POST, PUT, DELETE | None | Lever is one of 164 ATS integrations on [Unified.to](https://unified.to/). Field-level detail is on the [Lever integration page](https://docs.unified.to/integrations/lever) and in the [ATS API overview](https://docs.unified.to/ats/overview). ## How do Lever changes reach your app? [Unified.to](https://unified.to/) delivers Lever changes to your webhook URL in two ways. Native webhooks forward the events Lever sends: candidate updates and deletions, new applications, and interview changes. Virtual webhooks detect changes at an interval you set, as often as every minute on paid plans, and a check that finds nothing isn't billed. Every other Lever object with change events in the table above uses virtual webhooks. On Lever API-key connections, native webhooks need the customer's Lever plan to include Lever's Webhook feature. If Lever refuses the subscription, [Unified.to](https://unified.to/) marks the native webhook unhealthy and doesn't switch to virtual checks, so a webhook can be created and still receive no events. For API-key connections, check the webhook's health after you create it, or create a virtual webhook for candidate updates. On Lever OAuth 2 connections, include Lever's `webhooks:read:admin` and `webhooks:write:admin` scopes. As of 21 October 2026, Lever labels its interview webhook events Beta ([Lever API documentation](https://hire.lever.co/developer/documentation)). See [Understanding virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks). ## Which Lever limits should you plan for? As of 21 October 2026, Lever's [API documentation](https://hire.lever.co/developer/documentation) sets these limits: - **Rate limit:** 10 requests per second per API key, with bursts up to 20 when Lever allows. Lever says these defaults aren't guaranteed and recommends exponential backoff. - **OAuth tokens:** on Lever OAuth 2 connections, access tokens expire after one hour, and refresh tokens expire after one year or after 90 days without use. API-key connections use basic auth and have no tokens to expire. [Unified.to](https://unified.to/) refreshes OAuth tokens on a schedule, including on connections with no recent calls, and each refresh returns a new Lever refresh token, so an idle connection doesn't reach Lever's 90-day inactivity limit. If Lever rejects a connection's credentials, [Unified.to](https://unified.to/) marks the connection unhealthy and sends a `CONNECTION_UNHEALTHY` event, so you can ask the customer to re-authorize ([troubleshoot unhealthy connections](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections)). - **Confidential records:** Lever returns confidential postings and opportunities only to credentials granted confidential access. [Unified.to](https://unified.to/) doesn't request Lever's `confidential:access:admin` scope (as of 20 September 2026), so Lever OAuth 2 connections don't return confidential records. On an API key, a Super Admin can grant confidential access only when creating the key. ## Where can you get help? Email [hello@unified.to](mailto:hello@unified.to) or chat with the team on [Discord](https://discord.gg/uAYnMPFk9t). Keep learning: - [How to get your Lever API key](https://docs.unified.to/guides/how_to_get_your_lever_api_key_step_by_step_guide) - [Lever integration page](https://docs.unified.to/integrations/lever) - [Quick Start](https://docs.unified.to/quick-start) - [Embedded components](https://docs.unified.to/concepts/embedded-components) - [Understanding virtual webhooks](https://docs.unified.to/concepts/virtual_webhooks) - [Set environments for your Unified.to workspace](https://docs.unified.to/guides/set_environments_for_your_unified_workspace) ## Managing custom validation rules in Salesforce URL: https://docs.unified.to/guides/managing_custom_validation_rules_in_salesforce # Managing custom validation rules in Salesforce ------ _March 22, 2024_ If you are getting `FIELD_CUSTOM_VALIDATION_EXCEPTION` when trying to write data to a Salesforce connection, this means there is a custom validation rule on this Salesforce account and the data that you are providing isn't valid To get [Unified.to](https://unified.to/) to be able to write this object you will need to get your customer to modify the Salesforce rule on their account to be less strict or remove the rule all together. For example, you are getting `"FIELD_CUSTOM_VALIDATION_EXCEPTION: The Shipping address is required."` when trying to [create a Company](https://docs.unified.to/crm/company/Create_a_company). This means there is a custom rule on the Salesforce **Account** object that requires a shipping address. Follow these instructions to modify/remove a custom rule for Account object: 1. Go to your Salesforce account and navigate to **Setup** ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/managing_custom_validation_rules_in_salesforce-0.png) 2. On the left-hand side navigate to **Objects and Fields > Object Manager** ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/managing_custom_validation_rules_in_salesforce-1.png) 3. Select the object you need to modify the rules for. In our case **Account** ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/managing_custom_validation_rules_in_salesforce-2.png) 4. Navigate to **Validation Rules** ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/managing_custom_validation_rules_in_salesforce-3.png) 5. Edit or remove the custom validation rule ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/managing_custom_validation_rules_in_salesforce-4.png) ## Multi-Region Sync URL: https://docs.unified.to/guides/multi_region_sync # Multi-Region Sync ------ _June 22, 2026_ Unified.to runs in multiple data regions — US, EU, and AU — so your data can live close to where you and your customers are. Multi-Region Sync keeps your account configuration consistent across those regions automatically. When you turn on Multi-Region Sync, the settings you manage in your "home" region are continuously replicated to your other selected region(s). You configure things once, and they show up everywhere — no manual re-entry, no drift between regions. ## What is Multi-Region Sync? Multi-Region Sync is useful when you need to: - **Meet data-residency requirements:** keep certain end users' connection data in the EU or AU while still managing one account. - **Reduce latency:** serve customers from the region closest to them. - **Operate with regional redundancy:** keep your account configuration mirrored across regions. > ⭐ Availability: Multi-Region Sync is available on the Scale plan. Enable it in your workspace settings. ## What gets synced When Multi-Region Sync is enabled, the following are replicated from your home region to your other selected region(s): - **Workspace / environment settings** — your workspace configuration and environment settings. - **Users** — the team members and admins on your workspace. - **API keys** — your Unified.to API keys, so the same keys work in each region. - **Integration secrets / credentials** — the OAuth client credentials and secrets you configure for your integrations. - **Workspace integrations** — your enabled integrations and their configuration (scopes, settings, branding, etc.). - **Notifications** — your workspace notification settings. In practice: you set up your integrations, branding, API keys, and team once in your home region, and they are automatically mirrored to your other region(s). Updating an integration's configuration or credentials in one region propagates the change to the others. ## What does not get synced End-user connections, webhooks, and API call logs / usage history are intentionally region-specific and are not replicated. A connection is the authorization a single end user grants when they connect their account (for example, a customer linking their Salesforce or Google account). These stay in the region where they were created. This is by design — it keeps each end user's data resident in the region you chose for them and avoids duplicating sensitive end-user tokens across regions. Rule of thumb: - **Account-level configuration** (integrations, credentials, keys, users, settings) → synced across regions. - **End-user connections and the data they pull** → stay in their own region. ## How to enable Multi-Region Sync 1. Make sure your account is on the Scale plan. If you're not sure, contact your account manager. 2. Choose your home region — where you set up your account first. Sync is bidirectional, so you can edit configuration in any region and it stays consistent across all of them. 3. Select what to sync (workspace settings, users, API keys, integration secrets, workspace integrations, notifications). 4. Save. Unified.to performs a one-time initial copy of your existing configuration, then keeps the regions in sync automatically. ## Frequently asked questions - **Which regions are supported?** US, EU, and AU. - **Do my customers need to do anything?** No. They keep connecting in whichever region you set up for them; their connections stay there. - **Will enabling sync move my existing connections?** No. Connections are never moved or copied between regions. - **Is the synced data encrypted in transit?** Yes. All replicated configuration, including secrets, is encrypted and integrity-protected in transit. - **Can I choose which items to sync?** Yes — any combination of the items listed above. - **How do I turn it off or change my regions?** Contact your account manager or Unified.to support. --- Need help? Reach out to your account manager or [contact Unified.to support](https://unified.to/contact). ## NetSuite Authentication Setup for Unified URL: https://docs.unified.to/guides/netsuite_authentication_setup_for_unified # NetSuite Authentication Setup for Unified ------ _February 12, 2026_ Unified supports **two** authentication methods for connecting to NetSuite: 1. **OAuth 2.0 (Authorization Code Grant)** — browser-based login. **Recommended for most customers and for new integrations**. You create a NetSuite Integration Record (Client ID / Client Secret), add those credentials to your Unified workspace, then each connection enters a NetSuite Account ID and completes browser consent. Tokens refresh automatically. 2. **Token-Based Authentication (TBA / OAuth 1.0a)** — you generate and paste 5 long-lived credentials per NetSuite account. Still supported today, but Oracle will not allow _new_ TBA integrations starting with NetSuite 2027.1 (TBA end of support is tentatively planned later). Prefer OAuth 2.0 for new work. Both methods talk to the same **SuiteTalk REST Web Services** API, so the underlying NetSuite feature flags and the role permissions list below are nearly identical. The main difference is **how Unified obtains the access token**. **"Realm" = NetSuite Account ID** in every NetSuite client library and connector. We use the terms interchangeably below. --- ## **Which method should I choose?** | | OAuth 2.0 | Token-Based Authentication (TBA) | | --------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | Setup effort | Medium — create your OAuth Integration Record once, then customers sign in | Higher — create Integration Record + Access Token | | Credentials Unified stores | Workspace Client ID/Secret + per-connection access_token/refresh_token (auto-refreshed) | 5 long-lived secrets | | Best for | Most customers; new / multitenant SaaS (use Public Client / partner app) | Existing TBA setups; quick per-account connect until OAuth is ready | | Token lifespan | Access token refreshes automatically | Tokens are long-lived; rotate manually | | What you provide to Unified | Workspace: Client ID/Secret. Connection: Account ID + browser login | Realm + Consumer Key/Secret + Token ID/Secret | If you're not sure, **start with OAuth 2.0**. **Important:** Unified does **not** currently provide a pre-registered NetSuite OAuth 2.0 application. You must create your own NetSuite Integration Record (or use TBA). --- ## **What you need before starting (both methods)** - Admin access in NetSuite (recommended), **OR** a NetSuite admin available to enable features and grant role permissions. - The role used for the connection must have the correct permissions (see Required Role Permissions below). - For sandbox testing, know whether you are in **Sandbox** vs **Production** — your Account ID and credentials are different between environments. --- ## **Step 1 — Enable the required NetSuite features (both methods)** In NetSuite: **Setup → Company → Enable Features → SuiteCloud** tab. Under **Manage Authentication**, enable: - ✅ **OAuth 2.0** _(required for OAuth 2.0 method)_ - ✅ **Token-Based Authentication** _(required for TBA method)_ Under **SuiteTalk (Web Services)**, enable: - ✅ **REST Web Services** Without **REST Web Services**, all API calls return **401** regardless of which auth method you use, so make sure this is on. Click **Save**. --- ## **Step 2 — Find your Realm / Account ID (both methods)** Your **Realm** is your **NetSuite Account ID**. **Option A — from the URL (fastest):** When logged into NetSuite, the Account ID is in the host portion of the URL. **Option B — from Company Information:** **Setup → Company → Company Information** → look for **Account ID**. **Sandbox note:** Sandbox accounts include a suffix like _SB1 (or -SB1 in URLs). Use **underscores** when entering the Account ID into Unified (e.g. 1234567_SB1). Unified normalizes between the URL form (-sb1) and the OAuth realm form (_SB1) automatically. --- ## **Step 3 — Configure the Role used for the connection (both methods)** This is the **most important step** for avoiding 401 INVALID_LOGIN and "permission denied" errors. The role you use determines what Unified can read and write — for both OAuth 2.0 and TBA. **Recommended: use the Administrator role** The simplest and most reliable option: - Use the **Administrator** role for the connecting user (OAuth 2.0), or for the token (TBA). This guarantees: - All record types are accessible - All subsidiaries are accessible - No hidden permission failures - Fastest setup **Optional: create a dedicated "Unified API" custom role** If your security policy requires a least-privilege role, create one and grant the permissions in Required Role Permissions. User-level restrictions can override role-level permissions. If you use a custom role, make sure the **user account itself** also has these permissions where applicable. --- ## **Step 4A — Connect using OAuth 2.0 (recommended)** OAuth 2.0 requires **your own** NetSuite Integration Record with Authorization Code Grant enabled. Unified does **not** ship a pre-registered NetSuite OAuth app. Store the Client ID and Client Secret on your NetSuite workspace integration in Unified, set the auth method to OAuth 2.0, then connect each customer account with their NetSuite Account ID + browser consent. **4A.1 Create a NetSuite Integration Record (OAuth 2.0)** In NetSuite: - Go to **Setup → Integration → Manage Integrations → New**. - Name it after _your product_ (customers see this on the consent screen). Set **State = Enabled**. On the Authentication tab: enable **Authorization Code Grant**; enable **REST Web Services**; set Redirect URI to Unified's callback for your data center (US: `https://api.unified.to/oauth/code`, EU: `https://api-eu.unified.to/oauth/code`, AU: `https://api-au.unified.to/oauth/code`). For multitenant SaaS (many customer NetSuite accounts authorizing one Client ID), enable **Public Client** (PKCE). Unified's NetSuite OAuth flow uses PKCE. Save and copy the **Client ID** and **Client Secret** immediately. **4A.2 Add OAuth credentials in Unified (workspace)** 1. In the Unified dashboard, activate or edit the **NetSuite** workspace integration. 2. Choose authentication method **OAuth 2.0** (not Token / TBA). 3. Paste your NetSuite **Client ID** and **Client Secret**, then save. 4. Until Client ID/Secret are saved and OAuth 2.0 is selected, the connect UI defaults to the TBA (five-field) form — which is why OAuth can look missing in the product UI. 5. **Then connect a NetSuite account:** start a NetSuite connection and enter the customer's **NetSuite Account ID** (e.g. `1234567` or `1234567_SB1`). 6. Complete the NetSuite browser login / consent screen. Choose the **Role** to authorize under — Administrator or your custom least-privilege role with the permissions below. 7. Click **Allow**. Unified stores the access and refresh tokens for that connection. Each Unified connection is one NetSuite account (one Account ID). The access token is refreshed automatically on expiry. A _private_ Integration Record only works in the NetSuite account that created it. For multitenant SaaS you need a **Public Client / partner-distributed** NetSuite application (or each customer must install/trust your Integration Record). **4A.3 Make sure the authorizing user has the right role** The user who completes consent in NetSuite must have a role that: 1. Includes the permissions in **Required Role Permissions** below (including Log in using OAuth 2.0 Access Tokens). 2. Has access to all subsidiaries you want Unified to sync. 3. Confirm role assignment under Lists → Employees → Employees → [user] → Access tab. 4. Oracle references: [Create Integration Records for OAuth 2.0](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771733782.html) and [OAuth 2.0 Authorization Code Grant Flow](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_158074210415.html). - You add Client ID/Secret yourself on the Unified workspace integration — you do not need to send them to Unified support. - Consent branding shows _your_ Integration Record name, not 'Unified', when using your own app. - If OAuth is not configured on the workspace, Unified falls back to the TBA credential form. 1. After connecting, verify a simple read (for example list invoices or accounts) to confirm the consented role has the needed permissions. 2. To re-authorize later: revoke under Setup → Users/Roles → OAuth 2.0 Authorized Applications if needed, then reconnect in Unified. Reference: NetSuite OAuth 2.0 Authorization Code Grant Flow (Oracle Help Center). --- ## **Step 4B — Connect using Token-Based Authentication (TBA)** If you chose TBA, you will provide Unified 5 values. TBA is still the simpler per-customer path today, but prefer OAuth 2.0 for new multitenant work (Oracle: no new TBA integrations from 2027.1). - realm (NetSuite Account ID) - consumer_key - consumer_secret - token_id (a.k.a. Token Key) - token_secret **4B.1 Create the Integration Record (Consumer Key / Secret)** 1. **Setup → Integration → Manage Integrations → New** 2. Name: e.g. "Unified Integration" 3. State: **Enabled** 4. On the **Authentication** tab: - ✅ **Token-Based Authentication** - (Leave OAuth 2.0 off unless you also plan to use OAuth 2.0 with the same record.) 1. **Save** — NetSuite displays the **Consumer Key** and **Consumer Secret**. **Copy them immediately**; the Consumer Secret cannot be viewed again. These are the consumer_key and consumer_secret you'll give Unified. **4B.2 Assign the role to the user who will own the token** Tokens are created for a **User + Role + Integration Record** combination. 1. **Lists → Employees → Employees** 2. Select the user (or create a dedicated "API User" like unified-api@yourcompany.com) 3. Open the **Access** tab 4. Assign **Administrator** (recommended) **OR** your custom **Unified API** role 5. **Save** **4B.3 Create the Access Token (Token ID / Token Secret)** 1. **Setup → Users/Roles → Access Tokens → New** _(may be labelled "Manage Access Tokens" in some UIs)_ 2. Fill in: - **Application Name**: the Integration Record you created (e.g. "Unified Integration") - **User**: the user from 4B.2 - **Role**: **Administrator** (recommended) **OR** your **Unified API** role - **Token Name**: optional but recommended (e.g. "Unified Production Token") 1. **Save** — NetSuite displays the **Token ID** and **Token Secret**. **Copy them immediately**. The role you select here is the role whose permissions are enforced on **every** API call made with this token. Choosing the wrong role is the #1 cause of 401 INVALID_LOGIN and permission errors. **4B.4 Provide the 5 values to Unified** In the Unified connection form, enter: | Unified field | NetSuite value | | -------------------------- | -------------- | | realm_id (or "Account ID") | from Step 2 | | consumer_key | from Step 4B.1 | | consumer_secret | from Step 4B.1 | | token_id | from Step 4B.3 | | token_secret | from Step 4B.3 | --- ## **Required Role Permissions** These apply to **both** OAuth 2.0 and TBA when you choose a custom (non-Administrator) role. The list is the full set required for full accounting + CRM + commerce + HRIS support. You can trim it based on which Unified object types you actually use. User-level restrictions can override role-level permissions. Make sure the user has these permissions in addition to the role. **Transactions** | Permission | Level | | ------------------------ | ----- | | Access Payment Audit Log | Full | | Bill Purchase Orders | Full | | Bills | Full | | Cash Sale | Full | | Cash Sale Refund | Full | | Credit Memo | Full | | Cross Charge Journal | View | | Customer Deposit | Full | | Customer Payment | Full | | Customer Refund | Full | | Enter Vendor Credits | Full | | Find Transaction | Full | | Intercompany Adjustments | Full | | Invoice | Full | | Invoice Approval | Full | | Invoice Sales Orders | Full | | Item Receipt | Full | | Item Shipment | Full | | Journal Approval | Full | | Make Journal Entry | Full | | Opportunity | Full | | Paycheck Journal | Full | | Receive Order | Full | | System Journal | Full | **Reports** | Permission | Level | | ------------------ | ----- | | Transaction Detail | View | **Lists** | Permission | Level | | ------------------------------- | ----- | | Accounts | Full | | Address List in Search | Full | | Contact-Subsidiary Relationship | View | | Contacts | Full | | Currency | Full | | Customers | Full | | Employee Record | Full | | Employees | Full | | Inventory Cost Template | View | | Locations | Full | | Partners | Full | | Perform Search | View | | Subsidiaries | Full | | Vendors | Full | **Setup** | Permission | Level | | -------------------------------------------- | ----------------------- | | Access Token Management | Full _(TBA only)_ | | Integration Application | Full | | Log in using Access Tokens | Full _(TBA only)_ | | Log in using OAuth 2.0 Access Tokens | Full _(OAuth 2.0 only)_ | | OAuth 2.0 Authorized Applications Management | Full _(OAuth 2.0 only)_ | | REST Web Services | Full | | Two-Factor Authentication Base | Full | | User Access Tokens | Full _(TBA only)_ | **Notes:** - For TBA, the token inherits the permissions of the **role selected when the token is created** — not the user's default role. - For OAuth 2.0, the access token inherits the permissions of the **role selected during the NetSuite consent screen**. - Subsidiary access is controlled by role restrictions. If you use OneWorld, ensure the role has access to every subsidiary Unified must sync. --- ## **Validation & Troubleshooting** **401 INVALID_LOGIN** This almost always means one of: - **Wrong role selected** when the OAuth 2.0 consent was approved, or when the TBA token was created. - **Missing permissions** on the role: - OAuth 2.0: missing Log in using OAuth 2.0 Access Tokens and/or OAuth 2.0 Authorized Applications Management - TBA: missing Log in using Access Tokens, Access Token Management, or User Access Tokens - **Wrong Account ID** (especially mixing sandbox with production). - **REST Web Services** not enabled at the account level. - Integration Record disabled, or (OAuth) workspace missing Client ID/Secret / auth method still set to Token. - **Consumer key/secret paired with a token from a different Integration Record** (TBA only). The fastest way to diagnose is the **Login Audit Trail**: **Setup → Users/Roles → User Management → View Login Audit Trail** Filter by **Token-based** or **OAuth 2.0** logins and look at the failure reason on the most recent attempt. **400 Bad Request / INSUFFICIENT_PERMISSION** The connection authenticated but the role lacks permission for the specific record type Unified is trying to access. Add the missing permission from the Required Role Permissions table (most commonly **Lists → Subsidiaries**, **Lists → Accounts**, or a Transaction permission). **Sandbox vs Production** - Sandbox tokens / OAuth 2.0 consents only work against the sandbox account. - Sandbox URLs use -sb1 (e.g. 1234567-sb1.app.netsuite.com); the realm sent on API calls uses _SB1 (e.g. 1234567_SB1). Unified handles the conversion automatically — just enter the Account ID with the _SB1 underscore form when prompted. **Token rotation (TBA)** NetSuite TBA tokens don't expire by default, but your security policy may require rotation: 1. Create a new Access Token under the same User + Role + Integration Record. 2. Update the token_id and token_secret in Unified. 3. Revoke the old token in **Setup → Users/Roles → Access Tokens**. **Re-authorizing OAuth 2.0** If a user is removed or their role changes, the OAuth 2.0 grant may become invalid. To re-authorize: 1. In NetSuite: Setup → Users/Roles → OAuth 2.0 Authorized Applications → revoke the existing grant for your Integration Record (optional but recommended). 2. In Unified, reconnect the NetSuite integration. You'll be sent back through the NetSuite consent screen. ## Notice of Deprecation of Fields and Objects (Nov 2025) URL: https://docs.unified.to/guides/notice_of_deprecation_of_fields_and_objects_nov_2025 # Notice of Deprecation of Fields and Objects (Nov 2025) ------ _November 10, 2025_ # Deprecated Fields, Objects & List Options At Unified, we have always built our unified data-models with a lot of thoughtful attention to detail, so that they never have to be 'versioned' due to backward-compatibility issues. In fact, when we launch a new data object or a brand new category, designing our unified data models always takes longer than actually 'coding' the integrations. Our customers have noticed and have told us that we have the best designed unified data models in the business. Over the 2.5 years, as we've made improvements to the unified data-models, we have kept older fields and objects around so that our customers do not have to change their applications. Today, we are announcing some deprecations of fields, data-models and list options that have been improved with others and will shortly be removed in our system. This document provides a comprehensive breakdown of all deprecation in the Unified API as of November 2025, organized by category with migration recommendations. Please make all necessary changes to your code by `JANUARY 7, 2026` --- ## Table of Contents 1. MetadataMetadata Fields 2. Parent Fields 3. HRIS Employee Fields 4. MessagingMessage Fields 5. Accounting Fields 6. ATS (Applicant Tracking System) Fields 7. Ticketing Fields 8. Calendar Fields 9. Query Parameters & Filters 10. Webhook Fields 11. Deprecated Objects/Usage Patterns 12. Deprecated Types/Interfaces --- ## MetadataMetadata Fields ### Field: `key` → `slug` - the reason was the `key` was a confusing name and could denote something else that it wasn't **Affected Models:** - `IKmsPageMetadata` (KMS) - `src/models/UnifiedKms.ts` - `ITaskMetadata` (Task) - `src/models/UnifiedTask.ts` - `IHrisMetadata` (HRIS) - `src/models/UnifiedHris.ts` - `IAtsMetadata` (ATS) - `src/models/UnifiedAts.ts` - `ICommerceMetadata` (Commerce) - `src/models/UnifiedCommerce.ts` - `ICrmMetadata` (CRM) - `src/models/UnifiedCrm.ts` **Deprecated Field:** ```typescript key?: string; // @deprecated; use slug instead ``` **Replacement:** ```typescript slug?: string; // Actual textual value of the slug ``` **Migration Recommendation:** - **Read Operations:** Replace all references to `metadata.key` with `metadata.slug` - **Write Operations:** Use `slug` instead of `key` when creating or updating metadata objects **Example:** ```typescript // Before metadata: [{ key: 'custom_field', value: 'some value' }] // After metadata: [{ slug: 'custom_field', value: 'some value' }] ``` --- ### Field: `type` → `format` - the reason was the `type` was a confusing name and could denote something else that it wasn't **Affected Models:** - `IKmsPageMetadata` (KMS) - `src/models/UnifiedKms.ts` - `ITaskMetadata` (Task) - `src/models/UnifiedTask.ts` - `IHrisMetadata` (HRIS) - `src/models/UnifiedHris.ts` - `IAtsMetadata` (ATS) - `src/models/UnifiedAts.ts` - `ICommerceMetadata` (Commerce) - `src/models/UnifiedCommerce.ts` - `ICrmMetadata` (CRM) - `src/models/UnifiedCrm.ts` **Deprecated Field:** ```typescript type?: string; // @deprecated; use format instead ``` **Replacement:** ```typescript format?: TMetadataFormat; // Enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL, etc. ``` **Migration Recommendation:** - **Read Operations:** Replace all references to `metadata.type` with `metadata.format` - **Write Operations:** Use `format` with the appropriate enum value instead of `type` string - **Type Safety:** The `format` field uses strongly-typed enums, providing better type safety and validation **Example:** ```typescript // Before metadata: [{ key: 'age', type: 'number', value: 25 }] // After metadata: [{ slug: 'age', format: 'NUMBER', // Use enum value value: 25 }] ``` --- ## Parent Fields ### Field: `parent_space_id` → `parent_id` ### Field: `parent_page_id` → `parent_id` - the reason was that we standardized across all of our unified data models the field name of `parent_id` when it denoted a parent of the current object (and is the same object) **Affected Models:** - `IKmsSpace` - `src/models/UnifiedKms.ts` - `IKmsPage` - `src/models/UnifiedKms.ts` **Deprecated Field:** ```typescript // In KmsSpace parent_space_id?: string; // @deprecated; use parent_id instead // In KmsPage parent_page_id?: string; // @deprecated; use parent_id instead ``` **Replacement:** ```typescript parent_id?: string; ``` **Migration Recommendation:** - **Read Operations:** Use `parent_id` instead of `parent_space_id` when reading space objects - **Write Operations:** Use `parent_id` when creating or updating spaces **Example:** ```typescript // Before const space = { name: 'Subspace', parent_space_id: 'space_123' } // After const space = { name: 'Subspace', parent_id: 'space_123' } ``` --- ### Field: `parent_channel_id` → `parent_id` **Affected Model:** - `IMessagingChannel` - `src/models/UnifiedMessaging.ts` **Deprecated Field:** ```typescript parent_channel_id?: string; // @deprecated; use parent_id instead ``` **Replacement:** ```typescript parent_id?: string; ``` **Migration Recommendation:** - **Read Operations:** Use `parent_id` instead of `parent_channel_id` when reading channel objects - **Write Operations:** Use `parent_id` when creating or updating channels **Example:** ```typescript // Before const channel = { name: 'Sub-channel', parent_channel_id: 'channel_123' } // After const channel = { name: 'Sub-channel', parent_id: 'channel_123' } ``` --- ### Field: `parent_account_id` → `parent_id` **Affected Model:** - `IAccountingAccount` **Deprecated Field:** ```typescript parent_account_id?: string; // @deprecated; use parent_id instead ``` **Replacement:** ```typescript parent_id?: string; // The parent account ID for this account ``` **Migration Recommendation:** - **Read Operations:** Use `parent_id` instead of `parent_account_id` when reading account objects - **Write Operations:** Use `parent_id` when creating or updating accounts **Example:** ```typescript // Before const account = { name: 'Sub-account', parent_account_id: 'account_123' } // After const account = { name: 'Sub-account', parent_id: 'account_123' } ``` --- ### Field: `parent_message_id` → `parent_id` **Affected Model:** - `IMessagingMessage` **Deprecated Field:** ```typescript parent_message_id?: string; // @deprecated; use parent_id ``` **Replacement:** ```typescript parent_id?: string; // Represents the ID of the immediate predecessor message in the thread ``` **Migration Recommendation:** - **Read Operations:** Use `parent_id` instead of `parent_message_id` when reading message objects - **Write Operations:** Use `parent_id` when creating threaded messages **Example:** ```typescript // Before const message = { message: 'Reply text', parent_message_id: 'msg_123' } // After const message = { message: 'Reply text', parent_id: 'msg_123' } ``` --- ## HRIS Employee Fields ### Field: `department` → `groups` - we unified all HR 'grouping' objects into one called HRIS Group. Groups have types that denote if they are a department, business unit, team, … **Affected Model:** - `IHrisEmployee` - `src/models/UnifiedHris.ts` **Deprecated Field:** ```typescript department?: string; // @deprecated ``` **Replacement:** ```typescript groups?: IHrisGroup[]; // Which groups/teams/units that this employee/user belongs to ``` **Migration Recommendation:** - **Read Operations:** Access department information through the `groups` array - **Write Operations:** Use `groups` array with `IHrisGroup` objects instead of a single `department` string - **Multiple Groups:** The `groups` field supports multiple group memberships, which is more flexible than a single department **Example:** ```typescript // Before const employee = { name: 'John Doe', department: 'Engineering' } // After const employee = { name: 'John Doe', groups: [{ id: 'group_123', name: 'Engineering', type: 'DEPARTMENT' }] } ``` --- ### Field:`division` → `groups` **Affected Model:** - `IHrisEmployee` **Deprecated Field:** ```typescript division?: string; // @deprecated ``` **Replacement:** ```typescript groups?: IHrisGroup[]; // Which groups/teams/units that this employee/user belongs to ``` **Migration Recommendation:** - **Read Operations:** Access division information through the `groups` array - **Write Operations:** Use `groups` array with `IHrisGroup` objects instead of a single `division` string **Example:** ```typescript // Before const employee = { name: 'John Doe', division: 'Product' } // After const employee = { name: 'John Doe', groups: [{ id: 'group_456', name: 'Product', type: 'DIVISION' }] } ``` --- ### Field: `location` → `locations` **Affected Model:** - `IHrisEmployee` - `src/models/UnifiedHris.ts` **Deprecated Field:** ```typescript location?: string; // @deprecated ``` **Replacement:** ```typescript locations?: IHrisLocation[]; // Array of partial location objects ``` **Migration Recommendation:** - **Read Operations:** Access location information through the `locations` array - **Write Operations:** Use `locations` array with `IHrisLocation` objects instead of a single `location` string - **Rich Data:** The `locations` array provides full address details, timezone, currency, and other metadata **Example:** ```typescript // Before const employee = { name: 'John Doe', location: 'San Francisco Office' } // After const employee = { name: 'John Doe', locations: [{ id: 'loc_123', name: 'San Francisco Office', address: { address1: '123 Main St', city: 'San Francisco', region: 'CA', postal_code: '94102', country: 'United States' }, timezone: 'America/Los_Angeles' }] } ``` --- ## Messaging Fields ### Field: `channel_id` → `channels` ### Field: `channel_ids` → `channels` - the reason was so we could include additional channel information, such as the channel `name` , as well as its `id` **Affected Model:** - `IMessagingMessage` - `src/models/UnifiedMessaging.ts` **Deprecated Field:** ```typescript channel_id?: string; // @deprecated ``` **Replacement:** ```typescript channels?: IMessagingChannelMessage[]; // Represents the names of all channels to which the message is sent / belongs to ``` **Migration Recommendation:** - **Read Operations:** Use `channels` array instead of `channel_id` or `channels_ids` - **Write Operations:** Use `channels` array when creating messages - **Multi-Channel Support:** The `channels` array supports messages posted to multiple channels (when the integration supports it) **Example:** ```typescript // Before const message = { message: 'Hello', channel_id: 'channel_123', channels_ids: ["channel_123"] } // After const message = { message: 'Hello', channels: [{ id: 'channel_123', name: 'General' }] } ``` --- ### Field: `root_message_id` → Removed **Affected Model:** - `IMessagingMessage` **Deprecated Field:** ```typescript root_message_id?: string; // @deprecated ``` **Replacement:** - No direct replacement. Use `parent_id` to traverse the thread structure. **Migration Recommendation:** - **Read Operations:** If you need to find the root message, traverse the `parent_id` chain until you reach a message without a `parent_id` - **Write Operations:** Remove references to `root_message_id` when creating messages **Example:** ```typescript // Before const message = { message: 'Reply', parent_message_id: 'msg_123', root_message_id: 'msg_1' } // After const message = { message: 'Reply', parent_id: 'msg_123' // root_message_id removed - traverse parent_id chain if needed } // Helper function to find root message function findRootMessage(message) { while (message.parent_id) { message = getMessage(message.parent_id); } return message; } ``` --- ## Accounting Fields ### Field: `invoice_at` → `posted_at` **Affected Model:** - `IAccountingInvoice` **Deprecated Field:** ```typescript invoice_at?: (string | Date | number); // @deprecated; use posted_at ``` **Replacement:** ```typescript posted_at?: (string | Date | number); ``` **Migration Recommendation:** - **Read Operations:** Use `posted_at` instead of `invoice_at` when reading invoice objects - **Write Operations:** Use `posted_at` when creating or updating invoices - **Consistency:** This change aligns with other accounting objects that use `posted_at` for transaction dates **Example:** ```typescript // Before const invoice = { invoice_number: 'INV-001', invoice_at: '2024-01-15' } // After const invoice = { invoice_number: 'INV-001', posted_at: '2024-01-15' } ``` --- ### Field: `type` → Removed **Affected Model:** - `IAccountingInvoice` **Deprecated Field:** ```typescript type?: TAccountingInvoiceType; // @deprecated ``` **Replacement:** - No direct replacement. Bills are now found in the AccountingBill object, while invoices are solely in the AccountingInvoice object. **Migration Recommendation:** - **Read Operations:** Remove dependencies on `invoice.type` field - **Alternative:** Bills are now found in the AccountingBill object, while invoices are solely in the AccountingInvoice object. --- ### Field: `contact_id` → `contacts` **Affected Model:** - `IAccountingTransaction` - `src/models/UnifiedAccounting.ts` **Deprecated Field:** ```typescript contact_id?: string; // @deprecated; use contacts ``` **Replacement:** ```typescript contacts?: IAccountingTransactionContact[]; ``` **Migration Recommendation:** - **Read Operations:** Use `contacts` array instead of `contact_id` - **Write Operations:** Use `contacts` array when creating transactions, but just include an `id` field - **Multi-Contact Support:** The `contacts` array supports multiple contacts per transaction (when the integration supports multiple contacts) - **Rich Data:** Provides contact names and emails, not just IDs **Example:** ```typescript // Before const transaction = { total_amount: 1000, contact_id: 'contact_123' } // After const transaction = { total_amount: 1000, contacts: [{ id: 'contact_123', name: 'Acme Corp', emails: [{ email: 'billing@acme.com' }] }] } ``` --- ### Field: `income` → `income_sections` **Affected Model:** - `IAccountingProfitloss` - `src/models/UnifiedAccounting.ts` **Deprecated Field:** ```typescript income?: IAccountingProfitlossCategory[]; // @deprecated – use income_sections instead ``` **Replacement:** ```typescript income_sections?: IAccountingProfitlossSection[]; ``` **Migration Recommendation:** - **Read Operations:** Use `income_sections` instead of `income` - **Write Operations:** Use `income_sections` when creating profit/loss reports - **Enhanced Structure:** The new `income_sections` provides a more structured format with better categorization **Example:** ```typescript // Before const profitloss = { start_at: '2024-01-01', end_at: '2024-12-31', income: [{ name: 'Sales', amount: 100000 }] } // After const profitloss = { start_at: '2024-01-01', end_at: '2024-12-31', income_sections: [{ name: 'Sales', accounts: [{ name: 'Product Sales', amount: 100000 }] }] } ``` --- ### Field:`expenses` → `expenses_sections` **Affected Model:** - `IAccountingProfitloss` **Deprecated Field:** ```typescript expenses?: IAccountingProfitlossCategory[]; // @deprecated – use expenses_sections instead ``` **Replacement:** ```typescript expenses_sections?: IAccountingProfitlossSection[]; ``` **Migration Recommendation:** - **Read Operations:** Use `expenses_sections` instead of `expenses` - **Write Operations:** Use `expenses_sections` when creating profit/loss reports **Example:** ```typescript // Before const profitloss = { expenses: [{ name: 'Operating Expenses', amount: 50000 }] } // After const profitloss = { expenses_sections: [{ name: 'Operating Expenses', accounts: [{ name: 'Salaries', amount: 50000 }] }] } ``` --- ### Field:`cost_of_goods_sold` → `cost_of_goods_sold_sections` **Affected Model:** - `IAccountingProfitloss` **Deprecated Field:** ```typescript cost_of_goods_sold?: IAccountingProfitlossCategory[]; // @deprecated – use cost_of_goods_sold_sections instead ``` **Replacement:** ```typescript cost_of_goods_sold_sections?: IAccountingProfitlossSection[]; ``` **Migration Recommendation:** - **Read Operations:** Use `cost_of_goods_sold_sections` instead of `cost_of_goods_sold` - **Write Operations:** Use `cost_of_goods_sold_sections` when creating profit/loss reports **Example:** ```typescript // Before const profitloss = { cost_of_goods_sold: [{ name: 'Materials', amount: 30000 }] } // After const profitloss = { cost_of_goods_sold_sections: [{ name: 'Materials', accounts: [{ name: 'Raw Materials', amount: 30000 }] }] } ``` --- ### Field:`gross_profit_amount` → Calculate from `income_total_amount` and `cost_of_goods_sold_total_amount` **Affected Model:** - `IAccountingProfitloss` **Deprecated Field:** ```typescript gross_profit_amount?: number; // @deprecated – compute using income_total_amount - cost_of_goods_sold_total_amount ``` **Replacement:** ```typescript gross_profit_amount = income_total_amount - cost_of_goods_sold_total_amount ``` **Migration Recommendation:** - **Read Operations:** Calculate `gross_profit_amount` using `income_total_amount - cost_of_goods_sold_total_amount` - **Write Operations:** Do not set `gross_profit_amount` directly **Example:** ```typescript // Before const profitloss = { income_total_amount: 100000, cost_of_goods_sold_total_amount: 30000, gross_profit_amount: 70000 } // After const profitloss = { income_total_amount: 100000, cost_of_goods_sold_total_amount: 30000 // gross_profit_amount removed - calculate it } // Calculate gross profit const gross_profit_amount = profitloss.income_total_amount - profitloss.cost_of_goods_sold_total_amount; ``` --- ### Field: `net_profit_amount` → `net_income_amount` **Affected Model:** - `IAccountingProfitloss` **Deprecated Field:** ```typescript net_profit_amount?: number; // @deprecated – use net_income_amount instead ``` **Replacement:** ```typescript net_income_amount?: number; ``` **Migration Recommendation:** - **Read Operations:** Use `net_income_amount` instead of `net_profit_amount` - **Write Operations:** Use `net_income_amount` when creating profit/loss reports **Example:** ```typescript // Before const profitloss = { net_profit_amount: 50000 } // After const profitloss = { net_income_amount: 50000 } ``` --- ### Field: `IAccountingProfitlossCategory` and `IAccountingProfitlossSubcategory` → Deprecated Types **Deprecated Types:** - `IAccountingProfitlossCategory` - @deprecated - `IAccountingProfitlossSubcategory` - @deprecated **Replacement:** - Use `IAccountingProfitlossSection` instead **Migration Recommendation:** - **Type Definitions:** Update TypeScript interfaces to use `IAccountingProfitlossSection` - **Read Operations:** Use `income_sections`, `expenses_sections`, and `cost_of_goods_sold_sections` which use the new section structure **Example:** ```typescript // Before interface Profitloss { income?: IAccountingProfitlossCategory[]; } // After interface Profitloss { income_sections?: IAccountingProfitlossSection[]; } ``` --- ## ATS (Applicant Tracking System) Fields ### Field:`document_id` → `document_ids` **Affected Model:** - `IAtsActivity` - `src/models/UnifiedAts.ts` **Deprecated Field:** ```typescript document_id?: string; // @deprecated ``` **Replacement:** ```typescript document_ids?: string[]; // IDs for AtsDocument.get ``` **Migration Recommendation:** - **Read Operations:** Use `document_ids` array instead of `document_id` - **Write Operations:** Use `document_ids` array when creating activities - **Multi-Document Support:** Supports multiple documents per activity **Example:** ```typescript // Before const activity = { title: 'Review Resume', document_id: 'doc_123' } // After const activity = { title: 'Review Resume', document_ids: ['doc_123', 'doc_456'] } ``` --- ### Field: `departments` → `groups` **Affected Model:** - `IAtsJob` - `src/models/UnifiedAts.ts` **Deprecated Field:** ```typescript departments?: string[]; // @deprecated Use `groups` instead ``` **Replacement:** ```typescript groups?: IAtsGroup[]; // The departments/divisions/teams that this job belongs to ``` **Migration Recommendation:** - **Read Operations:** Use `groups` array instead of `departments` - **Write Operations:** Use `groups` array with `IAtsGroup` objects instead of department strings - **Rich Data:** Provides group IDs, names, and types (TEAM, GROUP, DEPARTMENT, DIVISION, etc.) **Example:** ```typescript // Before const job = { name: 'Software Engineer', departments: ['Engineering', 'Product'] } // After const job = { name: 'Software Engineer', groups: [{ id: 'group_123', name: 'Engineering', type: 'DEPARTMENT' }, { id: 'group_456', name: 'Product', type: 'DEPARTMENT' }] } ``` --- ## Ticketing Fields ### Field: `category` → `category_id` **Affected Model:** - `ITicketingTicket` - `src/models/UnifiedTicketing.ts` **Deprecated Field:** ```typescript category?: string; // @deprecated; use category_id ``` **Replacement:** ```typescript category_id?: string; ``` **Migration Recommendation:** - **Read Operations:** Use `category_id` instead of `category` string - **Write Operations:** Use `category_id` when creating or updating tickets - **Consistency:** Using `category_id` maintains consistency with other ID-based relationships **Example:** ```typescript // Before const ticket = { subject: 'Support Request', category: 'technical' } // After const ticket = { subject: 'Support Request', category_id: 'category_123' } ``` --- ## Calendar Fields ### Field:`primary` → `is_primary` **Affected Model:** - `ICalendarCalendar` - `src/models/UnifiedCalendar.ts` **Deprecated Field:** ```typescript primary?: boolean; // @deprecated ``` **Replacement:** ```typescript is_primary?: boolean; ``` --- ## Query Parameters & Filters ### `expand_recurring_events` → `expand` **Affected Models:** - `ICalendarEvent` **Deprecated Parameter:** ```typescript expand_recurring_events?: boolean; // @deprecated; use expand ``` **Replacement:** ```typescript expand?: boolean; ``` **Migration Recommendation:** - **API Calls:** Replace `expand_recurring_events` query parameter with `expand` - **More Flexible:** `expand` supports expanding multiple event types, not just recurring events **Example:** ```typescript // Before GET /api/calendar/events?expand_recurring_events=true // After GET /api/calendar/events?expand=true ``` --- ### `end_le` → `end_lt` **Affected Models:** - `ICalendarEvent` - `ICalendarBusy` - `ICalendarRecording` - `IUcCall` - `IUcRecording` - `IHrisTimeoff` - `IHrisTimeshift` - `IAccountingBalancesheet` - `IAccountingCashflow` - `IAccountingProfitloss` - `IAccountingTrialbalance` - `IMessagingMessage` - `IPaymentSubscription` - `IPaymentPayment` - `IPaymentPayout` **Deprecated Parameter:** ```typescript end_le?: string | Date; // @deprecated; use end_lt ``` **Replacement:** ```typescript end_lt?: string | Date; ``` **Migration Recommendation:** - **API Calls:** Replace `end_le` (less than or equal) with `end_lt` (less than) - **Consistency:** Aligns with standard date range filtering conventions **Example:** ```typescript // Before GET /api/calendar/events?end_le=2024-12-31 // After GET /api/calendar/events?end_lt=2025-01-01 // Note: Adjust date by +1 day if you need inclusive end date ``` ``` --- ## Webhook Payload Fields ### Field: `sig` → `sig256` **Affected Interface:** - `IWebhookData` **Deprecated Field:** ```typescript sig?: string; // @deprecated; use sig256 instead ``` **Replacement:** ```typescript sig256?: string; // HMAC-SHA256(workspace.secret, data + nonce) ``` **Migration Recommendation:** - **Webhook Verification:** Use `sig256` instead of `sig` for webhook signature verification - **Security:** `sig256` uses HMAC-SHA256 which is more secure than the previous signature algorithm. `sig` used HMAC-SHA1, which is now deprecated. - **Verification:** Update your webhook verification code to check `sig256` field **Example:** ```typescript // Before const isValid = verifySignature(webhook.sig, payload, secret, 'sha'); // After const isValid = verifySignature(webhook.sig256, payload, secret, ‘sha256'); ``` ``` --- ## Deprecated Objects/Usage Patterns ### `IAccountingInvoice` with `type === 'BILL'` → Use `IAccountingBill` **Status:** Deprecated usage pattern: - Instead of using `IAccountingInvoice` with `type='BILL'`, use the dedicated `IAccountingBill` object **Affected Model:** - `IAccountingInvoice` - `src/models/UnifiedAccounting.ts` **Deprecated Pattern:** ```typescript interface IAccountingInvoice { type: 'BILL', // @deprecated - use IAccountingBill instead ... } ``` **Replacement:** ```typescript interface IAccountingBill { ... } ``` **Key Differences:** - `IAccountingBill` uses `bill_number` instead of `invoice_number` - `IAccountingBill` does not have the deprecated `type` field - `IAccountingBill` does not have the deprecated `invoice_at` field (uses `posted_at` instead) - More semantic clarity: a Bill is clearly distinct from an Invoice **Migration Recommendation:** - **Read Operations:** Use `accounting_bill` endpoints instead of `accounting_invoice` endpoints with `type='BILL'` - **Write Operations:** Use `accounting_bill` create/update endpoints instead of `accounting_invoice` with `type='BILL'` **Field Mapping:** When migrating data: - `invoice_number` → `bill_number` - Remove `type` field (no longer needed) - `invoice_at` → `posted_at` (if still using deprecated field) **API Endpoints:** - ❌ Deprecated: `POST /accounting/invoice` with `type: 'BILL'` - ✅ Use: `POST /accounting/bill` **Example:** ```typescript // Before POST /accounting/invoice { 'type': 'BILL', 'invoice_number': 'BILL-001', 'contact_id': 'contact_123', 'total_amount': 1000 } // After POST /accounting/bill { 'bill_number': 'BILL-001', 'contact_id': 'contact_123', 'total_amount': 1000 } ``` --- ### `IAccountingReport` → Use Individual Report Objects **Status:** Deprecated object - Instead of using `IAccountingReport`, use the individual report objects directly **Affected Model:** - `IAccountingReport` - `src/models/UnifiedAccounting.ts` **Deprecated Object Structure:** ```typescript interface IAccountingReport { type?: TAccountingReportType; // Enum: TRIAL_BALANCE, BALANCE_SHEET, PROFIT_AND_LOSS ... } ``` **Replacement:** Use individual report objects directly **Balance Sheet:** ```typescript interface IAccountingBalancesheet { id?: string; created_at?: (string | Date | number); updated_at?: (string | Date | number); start_at?: (string | Date | number); end_at?: (string | Date | number); name?: string; currency?: string; net_assets_amount?: number; assets?: IAccountingBalancesheetItem[]; liabilities?: IAccountingBalancesheetItem[]; equity?: IAccountingBalancesheetItem[]; raw?: unknown; } ``` **Profit and Loss:** ```typescript interface IAccountingProfitloss { id?: string; created_at?: (string | Date | number); updated_at?: (string | Date | number); category_ids?: string[]; start_at?: (string | Date | number); end_at?: (string | Date | number); name?: string; currency?: string; income_sections?: IAccountingProfitlossSection[]; expenses_sections?: IAccountingProfitlossSection[]; cost_of_goods_sold_sections?: IAccountingProfitlossSection[]; income_total_amount?: number; net_income_amount?: number; expenses_total_amount?: number; cost_of_goods_sold_total_amount?: number; raw?: unknown; } ``` **Trial Balance:** ```typescript interface IAccountingTrialbalance { id?: string; created_at?: (string | Date | number); updated_at?: (string | Date | number); start_at?: (string | Date | number); name?: string; currency?: string; end_at?: (string | Date | number); total_debit_amount?: number; total_credit_amount?: number; sub_items?: IAccountingTrialbalanceSubItem[]; raw?: unknown; } ``` **Cash Flow:** ```typescript interface IAccountingCashflow { id?: string; created_at?: (string | Date | number); updated_at?: (string | Date | number); start_at?: (string | Date | number); end_at?: (string | Date | number); category_ids?: string[]; contact_id?: string; name?: string; // e.g. "Cash Flow Statement Q1 2020" currency?: string; // ISO 4217, e.g. "USD" cash_beginning_amount?: number; // Cash at beginning of period cash_ending_amount?: number; // Cash at end of period net_change_in_cash_amount?: number; // Usually ending - beginning operating_sections?: IAccountingCashflowSection[]; investing_sections?: IAccountingCashflowSection[]; financing_sections?: IAccountingCashflowSection[]; raw?: unknown; } interface IAccountingCashflowSection { section_name?: string; // e.g. "Operating Activities" total_amount?: number; // Net cash provided/used by this section items?: IAccountingCashflowItem[]; } interface IAccountingCashflowItem { account_id?: string; // If attributable to a specific GL account name?: string; // e.g. "Net Income", "Depreciation", "Equipment" amount?: number; // Positive = inflow, Negative = outflow transaction_ids?: string[]; // Optional linkage to transactions sub_items?: IAccountingCashflowItem[]; } ``` **Migration Recommendation:** - **Read Operations:** Instead of using `accounting_report` endpoints, use the specific report endpoints - ❌ Deprecated: `GET /accounting/report?type=BALANCE_SHEET`. ✅ Use: `GET /accounting/balancesheet` - ❌ Deprecated: `GET /accounting/report?type=PROFIT_AND_LOSS`. ✅ Use: `GET /accounting/profitloss` - ❌ Deprecated: `GET /accounting/report?type=TRIAL_BALANCE`. ✅ Use: `GET /accounting/trialbalance` - ✅ Use: `GET /accounting/cashflow` (Cash flow was never part of the deprecated report object, but should be accessed directly) - **Write Operations:** Create individual report objects directly instead of wrapping them in a report object - **Field Access:** Access report data directly from the individual objects instead of through `report.balance_sheet`, `report.profit_and_loss`, etc. **Example:** ```typescript // Before const report = await getAccountingReport({ type: 'BALANCE_SHEET', start_at: '2024-01-01', end_at: '2024-12-31' }); const assets = report.balance_sheet?.assets; // After const balanceSheet = await getAccountingBalancesheet({ start_at: '2024-01-01', end_at: '2024-12-31' }); const assets = balanceSheet.assets; ``` **Benefits of Using Individual Objects:** - **Simpler API:** Each report type has its own dedicated endpoint - **Better Type Safety:** TypeScript can better infer types for specific report objects - **Clearer Intent:** Code is more explicit about which report type is being accessed - **Easier Maintenance:** Individual objects are easier to extend and modify independently --- ## Deprecated Types/Interfaces ### `IAccountingProfitlossCategory` and `IAccountingProfitlossSubcategory` **Status:** Deprecated types - These interfaces are deprecated in favor of `IAccountingProfitlossSection` **Affected Files:** - `src/models/UnifiedAccounting.ts` - `src/models/UnifiedAccounting.joi.ts` (marked as `@deprecated`) - `src/models/UnifiedAccounting.proto` **Deprecated Types:** ```typescript interface IAccountingProfitlossCategory { name?: string; amount?: number; sub_items?: IAccountingProfitlossSubcategory[];} interface IAccountingProfitlossSubcategory { name?: string; amount?: number; transaction_ids?: string[];} ``` **Replacement:** ```typescript interface IAccountingProfitlossSection { section_type?: string; section_name?: string; total_amount?: number; accounts?: IAccountingProfitlossAccount[];} interface IAccountingProfitlossAccount { account_id?: string; account_name?: string; total_amount?: number; transaction_ids?: string[];} ``` **Migration Recommendation:** - Update TypeScript type definitions to use `IAccountingProfitlossSection` - Update code that creates or manipulates profit/loss categories to use sections instead - The new structure provides better organization with accounts grouped under sections --- ## Summary by Category | Category | Deprecated Fields Count | Primary Changes | | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------- | | Metadata | 2 | `key` → `slug`, `type` → `format` (affects 6 models) | | Parent/Relationship | 4 | Various `parent_*_id` → `parent_id` | | HRIS | 3 | `department`, `division`, `location` → `groups`/`locations` | | Messaging | 4 | `channel_id`, `channel_ids`, `parent_message_id`, `root_message_id` → `channels`/`parent_id` | | Accounting | 8 | Multiple field replacements and structural changes | | ATS | 2 | `document_id` → `document_ids`, `departments` → `groups` | | Ticketing | 1 | `category` → `category_id` | | Query Parameters | 3 | Date filter and expand parameter updates | | Webhooks | 1 | `sig` → `sig256` | | **Deprecated Objects** | **2** | `IAccountingInvoice` with `type='BILL'`, `IAccountingReport` | | **Deprecated Types** | **2** | `IAccountingProfitlossCategory`, `IAccountingProfitlossSubcategory` | **Total Deprecated Fields:** - 27 unique fields - 2 deprecated objects - 2 deprecated types across all categories --- ## General Migration Guidelines ### 1. **Backward Compatibility** - Deprecated fields will still be supported until `JANUARY 7, 2026` - Plan to migrate as soon as possible to avoid breaking changes in future API versions ### 2. **Testing** - Test all migrations thoroughly in a development environment - Verify that data reads and writes work correctly with the new fields ### 3. **Data Migration** - For existing data using deprecated fields, consider: - Running a one-time migration script to populate new fields from old fields - Updating your application code to read from both old and new fields during transition - Gradually migrating writes to use only new fields ### **4. Documentation** - Update your internal documentation and code comments - Notify your team about these changes - Update API integration tests to use new field names ## Oracle HCM / Taleo OAuth Credentials Guide URL: https://docs.unified.to/guides/oracle_hcm_taleo_oauth_credentials_guide # Oracle HCM / Taleo OAuth Credentials Guide ------ _April 6, 2026_ To connect Oracle HCM / Taleo to Unified, you only need to collect these 3 values from Oracle: - Client ID - Client Secret - Servername You will enter those directly into Unified. ## **What To Enter In Unified** When creating the Oracle HCM / Taleo connection, fill in: - Client ID - Client Secret - Servername **Example Servername** ## **How To Get These Values From Oracle** **1. Find Your Servername** Ask your Oracle administrator for your Oracle Fusion HCM host name. It usually looks like: `.fa..oraclecloud.com` Example: [`example.fa.us2.oraclecloud.com`](https://example.fa.us2.oraclecloud.com/) This exact value is what you should enter into Unified as the Servername. **2. Create A Confidential Application In Oracle** 1. Sign in to Oracle as an administrator 2. Open Integrated applications 3. Click Add application 4. Choose Confidential Application 5. Enter a name such as Unified Oracle HCM Integration 6. Continue through the setup **3. Enable OAuth Client Access** During application setup, make sure OAuth client access is enabled for the application. For this Unified connection, Oracle should issue credentials for a confidential application that can request access tokens for the HCM / Taleo APIs. Your Oracle administrator may need to: - enable OAuth for the app - allow the app to access the appropriate Oracle HCM / Taleo resources - activate the application once setup is complete **4. Copy The Credentials** After the confidential application is created and activated, open the application details and copy: - Client ID - Client Secret These are the exact values to enter into Unified. ## **Final Checklist** Before creating the connection in Unified, make sure you have: - Client ID - Client Secret - Servername ## **Oracle References** - OAuth configuration: [https://docs.oracle.com/en/cloud/saas/applications-common/25d/oaext/configure-oauth.html](https://docs.oracle.com/en/cloud/saas/applications-common/25d/oaext/configure-oauth.html) - HCM REST API: [https://docs.oracle.com/en/cloud/saas/human-resources/farws/index.html](https://docs.oracle.com/en/cloud/saas/human-resources/farws/index.html) ## Retrieval-Augmented Generation (RAG) URL: https://docs.unified.to/guides/retrieval_augmented_generation_rag # Retrieval-Augmented Generation (RAG) ------ _February 13, 2026_ _Last updated: October 2026_ This page explains how to implement a RAG pipeline using [Unified.to](https://unified.to/). RAG is an implementation pattern built on top of [Unified.to](https://unified.to/)'s APIs. [Unified.to](https://unified.to/) reads records from the source at request time, returns them in one data model per category, and delivers change events as webhooks on supported integrations. You are responsible for embeddings, vector storage and retrieval. At a high level, RAG with [Unified.to](https://unified.to/) follows this sequence: 1. Subscribe to webhooks. 2. Receive created or updated objects. 3. Retrieve full content from the source API. 4. Chunk the content. 5. Generate embeddings. 6. Store embeddings in your vector database. 7. On query, retrieve relevant chunks and generate a response. The pattern is the same across categories. ## What RAG Means in Practice RAG allows you to answer questions using customer data from connected SaaS platforms. Instead of relying only on model training data, your application: - Retrieves relevant customer records. - Supplies those records as context. - Generates a response grounded in that context. [Unified.to](https://unified.to/) reads records directly from the source APIs at request time and sends change events on supported integrations. Records come back: - In the same fields, whichever provider they come from. - As the source holds them at the moment of the request. [Unified.to](https://unified.to/) does not store embeddings or maintain a vector index. ## Step 1: Connect Data Sources Authorize integrations using Embedded Auth. Common RAG source categories include: - File Storage - Knowledge Management - CRM - Ticketing - ATS - Messaging Each authorized integration returns a `connection_id`. RAG pipelines should treat `connection_id` as the tenant boundary. ## Step 2: Subscribe to Object Updates Create webhook subscriptions for the objects you want to index. **Backfill on first subscription** Set `include_all: true` when creating the webhook subscription. This delivers all existing records to your endpoint before transitioning to incremental updates. Pages are delivered with `type: INITIAL-PARTIAL`; the final page is tagged `type: INITIAL-COMPLETE`. After that, the same subscription delivers ongoing changes tagged `NATIVE` or `VIRTUAL` depending on whether the source integration supports native webhooks. Your endpoint receives the same payload structure for backfill and incremental updates, so no separate backfill handler is required. **On created or updated events:** - Your endpoint receives the event. - You call the appropriate retrieve endpoint to fetch the latest version of the object. - If the object contains a `download_url`, fetch the content from that URL. List endpoints often return metadata but not full content. For RAG, you must explicitly retrieve full text before chunking. Native webhooks forward the vendor's events. Virtual webhooks use the same payload and detect changes at an interval you set, as often as every minute on paid plans. Webhooks are recommended over polling for RAG ingestion. ## Step 3: Chunk and Embed Large text (files, pages, tickets, resumes, notes, transcripts) should be split into chunks before embedding. Each chunk should include stable metadata such as: - `connection_id` — tenant boundary; required for all retrieval filters - `object_type` — e.g. `crm_deal`, `ats_candidate`, `ticketing_ticket` - `object_id` — source record ID; used to target re-embedding on update - `updated_at` — timestamp of the source record's last update - `is_latest` — boolean flag indicating whether this chunk is current; set to `false` when a record updates and new chunks are inserted. This field is not returned by [Unified.to](https://unified.to/); you maintain it in your vector store schema. You may also include other returned fields as metadata filters. [Unified.to](https://unified.to/) returns each object in the same fields whichever provider it comes from, so any field it returns can be stored as metadata in your vector index and used for filtering at retrieval time. You generate embeddings using the embedding model of your choice. [Unified.to](https://unified.to/)'s Generative AI API can create embeddings on supported integrations, but embeddings and indexes are stored in your infrastructure. ## Step 4: Store in a Vector Database Store embeddings in your vector database (for example, Pinecone or pgvector). Include metadata to support: - Tenant isolation (`connection_id`) - Object filtering (`object_type`) - Update replacement (`updated_at`) - Permission enforcement (see below) [Unified.to](https://unified.to/) does not: - Cache end-customer payloads - Store embeddings - Maintain a vector index ## Step 5: Retrieve and Generate When a user submits a query: 1. Embed the query. 2. Retrieve the top matching chunks from your vector database. 3. Filter by `connection_id` and any relevant metadata. 4. Pass retrieved context to your model. 5. Return the generated answer. If required, include citations that map back to `object_id` or `web_url`. [Unified.to](https://unified.to/) does not perform retrieval. It delivers change events; your application re-embeds and replaces vectors to keep the index in step with the source. ## Permissions File objects include explicit permissions metadata. Other objects (CRM, ATS, ticketing, messaging) do not return user- or group-level permission structures. If row-level enforcement is required, implement that logic in your application layer before returning retrieved results Always filter retrieval results by tenant and permission constraints before generation. ## How current is the index? For RAG pipelines, how current the index is depends on how changes reach you: - Native webhooks forward the vendor's events. - Virtual webhooks detect changes at an interval you set, as often as every minute on paid plans. - Most list endpoints support pagination and incremental filtering via `updated_gte` To keep embeddings current: - Re-fetch objects on update events. - Re-chunk and re-embed. - Replace outdated vectors in your index. ## When to Use RAG Use this pattern when you need to: - Build enterprise search across connected platforms. - Ground AI responses in customer documents or records. - Enable resume or candidate search. - Support contract or document Q&A. - Build account-level CRM assistants. The ingestion architecture is consistent across categories. Object models and content retrieval methods vary by API. ## Related APIs RAG pipelines commonly use: - [File Storage API](https://docs.unified.to/storage/overview) - [Knowledge Management API](https://docs.unified.to/kms/overview) - [CRM API](https://docs.unified.to/crm/overview) - [Ticketing API](https://docs.unified.to/ticketing/overview) - [ATS API](https://docs.unified.to/ats/overview) - [Messaging API](https://docs.unified.to/messaging/overview) Refer to category documentation for supported objects and webhook availability. ## Related Guides - [How to build Enterprise Search using RAG](https://unified.to/blog/how_to_build_enterprise_search_using_rag) - [ATS to Vector DB: How to Power Talent Intelligence with Real-Time Data](https://unified.to/blog/ats_to_vector_db_how_to_power_talent_intelligence_with_real_time_data) - [How to Build an AI-Powered Meeting Recording Summarization](https://unified.to/blog/how_to_build_an_ai_powered_meeting_recording_summarization_with_unified) - [How to Train AI with CRM Data using Unified's CRM API](https://unified.to/blog/how_to_train_ai_with_crm_data_using_unified_crm_api) - [How to Train AI with HRIS Data with Unified's HR & Directory API](https://unified.to/blog/how_to_train_ai_with_hris_data_with_unified_hr_and_directory_api) [→ Start your 30-day free trial](https://app.unified.to/login) [→ Book a demo](https://calendly.com/d/cph9-g8n-jzg/connect-with-unified) ## SalesForce (External Client Apps) on Multiple Organizations URL: https://docs.unified.to/guides/salesforce_external_client_apps_on_multiple_organizations # SalesForce (External Client Apps) on Multiple Organizations ------ _April 15, 2026_ This guide will walk you through the required steps to support using a Salesforce External Client App (ECA) across multiple organizations. When an ECA is created in Salesforce, it can only connect to users within the organization that created it. To allow the ECA to be used across multiple organizations, you must complete the required setup steps before it can connect to users outside the original organization. The customer's Salesforce admin does not need to give anyone their Salesforce credentials, they can run the second set of steps below. > ⚠️ This setup resolves the error 'Cross-org OAuth flows are not supported for this external client app'. That error usually means the External Client App was created in your SourceOrg but has not yet been installed/deployed/approved in the customer's TargetOrg. ### Terminology | Term | Meaning | | ----------------------- | ----------------------------------------------------------------------- | | SourceOrg | The Salesforce org where the External Client App was created | | TargetOrg | The customer's Salesforce org where users are trying to connect | | APP_API_NAME | API Name of the External Client App | | OAUTH_SETTINGS_API_NAME | Metadata name of the OAuth settings attached to the External Client App | 1. The first thing you need to do is create an ECA within Salesforce. If you have already done this proceed to step 2, otherwise check out our [how to guide](https://unified.to/blog/how_to_register_a_salesforce_developer_app_and_get_oauth_2_credentials). ![Screenshot_2026-04-29_at_11.06.56_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/salesforce_external_client_apps_on_multiple_organizations-0.png) 1. [Install SalesForce CLI](https://developer.salesforce.com/tools/salesforcecli) 2. Generate a SalesForce project ```shell sf project generate --name unified-sf ``` 3. Navigate into project ```shell cd unified-sf ``` 4. Login to your Source organization, this is the organization where you have created the External client app ```shell sf org login web --alias SourceOrg ``` 5. Retrieve the app metadata (Replace APP_API_NAME with your app name, which can be found at Setup → External Client Apps → open your app → settings → basic information → API Name ```shell sf project retrieve start --metadata ExternalClientApplication:APP_API_NAME --target-org SourceOrg sf project retrieve start --metadata ExtlClntAppOauthSettings --target-org SourceOrg ``` > ℹ️ After retrieval, look in `force-app/main/default/extlClntAppOauthSettings/` for a file like `salesforce-test-app_oauth.extlClntAppOauthSettings-meta.xml`. The part before `.extlClntAppOauthSettings-meta.xml` is your `OAUTH_SETTINGS_API_NAME`. It is usually NOT the same as `APP_API_NAME` (it often ends with `_oauth`), so confirm it from the retrieved file name instead of guessing. 6. Create a zip of the project folder to give to the customer who wants to install your app. ```shell zip -r ../my_project.zip . --exclude "*/.git/*" --exclude "*/.sfdx/*" ``` Customer Steps (Customer Salesforce admin) 1. Customer opens the project folder ```javascript cd my_project ``` 1. [Install SalesForce CLI](https://developer.salesforce.com/tools/salesforcecli) 2. Customer admin logs into the TargetOrg ```javascript sf org login web --alias TargetOrg ``` 1. The customer will need to make some changes within their Salesforce to deploy the app. In the SalesForce Dev Hub > Settings > Setup > Dev Hub, enable both 'Enable Unlocked Packages and Second-Generation Managed Packages' and 'Enable Dev Hub' ![Screenshot_2026-04-15_at_3.37.45_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/salesforce_external_client_apps_on_multiple_organizations-1.png) ![Screenshot_2026-04-15_at_1.44.39_PM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/salesforce_external_client_apps_on_multiple_organizations-2.png) 1. Customer deploys the ECA app ```javascript sf project deploy start --metadata ExternalClientApplication:APP_API_NAME --target-org TargetOrg sf project deploy start --metadata ExtlClntAppOauthSettings:APP_API_NAME_oauth --target-org TargetOrg ``` > ℹ️ Replace `APP_API_NAME_oauth` above with the real `OAUTH_SETTINGS_API_NAME` from the retrieved file name (it may differ from `APP_API_NAME`). Alternative: deploy both metadata types together: ```shell sf project deploy start --metadata ExternalClientApplication --metadata ExtlClntAppOauthSettings --target-org TargetOrg ``` 1. The customer can then verify that the app has been added to their organization by visiting Setup → External Client Apps → external client app manager where they should see your app installed. ![Screenshot_2026-04-29_at_11.06.47_AM.png](https://s3.us-east-2.amazonaws.com/unified-article-images/salesforce_external_client_apps_on_multiple_organizations-3.png) 1. Configure and approve access in the TargetOrg. The admin should review and configure: - Permitted users - Profiles / permission sets - OAuth policies - Refresh token policy - IP restrictions, if applicable - Admin-approved users, if the org requires admin approval 2. The customer can now create a connection to your app Once the app is deployed and access is granted, the end user can retry the normal Salesforce connection flow through Unified and the cross-org OAuth error will be resolved. ## Long-term application-distribution recommendation For repeated customer onboarding, the preferred long-term approach is to package the External Client App as a Salesforce package and share a package install link with customer Salesforce admins, instead of asking each admin to manually run metadata deployment commands. 1. You create the External Client App once in SourceOrg. 2. You package the app and OAuth settings. 3. You share the package install link with customer admins. 4. The customer Salesforce admin installs the package into TargetOrg. 5. The customer Salesforce admin approves/configures access. 6. End users connect normally through OAuth. ## SAML Single-Sign-On URL: https://docs.unified.to/guides/saml_single_sign_on # SAML Single-Sign-On ------ _September 28, 2025_ SAML (Security Assertion Markup Language) is an XML-based standard for exchanging authentication and authorization data between parties, particularly between an identity provider (IdP) and a service provider (SP). [Unified.to](https://unified.to/) supports SAML sign-in to your workspace with JumpCloud, Okta, Microsoft Entra and Google Workspace Directory, on the Pro and Scale plans (as of 8 October 2026). [Let us know](https://unified.to/contact) if you need another SAML identity provider. Here's how SAML works: ## **SAML Authentication Flow** **Key Components:** 1. **Identity Provider (IdP)** - The system that authenticates users (e.g., Active Directory, Okta, Azure AD) 2. **Service Provider (SP)** - The application the user wants to access (e.g., your Unified.to app) 3. **User** - The person trying to log in 4. **SAML Assertion** - XML document containing authentication/authorization information **Typical SAML SSO Flow:** 1. 2. 3. 4. **Detailed Steps:** 1. **User Access Request** - User visits your application and clicks "SAML SSO" login - Application generates a SAML Authentication Request (AuthnRequest) 2. **Redirect to Identity Provider** - User is redirected to their organization's IdP - AuthnRequest contains information about the SP and requested attributes 3. **User Authentication** - User enters their organizational credentials - IdP validates the credentials 4. **SAML Response Generation** - IdP creates a SAML Response containing a SAML Assertion - Assertion includes user identity, authentication method, session info, etc. 5. **Response Processing** - IdP redirects user back to SP with the SAML Response - SP validates the assertion signature and extracts user information - User is logged into the application **SAML Assertion Contents:** - **Subject**: Who the user is (NameID, email, etc.) - **Authentication Statement**: How they were authenticated - **Attribute Statement**: Additional user attributes (roles, groups, etc.) - **Conditions**: Validity period, audience restrictions - **Signature**: Cryptographic proof of authenticity **Common SAML Bindings:** - **HTTP Redirect**: Data passed via URL parameters - **HTTP POST**: Data posted in form fields - **HTTP Artifact**: Reference token exchanged for full assertion **Security Features:** - **Digital Signatures**: Ensure assertions haven't been tampered with - **Encryption**: Protect sensitive data in transit - **Time-based Conditions**: Assertions expire after set time - **Audience Restrictions**: Limit which SPs can use the assertion **Configuration Requirements:** **For Service Provider (your app):** - SAML metadata XML with entity ID, ACS URL, certificate - User attribute mapping (email, name, roles) - Certificate for signature validation **For Identity Provider:** - SP metadata containing endpoints and certificate - User attribute configuration - SSO URL configuration This is why SAML is popular for enterprise SSO - it provides secure, standardized authentication that integrates with existing corporate identity systems without requiring users to manage separate passwords for each application. ## **JumpCloud SAML Integration Setup** **1. JumpCloud Configuration (Identity Provider Side)** ### **Step 1: Create SSO Application in JumpCloud** 1. Log into JumpCloud Admin Portal 2. Navigate to **SSO Applications** 3. Click **+ Add New Application** 4. Search for "Custom SAML Application" or "Custom App" 5. Configure the application: - **Display Label**: "Unified.to" (or your app name) - **IdP Entity ID**: https://sso.jumpcloud.com/saml2/unified-to (or your preferred ID) - **SP Entity ID**: Your application's entity ID (e.g., https://api.unified.to/saml/metadata) ### **Step 2: Configure SAML Attributes** In the JumpCloud SSO configuration: - **ACS URL**: `https://api.unified.to/saml/acs` (your assertion consumer service endpoint) - **Audience**: https://api.unified.to/saml/metadata - **Name ID Format**: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - **Attribute Mappings**: - email → https://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress - firstName → https://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname - lastName → https://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname - username → https://schemas.xmlsoap.org/ws/2005/05/identity/claims/name ### **Step 3: Get JumpCloud Metadata** - Download the IdP metadata XML from JumpCloud - Note the **SSO URL** (usually https://sso.jumpcloud.com/saml2/unified-to) - Get the **X.509 Certificate** for signature validation # Configure SAML on Unified If you haven't registered an account, sign-in with any of our available Social or OAuth2 OIDC login options. Make sure to choose the relevant data region. Then proceed to the workspace settings and choose [SAML](https://app.unified.to/settings/saml). Select your Identity Provider. Either upload the SAML manifest XML file or input the SAML configuration settings from your identity provider. You can choose to restrict sign-ins to just SAML for your workspace. All existing and invited workspace members will then need to sign-in with SAML. ## Scaling MCP Tools with Anthropic's (& OpenAI's) Defer Loading URL: https://docs.unified.to/guides/scaling_mcp_tools_with_anthropic_and_openai_defer_loading # Scaling MCP Tools with Anthropic's (& OpenAI's) Defer Loading ------ _December 25, 2025_ Learn how to use Anthropic's and OpenAI's `defer_loading` tool search features with Unified's MCP server to efficiently manage hundreds of tools while maintaining high accuracy and context efficiency. When building AI applications with MCP servers, including the [Unified MCP server](https://www.notion.so/mcp/overview), you quickly encounter a critical challenge: most LLM models struggle with large numbers of tools. While Unified can provide thousands of tools across different integrations, traditional approaches hit two key limitations: - **Context window bloat**: Tool definitions consume massive portions of your context (50 tools ≈ 10-20K tokens) - **Tool selection degradation**: An LLM's ability to correctly select tools degrades significantly beyond 30-50 tools Anthropic's new **defer_loading** feature solves both first problems by dynamically discovering and loading tools on-demand instead of loading all tool definitions upfront. ## The Problem: Too Many Tools The Unified MCP server can expose tools from any connected integration— CRM, ATS, HRIS, ticketing, storage, and more. A single connection might offer 50+ tools, and with multiple integrations, you could easily have 200+ available tools. Overall, the Unified MCP server currently supports 22,566 tools. Traditional approach problems: - Loading 200 tool definitions uses 40,000-80,000 tokens - The LLM API struggles to select the correct tool from such a large set - You waste context on tools that won't be used in that conversation ## The Solution: Anthropic's Defer Loading Tool Search Anthropic's `defer_loading` feature works with two tool search variants: ### Tool Search Variants **1. Regex Tool Search** (`tool_search_tool_regex_20251119`) - Claude constructs regex patterns to search for tools - Best for exact matches and pattern-based discovery - Fast and efficient for well-named tools **2. BM25 Tool Search** (`tool_search_tool_bm25_20251119`) - Claude uses natural language queries to search - Better for semantic understanding - More flexible for varied naming conventions ### How It Works 1. You include a tool search tool in your tools list 2. You provide all tool definitions with `defer_loading: true` 3. Claude sees only the tool search tool initially 4. When Claude needs additional tools, it searches dynamically 5. The API returns 3-5 most relevant tools 6. These are automatically expanded into full definitions 7. Claude selects and invokes the appropriate tool ## Implementation To use the new `defer_loading` tool option, follow these instructions (found [here](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool#mcp-integration)) when calling Anthropic's ``/v1/messages`` API: - Add `"mcp-client-2025-11-20"`  to the `anthropic-beta` header: `anthropic-beta: advanced-tool-use-2025-11-20,mcp-client-2025-11-20` - Add the Unified MCP server's URL for your region (US `https://mcp-api.unified.to/mcp`, EU `https://mcp-api-eu.unified.to/mcp`, AU `https://api-au.unified.to/mcp`). Pass a connection-scoped signed token (generated with `get_unified_connection_mcp_url`, mode=user; works for one connection only) in `authorization_token`, never in the URL, and keep your workspace API key on your server. ```json "mcp_servers": [ { "type": "url", "name": "unified-salesforce-server", "url": "https://mcp-api.unified.to/mcp?connection=y", "authorization_token": "" } ], ``` - Then add an additional `tools` array with configuration on which tools to defer: ```json "tools": [ { "type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex" }, { "type": "mcp_toolset", "mcp_server_name": "unified-salesforce-server", "default_config": { "defer_loading": true }, "configs": { "list_crm_contacts": { "defer_loading": false } } } ], ``` - Use the `default_config.defer_loading: true` option to make all tools deferrable - Use the `configs.{tool_id}.defer_loading: true/false` to set an individual tool to defer (or not) Or, alternatively, you can call the Unified MCP `/tools` endpoint with parameters `?type=anthropic&defer_tools=all` and then feed that `tools` result into Anthropic's API. ## Best Practices ### 1. Keep Core Tools Non-Deferred If you have 3-5 tools that are frequently used, keep them as non-deferred. ### 2. Use Permissions to Scope Tools Reduce the tool catalog size by requesting only the permissions you need: ```typescript // Instead of all tools across all categories permissions: 'crm_contact_read,crm_contact_write,crm_deal_read' // This is better than loading 200+ tools from all categories ``` ### 3. Restrict Tools Use the Unified MCP `tools` parameter to restrict which tools will be returned back to the LLM API. This has a different effect than the `defer_tools` parameter as it doesn't return restricted tools to the LLM API at all, while deferring tools means that the LLM API knows about the tool, but doesnt process it until it is needed. ### 4. Monitor Token Usage Track your token consumption to understand the benefits: ```typescript console.log(`Input tokens: ${response.usage.input_tokens}`); console.log(`Output tokens: ${response.usage.output_tokens}`); console.log(`Tool search requests: ${response.usage.server_tool_use?.tool_search_requests}`); ``` ### 5. Combine with Prompt Caching Use [prompt caching](https://www.notion.so/guides/building_ai_applications_with_unified_and_langbase) with defer_loading for multi-turn conversations: ```typescript messages.push({ role: "user", content: "Now find their recent deals", cache_control: { type: "ephemeral" } }); ``` ## Tool Search Limits Be aware of these limits: - **Maximum tools**: 10,000 tools in your catalog - **Search results**: Returns 3-5 most relevant tools per search - **Pattern length**: Maximum 200 characters for regex patterns - **Model support**: Claude Sonnet 4.5+ and Opus 4.5+ only ## When to Use Defer Loading **Good use cases:** - 20+ tools available from Unified connections - Multiple integrations (CRM + ATS + HRIS + Storage) - Building multi-tenant applications where each tenant has different integrations - Context window is getting tight with tool definitions - Tool selection accuracy is degrading **When traditional tool calling might be better:** - Less than 10 tools total - All tools are frequently used in every request - Very focused single-integration use case ## Real-World Example: Multi-Integration Assistant Here's a practical example of a customer support assistant that accesses multiple integrations: ```typescript async function createSupportAssistant(crm_connection_id, hris_connection_id, zendesk_connection_id) { // Fetch tools from multiple Unified connections const crmTools = await fetchUnifiedTools(crm_connection_id, 'crm_contact_read,crm_deal_read', { type: 'anthropic', defer_tools: 'list_crm_'}); const ticketingTools = await fetchUnifiedTools(zendesk_connection_id, 'ticketing_ticket_read,ticketing_ticket_write', { type: 'anthropic', defer_tools: 'list_crm_'}); const hrisTools = await fetchUnifiedTools(hris_connection_id, 'hris_employee_read', { type: 'anthropic', defer_tools: 'list_crm_'}); // Combine all tools with defer_loading const tools = [ ...crmTools, ...ticketingTools, ...hrisTools ]; // Total: 150+ tools, with all listX tools being deffered return await anthropic.beta.messages.create({ model: "claude-sonnet-4-5-20250929", betas: ["advanced-tool-use-2025-11-20"], max_tokens: 4096, messages: [{ role: "user", content: "Customer John Doe from Acme Corp called about ticket #12345. Show me his account info, open tickets, and any recent deals." }], tools: tools }); } ``` ## Resources - [Unified MCP Server Overview](https://docs.unified.to/mcp/overview) - [MCP Installation & Usage](https://docs.unified.to/mcp/installation) - [MCP Server Options](https://docs.unified.to/mcp/server-options) - [Anthropic Tool Search Documentation](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool) - [Anthropic MCP Integration Guide](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool#mcp-integration) - [Best MCP Servers for Connecting AI Agents to SaaS APIs](https://unified.to/blog/best_mcp_servers_for_connecting_ai_agents_to_saas_apis): how ten MCP servers compare on tool filtering, deferred loading and scope ## TLDR Anthropic's `defer_loading` feature is a game-changer for building AI applications with Unified's MCP server. By the LLM API dynamically loading tools on-demand, you can: - **Scale to hundreds of tools** across multiple integrations - **Reduce context usage** by 80-90% - **Improve tool selection accuracy** significantly - **Build more capable AI assistants** that access diverse data sources Start by adding the tool search tool and marking your Unified MCP tools as deferred. Monitor your token usage and tool selection accuracy to see the immediate benefits. The combination of Unified's extensive integration network and Anthropic's intelligent tool search opens up possibilities for building truly comprehensive AI agents that can work across your entire SaaS ecosystem. ## Set Environments for your Unified.to Workspace URL: https://docs.unified.to/guides/set_environments_for_your_unified_workspace # Set Environments for your Unified.to Workspace ------ _June 7, 2023_ We're eager to share the release of environments, a new feature that makes it easier to test and customize your integrations before releasing them to production. For example, you can create environments for staging and production, which gives you more control over testing integrations with our Unified API. [**Try it now**](https://app.unified.to/?utm_source=blog&utm_medium=blog&utm_campaign=environments&utm_content=environments_blog) ## What is an environment? Environments are configurable spaces that allow you to specify different integration credentials for different circumstances. Determine distinct access and configuration rules for various scenarios, such as production, staging, and testing, which gives you more flexibility and control over managing your release processes. Environments can be added under any plan in your Unified.to account's Workspace. ![UnifiedAPI_Environments.png](https://s3.us-east-2.amazonaws.com/unified-article-images/set_environments_for_your_unified_workspace-0.png) ## Capabilities of environments - **Integration testing:** Before releasing integrations to production, you can now test them in a different environment. - **Multiple environments:** You can set up multiple environments such as production, staging, and others personalized to you and your team's needs. - **Environment-specific integrations:** You can now decide which integrations are active in each environment. - **Application credential assignment:** Choose which application credentials to use for each integration in each environment. ## Benefits of environments - **Greater flexibility:** You can tailor your integrations to meet the unique needs of each environment including client specifications. - **Reduced errors:** Testing integrations in a staging environment before moving them to production helps minimize mistakes and unexpected errors. - **Increased productivity:** This feature streamlines the management of your workspace, reducing time spent on setup and adjustments. ## When to use multiple environments Set multiple environments regardless of what plan you're on for different scenarios: **1. Localhost** _**Create a sandbox environment without disrupting your team's workflow or end-user experience**_ Example: Suppose Laura, a developer, is working on integrating a new batch of HR services. She creates her own personal authentication environment within Unified.to, complete with application credentials specific to her environment. This allows Laura to experiment freely without affecting her app's live features or her fellow developers' work. **2. Testing or QA** _**Try out new integrations and run tests before releasing them to production**_ Example: Once Laura has completed her initial development work, she simply copies the credentials to the testing environment. This environment has its own set of credentials and often uses mocked or dummy data for testing purposes. In this stage, Laura and her team run a battery of tests to ensure the new HR integrations work as expected and don't disrupt other functionalities within the app. **3. Staging** _**Mirror your production environment to finely-tune performance and perform final tests**_ Example: After the new HR integrations pass all tests in the testing environment, it's promoted to the staging environment. This environment is a mirror image of the production environment, complete with the same application credentials used in production. The HR integrations are tested under conditions that closely mimic the live application. This final testing phase ensures the new batch of integrations are ready for deployment in the production environment. **4. Production** _**Your live environment where your integrations run and are available to end-users**_ Example: This is the live environment where the application operates, interacts with real data, and serves actual users. It uses production application credentials. With the successful deployment of the new HR integrations, all users of the app can now benefit from the new integrations Laura has been working on. ## How to set new environments Implement this new feature in seconds. Follow these steps: **Setting up Environments** 1. Log in to or create a [Unified.to account](https://app.unified.to/?utm_source=blog&utm_medium=blog&utm_campaign=environments&utm_content=environments_blog) 2. Go to **Workspace Settings** and insert the name of your new environment, (e.g., 'Staging') and press enter 3. Once entered, your environment can be used for configuring individual integrations and generating an Embedded Directory **Configuring Integrations** 1. Navigate to **Integrations** and click ACTIVATE on any integration, for example, Workday ![CleanShot_2023-06-08_at_09.12.242x.png](https://s3.us-east-2.amazonaws.com/unified-article-images/set_environments_for_your_unified_workspace-1.png) 2. From the dropdown menu, select the environment where you to want to add credentials, such OAuth Client ID and Secret ![Workday_Environment_Selection.png](https://s3.us-east-2.amazonaws.com/unified-article-images/set_environments_for_your_unified_workspace-2.png) 3. Input your credentials for Workday for the selected environment and click ACTIVATE ![Note: If you are activating an integration that supports API key and doesn't require OAuth credentials, then you can just click ACTIVATE.](https://s3.us-east-2.amazonaws.com/unified-article-images/set_environments_for_your_unified_workspace-3.png) 4. Repeat for any integration and environment ## Embed Directory After enabling integrations for a specific environment, you can generate an Embedded Directory for that environment. 1. Select the desired authentication environment from the dropdown menu. ![Embedded_Directory_Environment.png](https://s3.us-east-2.amazonaws.com/unified-article-images/set_environments_for_your_unified_workspace-4.png) 2. This will generate a script with a new environment parameter for you. ![Directory_Script.png](https://s3.us-east-2.amazonaws.com/unified-article-images/set_environments_for_your_unified_workspace-5.png) If you're using our API to pull a list of active integrations for your directory, you can pass the environment query parameter to retrieve the correct integrations. ## Building the ultimate Unified API solution for integrations This new authentication environment feature is just one of the many ways we're making Unified.to work better for developers who want to build API-first integrations with industry-leading HR, Sales, Marketing, and Support apps. We're excited for you to try Authentication Environments and see the difference it makes in managing your integrations pre-launch. Our team is here to support you every step of the way. Enjoy exploring Unified.to and let us know if you need a hand. You can try setting Unified.to's Authentication Environments at [unified.to/get-started](https://app.unified.to/?utm_source=blog&utm_medium=blog&utm_campaign=environments&utm_content=environments_blog). ## Setting up a Slack Bot Connection with Unified.to URL: https://docs.unified.to/guides/setting_up_a_slack_bot_connection_with_unified # Setting up a Slack Bot Connection with Unified.to ------ _June 16, 2026_ > This guide walks you through connecting Slack Bot (slackbot) to Unified.to — including the Slack app settings you need for sending messages, interactive buttons, and receiving button-click events as Unified MessagingEvent webhooks. --- ## Overview A Slack Bot integration has three parts: | Part | Where | Purpose | | -------------------- | ------------------------ | ----------------------------------------------------------------------------- | | Slack app | api.slack.com | OAuth credentials, bot scopes, and request URLs Slack calls when events occur | | Connection | Unified dashboard | Authorizes your Slack workspace and stores the bot token | | Webhook subscription | Unified dashboard or API | Tells Unified where to deliver normalized MessagingEvent payloads | You can use Unified's shared Slack OAuth app (fastest) or register your own Slack app if you need full control over the app configuration. --- ## Important: two different URLs Slack and your application use different webhook URLs. Do not point Slack at the same URL you use as your Unified hook_url. | URL | Configured in | Receives | | ------------------------- | --------------------------------------------------------- | -------------------------------------------------------- | | Unified workspace webhook | Slack → Event Subscriptions & Interactivity → Request URL | Raw Slack Events API and interactive payloads from Slack | | Your hook_url | Unified → Webhooks → Create Webhook | Normalized Unified MessagingEvent objects | Flow: Slack → Unified workspace webhook → Unified converts the event → POST to your hook_url. Use your regional Unified API host and include your workspace ID, for example: https://api.unified.to/webhook/workspace/slackbot?workspace_id=YOUR_WORKSPACE_ID EU and AU workspaces should use the corresponding regional API host (for example api-eu.unified.to or api-au.unified.to). --- ## Step 1 — Create a Slack app 1. Go to https://api.slack.com/apps → Create New App → From scratch. 2. Name the app and select the Slack workspace you want to connect. 3. Click Create App. --- ## Step 2 — Add OAuth scopes Open OAuth & Permissions → Scopes → Bot Token Scopes and add the scopes for the objects you plan to use. Minimum for sending messages, reading history, and receiving button clicks: | Scope | Purpose | | ------------------------------------------------------------- | ------------------------------------------- | | chat:write | Send messages and messages with buttons | | channels:read / groups:read / im:read / mpim:read | List channels and DMs | | channels:history / groups:history / im:history / mpim:history | Read message history | | users:read / users:read.email / users.profile:read | Resolve message authors and button clickers | | reactions:read | Reaction events (optional) | | files:read / files:write | Attachments (optional) | > These mirror the messaging_message_write and messaging_event_read scope sets for the slackbot integration. Add additional scopes only if you use those objects. --- ## Step 3 — Set the OAuth redirect URL 1. Under OAuth & Permissions → Redirect URLs, click Add New Redirect URL. 2. Paste the Unified OAuth callback URL shown in the dashboard when you connect Slack (bot). It looks like https://api.unified.to/oauth/code (use your region's host if applicable). 3. Save URLs. --- ## Step 4 — Enable Interactivity 1. Go to Interactivity & Shortcuts and turn Interactivity ON. 2. Set Request URL to your Unified workspace webhook (not your hook_url): https://api.unified.to/webhook/workspace/slackbot?workspace_id=YOUR_WORKSPACE_ID 3. Save. Unified receives Slack block_actions payloads here, converts button clicks into MessagingEvent objects with type BUTTON_CLICK, and forwards them to your hook_url. --- ## Step 5 — (Optional) Enable Event Subscriptions Enable this if you also want message, reaction, or channel membership events (not just button clicks). 1. Go to Event Subscriptions and turn it ON. 2. Set Request URL to the same Unified workspace webhook URL as Step 4: https://api.unified.to/webhook/workspace/slackbot?workspace_id=YOUR_WORKSPACE_ID 3. Under Subscribe to bot events, add the events you need, for example message.channels, app_mention, reaction_added, member_joined_channel. 4. Save. --- ## Step 6 — Activate the integration, create a connection, and add a webhook In the Unified dashboard (or via API), complete all three steps below. 1. Go to Integrations and activate Slack (bot) for your environment. If you use your own Slack app, enter your Client ID and Client Secret. 2. Create a connection for the Slack (bot) integration and complete the OAuth flow to install the app into your Slack workspace. 3. Create a webhook on that connection with object type messaging_event and event created. Set hook_url to your application endpoint (the URL where you want to receive Unified payloads). Your hook_url is only configured on the Unified webhook subscription. Slack should always send events to the Unified workspace webhook from Steps 4–5. --- ## Receiving events When a user clicks a button or triggers a subscribed Slack event, Unified POSTs a normalized payload to your hook_url. Button clicks arrive as MessagingEvent objects with type BUTTON_CLICK — not as raw Slack block_actions JSON. See the Unified webhook documentation for payload structure, signatures, and verification. --- ## Troubleshooting | Symptom | Likely cause | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Slack URL verification fails | Request URL is missing workspace_id, uses the wrong regional host, or Unified cannot be reached from Slack | | Button clicks never reach my application | Interactivity Request URL is not set to the Unified workspace webhook, or no Unified webhook with hook_url was created | | My endpoint receives raw Slack JSON | Slack Request URL was set to your hook_url instead of the Unified workspace webhook | | invalid_auth or missing scopes | Re-install the Slack app after adding scopes (Slack requires re-consent) | ## Setting up OAuth 2 Credentials for Greenhouse's APIs URL: https://docs.unified.to/guides/setting_up_oauth_2_credentials_for_greenhouse_apis # Setting up OAuth 2 Credentials for Greenhouse's APIs ------ _May 27, 2024_ ## Important — Read this first! Unified.to has _two_ integrations name with the name 'Greenhouse': 1. The first one that appears in the **Integrations** page is for Greenhouse's [**Harvest API**](https://support.greenhouse.io/hc/en-us/articles/360029266032-Harvest-API-overview) **(v3)**, which uses OAuth2 code-flow to authorize your application. For most of the use-cases, you should use this integration. 2. The second one that appears in the **Integrations** page is for Greenhouse's [**Candidate Ingestion API**](https://developers.greenhouse.io/candidate-ingestion.html), which uses OAuth 2 client-credentials-flow to authorize your application. # Greenhouse Harvest API (v3) 1. [Integration request form](https://www.greenhouse.com/integration-partner#apply-now) should be submitted by every new company. _Applications are reviewed by Greenhouse on the first Monday of every month._ **If a company is already an official Greenhouse partner with a signed agreement, they may proceed directly to step 3.** 2. After completing a review, Greenhouse will share API agreement with company 3. Once the agreement is signed, the company should follow the steps [here](https://harvestdocs.greenhouse.io/docs/harvest-partner-oauth) to have a client application created for their integration. They will provide Greenhouse with a redirect_uri (https://api.unified.to/oauth/code) that will be attached to their specific integration's client application. Greenhouse will provide the client credentials (client_id and client_secret) 4. The company will input their (2) credentials in [https://app.unified.to/integrations/greenhouse](https://app.unified.to/integrations/greenhousehttps://app.unified.to/integrations/greenhouse). Unified will handle all authorization: 1. Access authorization, token management, refreshes and storage ([instructions found here](https://harvestdocs.greenhouse.io/docs/harvest-partner-oauth)) 2. Proxy requests on behalf of the originating partner to harvest v3 APIs 3. Secure access schemes to proxy APIs from originating partner to UAPI provider that ensure no cross contamination 5. The company will complete the remaining onboarding steps with Greenhouse. # Greenhouse Ingestion API ## Submit information about your application to Greenhouse Before you can make use of Unified.to's Embedded Authorization component to let your users authorize Greenhouse to access their accounts, you will need to provide Greenhouse with the following: 1. **Application Name**: The name of your application as it would appear in Greenhouse. 2. **Application URL**: The URL of your application. 3. **Callback URL**: After the end user authorizes your application to access their account, Greenhouse will redirect them to this URL. Provide this one: `https://api.unified.to/oauth/code` 4. **Logo Image**: A 128 by 128 pixel image that Greenhouse will include in their permissions modal. ## Receive the Consumer Key and Consumer Secret provided by Greenhouse When Greenhouse receives the information listed above, they will provide a set of values including these two: - A **consumer key**, which uniquely identifies your application - A **consumer secret**, which acts as proof of your application's identity As its name implies, the consumer secret is confidential. For security purposes, Greenhouse will encrypt the consumer secret before emailing it to you (they'll explain how to decrypt it). ## Activate Greenhouse in Unified.to With your application registered with Greenhouse and the OAuth 2 credentials in hand, you can now activate the Greenhouse integration in Unified.to. In Unified.to, go to the [**Active Integrations**](https://app.unified.to/integrations) page, find the Greenhouse Ingestion API item (and _not_ the Greenhouse Harvest API one) and click on it: ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/setting_up_oauth_2_credentials_for_greenhouse_apis-0.png) On the page that appears, do the following: 1. Select **Your OAuth 2 credentials**. 2. Enter the consumer key value into the **OAuth 2 Client ID** text field. 3. Enter the consumer secret value into the **OAuth 2 Client Secret** text field. 4. Click the **Activate** button to save the changes and activate the Greenhouse integration. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/setting_up_oauth_2_credentials_for_greenhouse_apis-1.png) ## Trello Connection Guide in Unified URL: https://docs.unified.to/guides/trello_connection_guide_in_unified # Trello Connection Guide in Unified ------ _April 17, 2026_ ### Overview To create a Trello connection in Unified, you need to: - Create a Trello app - Copy its API key - Allow Unified domains as **Allowed Origins** --- ### Steps **1. Create a Trello App** - Go to **Trello Power-Ups Admin** - Create a new app - Open the app details page **2. Get API Key** - Navigate to the **API Key** section - Copy your Trello API key **3. Configure in Unified** - Go to:[https://app.unified.to/integrations/trello](https://app.unified.to/integrations/trello) - Paste the API key into the required field **4. Add Allowed Origins in Trello** ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/trello_connection_guide_in_unified-0.png) Add the following URLs in your Trello app settings: - [https://app.unified.to](https://app.unified.to/) - [https://auth.unified.to](https://auth.unified.to/) - The Unified API domain shown during setup, for example: - [https://api.unified.to](https://api.unified.to/) - [https://api-eu.unified.to](https://api-eu.unified.to/) - (or your region-specific API domain) **5. Save & Complete Setup** - Save the Trello app settings - Return to Unified - Complete the connection flow --- ### Notes - Make sure the **regional API domain** matches what is shown in the Unified app during setup - This can vary depending on your environment or region --- ### Troubleshooting If the connection does not work: - Confirm the API key is from the correct Trello app - Ensure all **Allowed Origins** are added exactly (no typos or missing domains) - Verify the correct **regional API domain** is included - Retry the connection after saving changes ## Understanding OAuth2 Authorization flows URL: https://docs.unified.to/guides/understanding_oauth2_authorization_flows # Understanding OAuth2 Authorization flows ------ _October 17, 2025_ _Last updated: May 2026_ [OAuth 2.0](https://oauth.net/2/) is an industry-standard protocol for authorization that lets applications request limited access to a user's data on an API service without sharing passwords. This overview highlights the core flows that Unified supports. OAuth itself has two major versions (1.0 and 2.0), and OAuth 2.0 defines several flows. Overall, Unified supports 70+ OAuth 2.0 variations across integrations, spanning integration-specific scope models, token formats, and authorization requirements. We won't cover OAuth v1, which is no longer widely used and has largely been superseded by OAuth 2.0, published as RFC 6749 in October 2012. RFC 6749 states it "replaces" OAuth 1.0, though OAuth 1.0 (RFC 5849) remains published and is not formally deprecated.[[1]](https://rfc-editor.org/info/rfc6749)[[2]](https://rfc-editor.org/info/rfc5849) [OAuth 2.1](https://oauth.net/2.1/) is still in progress (as of Oct 2025). It consolidates best practices from OAuth 2.0—removing deprecated flows like implicit and password, and enforcing stronger security defaults such as PKCE—so it is not fully backwards-compatible with every 2.0 flow. --- ## OAuth 2.0 Authorization Code Flow The OAuth 2.0 Authorization Code flow (`code` flow) is designed for applications that need to access a user's data in a third-party service (e.g., Google, Salesforce, HubSpot) with the user's explicit consent. It's the standard behind options you see all over the internet like "Sign in with Google." This flow is used for delegated authorization—API access on behalf of a user—and can also support authentication when combined with OpenID Connect. **How it works:** - Your app redirects the user to the integration's authorization page. This URL contains your app's `client ID` from that integration, plus integration-specific permission `scopes` and a `redirect URL`. It can also contain additional information such as `state`, `PKCE challenge`, and many more integration-specific parameters. - The end-user logs in to that integration (if not already logged in) and is shown what permissions your app is requesting. They can then approve or reject that access request. - If approved, the integration redirects back to your app with a temporary code. - Your backend exchanges the code for an access token (and optionally a refresh token). This step uses both the client ID and client secret, securing the transaction. The access token will have an expiry, which requires the use of the refresh token to obtain new access tokens. - Your app uses the access token in the `Authorization` header to call that integration's API on behalf of the user. **Unified.to** [handles all aspects of this OAuth2 flow](https://docs.unified.to/unified/integration/Authorize_new_connection): - creating the authorizing URL correctly - unifying permission scopes (e.g. crm_contact_read, accounting_invoice_write, …) - exchanging codes for access tokens - automatically refreshing access tokens (and optionally refresh tokens) - calling the end API with the access token in the correct header and format This happens with our pre-built components, or directly through our Authorization API. Our customers just input their application's OAuth2 client ID and client secret, obtained once from the integration. Unified also provides instructions on how to obtain those OAuth credentials. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/understanding_oauth2_authorization_flows-0.png) Currently, Unified.to supports 176 integrations that use OAuth2 `code` flow. ## OAuth 2.0 OpenID Connect (OIDC) Flow The [OpenID Connect](https://openid.net/developers/how-connect-works/) flow is built on top of OAuth 2.0 and is used primarily for user authentication (identity), often alongside OAuth 2.0 authorization for API access. It returns an `id_token`—a JWT (JSON Web Token) containing verified user identity claims, such as a name and email—and can also provide a `/userinfo` API endpoint to obtain the verified identity. Depending on the integration and the scopes requested (for example, `offline_access`), it can also issue access and refresh tokens. When you see a "Sign in with Google" button, that is usually an OpenID Connect flow. **Unified.to** [hides this complexity in our Unified Authentication API](https://docs.unified.to/auth/login/Sign_in_a_user), supporting OIDC along with integrations that only support OAuth2 code flow with non-standard `User Info` API endpoints. ## OAuth 2.0 Client Credentials Flow OAuth 2.0 `client credentials` flow is for server-to-server (machine-to-machine) authentication. No user is involved; your backend service authenticates as itself to access application-level or tenant-level data—not an individual user's data. The customer (or a tenant admin) obtains a client ID and client secret from their integration and provides it to your application. **How it works:** - The customer or tenant admin generates an OAuth2 client ID and client secret in their integration and gives it to your application. They usually set what permissions they're willing to grant to those credentials. - Your backend sends the client ID and secret to the integration's token endpoint. - The integration returns an access token. - Your backend uses the token to call APIs. With Unified.to, your end-user will be asked to fill out these credentials, plus any other required credentials to access that integration's API. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/understanding_oauth2_authorization_flows-1.png) Currently, Unified.to supports 29 integrations that use OAuth2 `client_credentials` flow. ## API Token / Key / Username & Password Authentication APIs can also be authenticated with an `API key`, `API token`, or even a `username and password`. The keys and tokens consist of either random characters or an encoded [JWT](https://www.jwt.io/) (JSON Web Token). They can be sent in the `Authorization` header, in a custom header, or in the URL query as a parameter. If an API uses username and password for authentication instead of an API key, that combination is sent in the `Authorization` header, base64-encoded as "username:password". Because these credentials must be kept secret, they should only be used in backend environments. Using them in client-side code (e.g., a browser) would risk leaking them to the end-user. Unified.to can ask the end-user for any number of API keys/tokens and additional required API credentials. Optionally, our customers can request this information from their end-users and [create a connection manually](https://docs.unified.to/unified/connection/Create_connection). ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/understanding_oauth2_authorization_flows-2.png) If you're interested in reading about more complexities with OAuth2, read our blog post: "[How We Normalize OAuth Across 460+ APIs at Unified.to](https://unified.to/blog/how_we_normalize_oauth_across_apis_at_unified)". ## Understanding Response Time Headers in the Unified API URL: https://docs.unified.to/guides/understanding_response_time_headers_in_the_unified_api # Understanding Response Time Headers in the Unified API ------ _July 13, 2026_ Every response from the Unified API includes custom HTTP headers that break down exactly where time was spent during your request. This gives you visibility into whether latency is coming from the third-party integration or from Unified's processing layer. ### The Headers **`X-Response-Time`** — The total end-to-end time for your request, measured from when the Unified API received it to when the response was sent back. **`X-EndApi-Response-Time`** — The time spent waiting on the third-party API (e.g., Salesforce, Slack, HubSpot). This is the network round-trip to the downstream service plus its processing time. **`X-Unified-Response-Time`** — The time spent inside Unified's own code — authentication, data mapping, validation, and response formatting. Calculated as the total time minus the end API time. ### When They Appear `X-Response-Time` is included on every response. The two breakdown headers (`X-Unified-Response-Time` and `X-EndApi-Response-Time`) only appear when your request involved a call to a third-party API. Requests that are handled entirely by Unified (such as validation errors or metadata lookups) will only include `X-Response-Time`. ### Example ```plain text HTTP/1.1 200 OK X-Response-Time: 340ms X-Unified-Response-Time: 12ms X-EndApi-Response-Time: 328ms ``` In this example, the third-party API took 328ms to respond, while Unified added just 12ms of overhead for auth, field mapping, and validation. ### How to Use This - **Debugging slow requests** — If `X-EndApi-Response-Time` is high, the bottleneck is the third-party provider, not Unified. You may want to check the provider's status page or consider caching on your end. - **Monitoring** — Log these headers alongside your own application metrics to build a complete picture of your API call latency. - **Support** — When reaching out about performance concerns, include these header values to help pinpoint the issue faster. ## Unified Assessment API: Embed Assessments Inside ATS Platforms URL: https://docs.unified.to/guides/unified_assessment_api_embed_assessments_inside_ats_platforms # Unified Assessment API: Embed Assessments Inside ATS Platforms ------ _February 9, 2026_ Most assessment integrations rely on application status changes or external tools. Recruiters switch systems, and providers handle fragmented integration logic. Some ATS platforms now support embedded assessment listings, where recruiters select and order assessments directly inside the ATS. With the Assessment API, the recruiter orders your assessment inside the ATS and the order reaches you by webhook; you don't poll or watch status changes. ## What is an Assessment API? An assessment API allows assessment providers to integrate directly into ATS platforms so recruiters can request candidate assessments without leaving the ATS. Instead of triggering assessments based on status changes, recruiters select assessment packages inside the hiring process, and the order is routed to the provider by webhook. The Assessment API covers package management, order delivery and result submission for ATSes with an embedded assessment module. ## Why ATS-native assessment integrations matter Recruiters order the assessment at the point of decision, without switching tools, and the result is written back to the system where the hiring decision is made. ## Three ways to integrate assessments with Unified **1. Embedded (Assessment API)** - Recruiter selects assessment inside ATS - Best UX, requires ATS support **2. Triggered (ATS API)** - Trigger on status change - Works on any ATS integration that returns applications **3. Verification-style (Verification API)** - ATS/HRIS calls provider directly - No marketplace required ## Unified Assessment API The Unified Assessment API allows assessment and background check providers to integrate directly with Applicant Tracking Systems (ATS) platforms that support embedded assessment listings. This API enables recruiters to request assessments for candidates directly from within their ATS. The recruiter does not need to log into a separate assessment platform or use a separate system. Instead, the assessment package is selected inside the ATS, and the request is routed to your platform through Unified. This is different from triggering assessments based on status changes. It is a recruiter-initiated flow inside the ATS itself. ### Core objects in the Assessment API - **Packages**: define assessment offerings, metadata, scoring, and configuration - **Orders**: represent assessment requests, candidate context, and lifecycle state - **Results**: fields on the order (`response_score`, `response_url`, `response_attributes` and others) that you update when the assessment is done Orders include rich candidate, job, and application context, allowing providers to tailor assessments and return structured results directly into the ATS. ![image.png](https://s3.us-east-2.amazonaws.com/unified-article-images/unified_assessment_api_embed_assessments_inside_ats_platforms-0.png) ### What can you read and write? The Assessment API supports both: - Creating and managing assessment packages - Receiving assessment orders by webhook - Updating orders with structured results and scores Calls go to the source ATS at request time, with no customer records at rest; connection credentials and operational metadata are encrypted at rest. ### **Using assessment data in AI systems** Because your product holds the structured results it writes back, you can also use them to: - Generate candidate summaries and scoring insights - Feed structured evaluation data into copilots or hiring assistants - Trigger automated follow-ups or recommendations ## What you can build with the Assessment API - Embedded assessment marketplaces inside ATS platforms - Partner integrations with ATS vendors (such as Greenhouse, Ashby, Workable) - Candidate evaluation started by the recruiter inside the ATS - AI scoring and feedback returned to the ATS ### Embedded assessment marketplaces inside ATS Some ATS platforms support native assessment marketplaces, where providers can list assessment packages directly inside the product. The Assessment API enables this model by: - Listing your packages inside the ATS - Allowing recruiters to select assessments directly - Sending you each order by webhook - Returning results back into the same flow ## **How the Assessment API differs from other Unified APIs** The **Unified Verification API** is used by ATS or HRIS platforms to call verification or assessment providers in a unified way. In that flow: - The ATS or HRIS platform initiates the request. - They do not need a direct partnership with the assessment provider. - The call is routed through Unified using the unified verification data models. The Assessment API is different. It is designed for assessment providers who want their assessment packages listed directly inside an ATS that supports embedded assessment integrations. The integration is initiated by the recruiter inside the ATS, not by the assessment provider or backend workflow. ### How This Differs from the Unified ATS API The **Unified ATS API** allows assessment providers to listen for job application changes and trigger an assessment when an application reaches a specific status. That flow works across most ATS platforms supported by Unified and does not require a special assessment marketplace integration. It is best suited for ATS platforms that do not support embedded assessment listings. The new Unified Assessment API works in a different way: - It connects directly to ATS platforms that have a dedicated 'assessment API.' - Your assessment packages are listed inside the ATS. - A recruiter manually selects a package for a candidate. - The ATS sends an assessment order through Unified. - You process the assessment. - You update the order. - The results are written back into the ATS. The key difference: The recruiter stays inside the ATS the entire time. There is no external trigger based on status change. The recruiter explicitly orders the assessment. ### When Should You Use Each? For assessment providers: - Use the **Assessment API** when the ATS supports embedded assessment listings and you have a partnership with that ATS. - Use the **ATS API** for all other ATS platforms. Most providers will use both: - Assessment API for supported ATS platforms. - ATS API as the broad coverage fallback. The Assessment API reduces recruiter friction but is limited to ATS platforms that expose this functionality. ### Who this is for - Assessment providers (coding tests, behavioral, psychometric, etc.) - Background check and verification providers - AI-based candidate evaluation platforms - Hiring infrastructure tools integrating into ATS ecosystems ## Supported ATS Integrations As of 19 October 2026, the Assessment API connects to the assessment modules of Ashby, ATS Anywhere, Cornerstone, Greenhouse, JobAdder, PageUp, Paylocity, Recruitee, SmartRecruiters, TalentLyft, Teamtailor and Workable. It also orders assessments from assessment companies such as Berke, The Predictive Index and Wonderlic. See the [Assessments integration list](https://docs.unified.to/assessment/integrations). Each of these has a dedicated assessment integration that is separate from the standard ATS integration. Only ATS platforms that support embedded assessment listings can be supported through this API. ## How do you set up the Assessment API? ### Step 1: Create a Connection First, you need to manually create a connection for your assessment integration. This connection will be used to authenticate requests from the ATS. When creating the connection, you must supply a **Partner API Key** in the `connection.auth.token` field that you generate yourself. You will then share this API key with your end-customer to configure the integration in their ATS. Always check the authentication requirements for each assessment integration as some will require additional authentication information. For example, Ashby also requires a `Customer API Key` and a `Partner ID`. **Example Connection Creation:** ```json { "integration_type": "greenhouseassessment", "auth": { "token": "YOUR_PARTNER_API_KEY" }, } ``` ### Step 2: Configure Webhooks You also need to set up webhooks to receive assessment orders from the ATS systems. ### Required Webhook Create a webhook with the following configuration: - **object_type**: `assessment_order` - **event**: `created` This webhook will be triggered whenever a recruiter requests an assessment for a candidate in the ATS. ### Optional Webhook Some ATS vendors (like Ashby) also send order cancellation events. You can optionally create an additional webhook: - **object_type**: `assessment_order` - **event**: `deleted` This webhook will notify you when an assessment order is cancelled. ### Step 3: Provide Configuration to Your End-Customer Once you've created the connection and webhook, provide the following information to your end-customer: 1. **Base URL**: `https://api.unified.to/assessments/{connection_id}` - Replace `{connection_id}` with the actual connection ID from Step 1 2. **Partner API Key**: The token value you set in `connection.auth.token` Your end-customer will then input these credentials into their ATS software to enable the integration. ## **How the assessment flow works** Here's how the assessment flow works: 1. **Package Configuration**: You create assessment packages via the Unified API (w/ assessment packages API endpoints) 2. **ATS Lists Packages**: The ATS queries your available packages 3. **Recruiter Orders Assessment**: A recruiter selects a package and orders an assessment for a candidate 4. **Webhook Notification**: [Unified.to](https://unified.to/) sends you a webhook with the order details 5. **Process Assessment**: You send the assessment to the candidate and process their response 6. **Submit Results**: You submit the assessment results back to [Unified.to](https://unified.to/) (w/ assessment order update API endpoint) 7. **Results in ATS**: The results appear in the ATS for the recruiter to review ## API Endpoints ### Assessment Packages Manage your available assessment packages: - `POST /assessment/{connection_id}/package` - Create a new package - `GET /assessment/{connection_id}/package` - List all packages - `GET /assessment/{connection_id}/package/{id}` - Get a specific package - `PUT /assessment/{connection_id}/package/{id}` - Update a package - `DELETE /assessment/{connection_id}/package/{id}` - Delete a package ### Assessment Orders Update assessment order results: - `PUT /assessment/{connection_id}/order/{id}` - Update an order with results ## Webhook Payload When an assessment order is created, you'll receive a webhook with an `AssessmentOrder` payload: ```json { "id": "123", "package_id": "your_package_id", "candidate_id": "candidate_456", "application_id": "application_789", "job_id": "job_012", "profile_first_name": "John", "profile_last_name": "Doe", "profile_emails": ["candidate@example.com"], "status": "OPEN" } ``` ## Submitting Results Once a candidate completes an assessment, submit the results using the update order endpoint: ```bash PUT /assessment/{connection_id}/order/{order_id} ``` **Example Request:** ```json { "status": "COMPLETED", "response_score": 85, "response_max_score": 100, "response_url": "", "response_completed_at": "2024-01-15T10:30:00Z", "response_attributes": [ { "type": "TEXT", "label": "Overall Assessment", "value": "Strong candidate" } ] } ``` `status`, `response_score` and `response_url` are writable on every ATS assessment module; other fields vary, so check the integration page. The results will then be written back to the ATS system, where recruiters can view them directly. ## What should your webhook handler do? 1. **Secure Your API Key**: Treat your Partner API Key as sensitive information. Only share it with trusted end-customers. 2. **Handle Webhooks Reliably**: Implement retry logic and idempotency checks in your webhook handler to ensure you don't miss or duplicate orders. 3. **Validate Webhook Data**: Check the `sig256` signature (HMAC-SHA256) and validate the webhook payload to ensure it contains the expected candidate and job information before processing. 4. **Monitor Order Status**: Track the status of assessment orders and handle cancellations appropriately when the `deleted` event webhook is received. What is an assessment API for recruiting platforms? An assessment API allows providers to integrate directly with ATS platforms so recruiters can request, manage, and review candidate assessments without leaving the ATS. It delivers orders by webhook and writes structured results back to the same system. How do assessment providers integrate with ATS platforms? Assessment providers integrate with ATS platforms either through embedded assessment APIs or through status-based triggers. Embedded integrations allow recruiters to select and order assessments directly inside the ATS, while triggered integrations operate based on application state changes. ## Unified's MCP Server URL: https://docs.unified.to/guides/unified_mcp_server # Unified's MCP Server ------ _May 24, 2025_ Unified has launched an MCP server that connects any Unified connection to LLM (Large Language Model) providers supporting the newest MCP protocols. The available MCP `tools` will be determined by the integration's feature support and the connection's requested permissions. All MCP tool calls are executed live against the source API. Unified does not cache or store end-customer data, and access is governed by scoped permissions and tool allow-listing. ## Use-cases: - **End-Customer Conversation with LLMs**: Allows end-customers to interact with an LLM using permissioned tools backed by the data they've explicitly authorized through a Unified connection. - **Debugging End-Customers' Connections**: Allows your product and support teams to assist your end-customers by debugging their Unified connections. Please make sure that you observe all privacy policies, both yours and your regions's, since end-customer data may be made available to the LLM based on the permissions and tools you explicitly configure. Unified provides options to restrict tools, scope permissions, and remove sensitive fields from responses. MCP is a new protocol and it is moving fast. We expect more LLM & agent clients to support its newer Streamable HTTP transport protocol. We also expect that the MCP protocol will continue to expand quickly. Stay tuned as we also keep up. ## Sample queries: - List candidates for job X - List deals for contact with email address joe@foo.com - update application 12345 with a status of HIRED - summarize the accounting balance sheet report - find the vacation policy by listing the pages in Notion - post a message in Slack summarizing this deal - create an invoice for customer X - schedule an interview and notify the candidate More information can be found at [https://docs.unified.to/mcp](https://docs.unified.to/mcp) ## Unified MCP Server with LangChain and LangGraph URL: https://docs.unified.to/guides/unified_mcp_server_with_langchain_and_langgraph # Unified MCP Server with LangChain and LangGraph ------ _September 25, 2026_ ## Use the Unified MCP server's 22,566 tools as LangChain tools in LangChain and LangGraph agents LangChain and LangGraph give you a framework for building AI agents with tools, memory and multi-step workflows. With the official LangChain MCP adapter, every tool exposed by the Unified MCP server becomes a native LangChain tool. Your agents get real-time read and write access to your customers' CRM, ATS, HRIS, accounting, ticketing, file storage, messaging and commerce data, across all of Unified.to's integrations. You don't need custom integration code. The adapter converts each Unified MCP tool into a LangChain `BaseTool`. You can pass the tools to `create_agent`, bind them to any chat model, or run them in a LangGraph `ToolNode`. ## Prerequisites - A Unified.to workspace and your [workspace API key](https://app.unified.to/settings/api) - At least one end-customer connection (for example, a HubSpot or Greenhouse connection), and its connection ID from [Connections](https://app.unified.to/connections) - Python 3.10+ or Node.js 18+ ## Install **Python** ```bash pip install langchain langchain-mcp-adapters langchain-anthropic ``` **TypeScript** ```bash npm install langchain @langchain/mcp-adapters @langchain/anthropic ``` You can use any LangChain-supported model provider. The examples below use Anthropic, but OpenAI, Google, Mistral and others work the same way. ## Quickstart (Python) Connect LangChain to the Unified MCP server with a few lines of code: ```python import asyncio import os from langchain.agents import create_agent from langchain_mcp_adapters.client import MultiServerMCPClient UNIFIED_API_KEY = os.environ["UNIFIED_API_KEY"] CONNECTION_ID = os.environ["UNIFIED_CONNECTION_ID"] # end-customer connection async def main(): client = MultiServerMCPClient( { "unified": { "transport": "streamable_http", "url": f"https://mcp-api.unified.to/mcp?connection={CONNECTION_ID}", "headers": {"Authorization": f"bearer {UNIFIED_API_KEY}"}, } } ) tools = await client.get_tools() print(f"Loaded {len(tools)} Unified tools") agent = create_agent("anthropic:claude-sonnet-4-5", tools) result = await agent.ainvoke( {"messages": [{"role": "user", "content": "List the 10 most recently updated deals and summarize their stages."}]} ) print(result["messages"][-1].content) asyncio.run(main()) ``` The tools available depend on the connection's integration and the permissions granted. A CRM connection exposes tools for contacts, companies, deals, and so on. An ATS connection exposes tools for candidates, applications, jobs, and so on. ## Quickstart (TypeScript) ```typescript import { MultiServerMCPClient } from "@langchain/mcp-adapters"; import { createAgent } from "langchain"; const client = new MultiServerMCPClient({ mcpServers: { unified: { transport: "http", url: `https://mcp-api.unified.to/mcp?connection=${process.env.UNIFIED_CONNECTION_ID}`, headers: { Authorization: `bearer ${process.env.UNIFIED_API_KEY}` }, }, }, }); const tools = await client.getTools(); const agent = createAgent({ model: "anthropic:claude-sonnet-4-5", tools }); const result = await agent.invoke({ messages: [{ role: "user", content: "Find open jobs and count applications per job." }], }); console.log(result.messages.at(-1)?.content); await client.close(); ``` ## Authentication Send the token in the `Authorization: Bearer {token}` header; the `token` URL parameter is deprecated. All other options must be sent as URL parameters. See [Authentication](https://docs.unified.to/mcp/authentication) for full details. | Use case | Token | Other URL parameters | | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | | Your backend runs the agent (recommended) | Workspace API key, in the `Authorization` header | `connection={connectionId}` | | Token is handed to a less-trusted runtime or end user | Connection-scoped signed token from `get_unified_connection_mcp_url` (mode=user), in the `Authorization` header; works for one connection only | none | **A workspace API key grants access to all of your connections and your** [**Unified.to**](https://unified.to/) **account.** Keep it server-side, send it only in the `Authorization` header, and never put it in an MCP URL or a client config. ## Controlling which tools the agent sees Agents choose tools more reliably when they see a smaller, focused list, and a shorter list also saves context. Use these [server options](https://docs.unified.to/mcp/server-options) as URL parameters to shape the tools LangChain loads: | Option | What it does | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tools` | Comma-delimited list of tool IDs. Only these tools are exposed. | | `permissions` | Comma-delimited list of Unified.to permissions. Only tools allowed by these permissions are exposed. For example, pass read-only permissions for an agent that should never write. | | `hide_sensitive` | Hides PII fields (names, emails, telephones, and so on) from results. | ```python url = ( "https://mcp-api.unified.to/mcp" f"?connection={CONNECTION_ID}" "&permissions=crm_contact_read,crm_deal_read" "&hide_sensitive=true" ) ``` ## Using Unified tools in a LangGraph workflow For more control over agent flow, bind the Unified tools to a model and route tool calls through a LangGraph `ToolNode`: ```python from langchain.chat_models import init_chat_model from langchain_mcp_adapters.client import MultiServerMCPClient from langgraph.graph import StateGraph, MessagesState, START from langgraph.prebuilt import ToolNode, tools_condition client = MultiServerMCPClient( { "unified": { "transport": "streamable_http", "url": f"https://mcp-api.unified.to/mcp?connection={CONNECTION_ID}", "headers": {"Authorization": f"bearer {UNIFIED_API_KEY}"}, } } ) tools = await client.get_tools() model = init_chat_model("anthropic:claude-sonnet-4-5").bind_tools(tools) async def call_model(state: MessagesState): return {"messages": [await model.ainvoke(state["messages"])]} builder = StateGraph(MessagesState) builder.add_node("call_model", call_model) builder.add_node("tools", ToolNode(tools)) builder.add_edge(START, "call_model") builder.add_conditional_edges("call_model", tools_condition) builder.add_edge("tools", "call_model") graph = builder.compile() result = await graph.ainvoke( {"messages": [{"role": "user", "content": "Which candidates applied this week, and for which roles?"}]} ) ``` ## Multi-tenant agents If your product serves many end customers, each customer has their own Unified connection. Create the MCP client per request or session, scoped to the current customer's connection, so an agent can only reach that customer's data: ```python def unified_client_for(connection_id: str) -> MultiServerMCPClient: return MultiServerMCPClient( { "unified": { "transport": "streamable_http", "url": f"https://mcp-api.unified.to/mcp?connection={connection_id}", "headers": {"Authorization": f"bearer {UNIFIED_API_KEY}"}, } } ) async def handle_request(customer, prompt: str): tools = await unified_client_for(customer.unified_connection_id).get_tools() agent = create_agent("anthropic:claude-sonnet-4-5", tools) return await agent.ainvoke({"messages": [{"role": "user", "content": prompt}]}) ``` Because Unified normalizes data across integrations, the same agent code works whether one customer uses Salesforce and another uses HubSpot. ## Calling tools directly Unified tools are standard LangChain tools, so deterministic workflows can call them without an agent: ```python tools_by_name = {t.name: t for t in await client.get_tools()} contacts = await tools_by_name["list_crm_contacts"].ainvoke({"limit": 50}) ``` Tool names vary by integration and category. Print `[t.name for t in tools]` to see what a connection exposes. ## Regions | Region | Streamable HTTP endpoint | | ------ | ----------------------------------- | | US | `https://mcp-api.unified.to/mcp` | | EU | `https://mcp-api-eu.unified.to/mcp` | | AU | `https://api-au.unified.to/mcp` | Use the endpoint for the region where your [Unified.to](https://unified.to/) workspace is hosted. A credential works only on its own region's host. ## Troubleshooting - **401 / unauthorized:** Check that the token is a valid workspace API key or a correctly signed end-user token. When you use a workspace API key, make sure the `connection` parameter is also present. - **Fewer tools than expected:** The tool list reflects the connection's integration, the scopes granted when the customer authorized it, and any `tools` or `permissions` filters in the URL. - **Agent picks the wrong tool:** Narrow the tool list with `tools` or `permissions`, and describe the task more specifically in your system prompt. - **Options not applied:** Only the token can go in a header. Every other option must be a URL parameter. ## Related - [Unified MCP Server overview](https://docs.unified.to/mcp) - [Authentication](https://docs.unified.to/mcp/authentication) - [Server options](https://docs.unified.to/mcp/server-options) - [LangChain MCP adapters (Python)](https://github.com/langchain-ai/langchain-mcp-adapters) ## Unlock real-time data with virtual webhooks URL: https://docs.unified.to/guides/unlock_real_time_data_with_virtual_webhooks # Unlock real-time data with virtual webhooks ------ _February 13, 2025_ Product teams building event-driven applications face a common challenge: most third-party APIs don't offer webhooks (In fact, our [State of SaaS APIs report](https://unified.to/blog/the_state_of_saas_apis_2024) found only 11% of APIs have built-in support for webhooks). This leads to inefficient polling, manually handling rate limits, and writing custom logic just to avoid stale data. For how virtual webhooks compare with native webhooks and polling, see [What Are Virtual Webhooks?](https://unified.to/blog/what_are_virtual_webhooks_how_they_differ_from_sync_based_webhooks). But what if you could control webhook updates while reducing API costs and complexity? That's exactly what [Unified.to](https://unified.to/)'s [virtual webhooks](https://unified.to/blog/replace_polling_with_unified_webhooks_real_time_and_historical_data_in_one_flow) enable. In this article, we'll review: - How virtual webhooks work - Why they help control API costs by managing request frequency - How they reduce the custom rate-limit handling you need to write - How virtual webhooks shorten the time to receive change events from APIs without webhooks ## Why use virtual webhooks? ![virtual_webhooks.png](https://s3.us-east-2.amazonaws.com/unified-article-images/unlock_real_time_data_with_virtual_webhooks-0.png) Pairing Unified.to's virtual webhooks with your product's integration strategy means: - **Webhooks on supported integrations and objects:** Receive change events from APIs that don't offer webhooks, through the same API you use for native ones. - **Predictable API usage:** Virtual webhooks check on the interval you set, so you control how often the source API is called. A check that finds nothing isn't billed. - **One unified schema:** Whether the source sends its own webhooks or not, data arrives in the same format. No custom polling logic needed. - **Configurable change detection:** Set the check interval (e.g., every minute, every 30 minutes, every hour; minimum 1 minute on paid plans, 60 minutes on the trial). [Unified.to](https://unified.to/) honours each integration's retry-after hints. - **Faster time-to-market:** Virtual webhooks eliminate the need to build custom data collection. - **Historical data access:** Receive existing records when you create a webhook subscription, paged until the initial sync completes. - **Built-in observability:** Monitor webhook activity with logs (metadata only) and health status. - **Event-driven AI and automation:** Trigger AI actions and automation as change events arrive. Instead of dealing with a patchwork of webhook, polling, and batch sync solutions, [Unified.to](https://unified.to/)'s virtual webhooks give you one way to receive change events across the integrations that support them. ## How virtual webhooks work Webhooks deliver data updates to you, eliminating the need for polling. But most APIs don't support them, which forces developers to either: 1. Poll the API (which wastes API calls and delays updates), or 2. Manually mix polling and webhooks (adding complexity and inconsistencies). [Unified.to](https://unified.to/)'s virtual webhooks detect changes and deliver events, even when the source API doesn't offer webhooks: - We monitor your customers' connections on a scheduled interval that you configure (e.g., as frequently as every minute on paid plans) and detect changes automatically - We deliver events to your webhook URL in the same format as native webhooks - You create and manage them with the same webhook API as native webhooks No polling logic to write. Events on your interval, as often as every minute on paid plans. [Explore documentation](https://docs.unified.to/guides/how_to_create_and_configure_webhooks) ## How virtual webhooks compare to traditional webhooks and polling Most third-party APIs don't offer native webhooks, forcing teams to choose between batch syncs, polling, or manual workarounds. | **Feature** | **Unified's Virtual + Native Webhooks** | **Native Webhooks** | **Polling** | | ------------------------------------------------------------ | --------------------------------------- | --------------------------------------------------- | ---------------------- | | Works even if API doesn't support webhooks | ✅ Yes | ❌ No | ✅ Yes, but inefficient | | Event updates on your interval (virtual) or as sent (native) | ✅ Yes | ✅ Yes | ❌ No, delayed | | API rate limit friendly | ✅ Yes | ✅ Yes | ❌ No, high API usage | | Historical data retrieval | ✅ Yes | ✅ With include_all, where initial sync is supported | ❌ No | | Fine-grained sync control | ✅ Yes (set the check interval) | ❌ No | ✅ Yes, but manual | | Built-in error handling & retries | ✅ Yes | ❌ Varies by API | ❌ No | | Webhook observability (logs, health monitoring) | ✅ Yes | ❌ Varies by API | ❌ No | Virtual webhooks use the same subscription API and payload format as native webhooks, and work where APIs don't offer webhooks. ## Product use cases Let's look at how developers and product teams can use virtual webhooks. **AI-powered customer support** Virtual webhooks can update AI models within your configured interval when new support tickets are created. Instead of writing polling logic against a help desk API, an AI assistant can respond to customer inquiries or escalate issues as events arrive. [Unified Ticketing API](https://unified.to/ticketing) [Build an AI Support Bot](https://unified.to/blog/how_to_build_a_discord_support_bot_with_unified_and_langbase) **Automated financial reporting** Fintech and accounting applications rely on up-to-date financial data to generate reports, sync transactions, and reconcile accounts. However, many accounting platforms don't offer webhooks, meaning transaction updates often rely on batch syncs or polling. With Unified.to's virtual webhooks, accounting software can: - Get notified within your configured interval when new transactions, invoices, or payments are recorded - Trigger automatic reconciliation instead of waiting for scheduled syncs [Unified Accounting API](https://unified.to/accounting) **Recruitment automation** Hiring teams often integrate with multiple ATS platforms, some of which don't offer webhooks. Virtual webhooks let AI-powered candidate screening start within your configured interval of a new application arriving. [Unified ATS API](https://unified.to/ats) [How HeroHunt added 20 integrations in 1 week](https://unified.to/blog/how_herohunt_saved_ten_months_of_engineering_time_with_unified) ## How much polling actually costs Polling isn't just inefficient. It's expensive. Example: - Polling every 5 minutes on 1000 accounts = 288,000 API requests per day - Polling every 1 minute on 1000 accounts = 1.4 million API requests per day - With virtual webhooks, you're billed only when a check finds changes, so a low-activity account costs little even on a one-minute interval. Checks still call the integration's API, and [Unified.to](https://unified.to/) honours the integration's retry-after hints. Instead of writing and running your own polling, you pay per page of changes delivered. ## What happens if you don't use virtual webhooks? - You could hit API rate limits while continuously polling for updates - You could rely on batch syncs, leading to outdated or incomplete data - You could spend months building and maintaining custom webhook fallbacks for every integration Or you could use [Unified.to](https://unified.to/)'s virtual webhooks, which detect changes on your interval and deliver change events across the integrations that support them. ## How do you set up a virtual webhook? 1. **Start from a connection and object:** Webhooks are created from an existing connection. In the app, go to **Integrations** > **Webhooks**, click **New Webhook** and select the connection, or send a POST request to `/unified/webhook` with `connection_id`, `object_type` and `event`. 2. **Choose the virtual type:** Set **Type** to virtual where the integration supports it. Webhook support varies by integration and object, so check the integration's **Feature Support** tab first. The type can't be changed after creation; delete and recreate the webhook to switch. 3. **Set the interval:** Choose how often [Unified.to](https://unified.to/) checks the integration for changes. The minimum is 1 minute on paid plans and 60 minutes on the trial. The interval doesn't affect the initial sync. 4. **Optionally backfill history:** Add the `include_all` query parameter (or check **Initially sync all data** in the app). Historical records arrive in pages marked `INITIAL-PARTIAL`, followed by a final `INITIAL-COMPLETE` payload. 5. **Point it at your endpoint:** Set `hook_url` to your webhook URL, and verify incoming payloads with the `sig256` signature. For every configuration option, including filters and fields, see [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks). ## Start using Unified.to's virtual webhooks Ready to stop polling and start receiving change events? Here's how to get started: - [Start a free trial](https://app.unified.to/login) to access virtual webhooks - [Join our Discord](https://discord.gg/85z7HF7JbD) to connect with our team and get support - [Review our documentation](https://docs.unified.to/guides/how_to_create_and_configure_webhooks) for step-by-step guidance on setting up webhooks - [See our changelog](https://unified.to/changelog) for virtual webhook enhancements ## Use Unified to sign in your users into your application URL: https://docs.unified.to/guides/use_unified_to_sign_in_your_users_into_your_application # Use Unified to sign in your users into your application ------ _August 31, 2023_ Before your users can utilize your application and any of Unified.to's integrations, they need to sign in to your application. Luckily, we've simplified user authentication through our Unified Authentication API. Just follow these steps. ## 1. Activate Authentication integrations 1.1 Click on the 'Integrations' menu, and then on the 'Authentication Integrations' option. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/use_unified_to_sign_in_your_users_into_your_application-0.png) 1.2 Activate the integrations that you would like to use to sign-in your users into your application by clicking on each desired integration. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/use_unified_to_sign_in_your_users_into_your_application-1.png) 1.3 If available, you can chose to use Unified's branding and name on the authentication page that your users see with signing-in to your application. We don't recommend this for production applications, but this is a great option to test the authentication feature. 1.4 If you want to add your own branding to the authentication pages, then you will need to first obtain your own OAuth2 application credentials from the specific integration vendors (eg. Google). For help on creating your own OAuth2 application, please see [Generating OAuth2 Credentials](https://unified.to/blog/start_here_how_to_generate_oauth2_credentials) or click on the 'Get your own OAuth2 credentials' link. Once you have that, enter your client ID and client secret that you obtained from the integration vendor. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/use_unified_to_sign_in_your_users_into_your_application-2.png) 1.5 Click on the 'Activate' button to enable this integration. 1.6 Repeat for all of the integrations that you want to use to authenticate your users. ## 2. Display sign-in links to your users Now that you have a list of Authentication connections, you can incorporate them into your application. You have two options: 1. Remember the integration types (ie. google, discord, workday) and create links that point to our authentication API endpoint: `https//api.unified.to/unified/integration/login/{workspace_id}/{integration_type}?redirect=true`. You can style that link however you like. 2. Call the our API that returns a list of integrations; `https//api.unified.to/unified/integration/workspace/{workspace_id}?summary=true&active=true&categories=auth`. This will return all activated Authentication integrations and each will have information that you can use to style your Sign-in links; ```json [ { logo_url: "https://localhost:8000/docs/images/google.png", name: "Google", type: "google" } ] ``` The Sign-in URL can have the following optional parameters: `redirect` - `true` to redirect the user or if empty, will return the URL as string in its response `success_redirect` - the URL that the user will be redirected to once they have successfully authenticated with that integration vendor `failure_redirect` - the URL that the user will be redirected to if there is an error or other issue preventing the user from being authenticated by the integration vendor `state` - a string that will be sent back to the success_redirect. You can use this to remember a user ID or other identifier. 1. An alternative to building your own Sign-in page is to use our Embedded Sign-in widget. Click on the Settings menu and then on the Embedded Sign-in option. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/use_unified_to_sign_in_your_users_into_your_application-3.png) Copy the CSS and code and insert it into your own application. ## Step 3: Verify a sign-in attempt Once the user clicks on the authentication button/link, they will be redirect to the integration vendor's authentication page. ![Untitled.png](https://s3.us-east-2.amazonaws.com/unified-article-images/use_unified_to_sign_in_your_users_into_your_application-4.png) btw: it is important to note that the authentication pages are not Unified's pages but that of the integration vendor. Unified never sees the user's login credentials including their password. Once the user successfully signs-in, they are redirected back to your application (or to the location of the `success_redirect` URL parameter from step 2). A `jwt` parameter will be appended to that URL. The `jwt` is a base64 encoded [JWT](https://jwt.io/) and is signed with your Workspace Secret. Verify the JWT with your workspace secret on your server (NOT in your browser as the workspace secret is not public). Example code to verify a JWT on a NodeJS server: ```javascript try { let result = JWT.verify( req.payload.jwt, workspace.secret ) } catch (err) { console.error(err); } ``` The decoded JWT will contain a name and emails field: ```json { name: "Jane Smith", emails: ["jane@foo.com", "jsmith89@gmail.com"] } ``` Use the user's email to log them into your application as it is verified by the integration. ## Workable: Creating candidates and attaching them to jobs URL: https://docs.unified.to/guides/workable_creating_candidates_and_attaching_them_to_jobs # Workable: Creating candidates and attaching them to jobs ------ _September 14, 2026_ ## How do candidates and applications work in Workable? In [Unified.to](https://unified.to/)'s Workable integration, creating a candidate requires a job: Workable creates the candidate on that job. To add the same person to another job, create the candidate again with that job in `job_ids`. Workable keeps a separate candidate record, with its own id, for each job. This guide explains the recommended flow and how to handle the ids you get back. To connect Workable first, see [Getting started with Workable](https://docs.unified.to/guides/getting_started_with_workable); for the objects and fields Workable supports, see the [Workable integration page](https://docs.unified.to/integrations/workable). ## Create a candidate on a job `POST /ats/{connection_id}/candidate` with `job_ids` set to the Workable job shortcode creates the candidate on that job. `job_ids` is required: a create without it returns an error and creates nothing. - The response `id` is that job's candidate id. Store it. ## Add the person to another job `POST /ats/{connection_id}/candidate` again, with the same details and `job_ids` set to the other job's shortcode. The response `id` is a **new** candidate id for that job. Store it alongside the first one. ## What does application create do on Workable? In Workable a candidate on a job is the application, so `POST /ats/{connection_id}/application` doesn't attach anyone to a job (as of 21 October 2026). Send: - `candidate_id`: a Workable candidate id - `job_id`: the shortcode of the job that candidate is already on It returns the application for that candidate and job, and can set its status (stage), screening answers or rejection in the same call. If the candidate isn't on that job, it returns HTTP 400 and changes nothing. It also returns HTTP 400 if the job is a draft, closed or archived. > ⚠️ Application create no longer copies a candidate onto another job. To put someone on a second job, create the candidate on that job. ## Example sequence 1. Create candidate with `job_ids: [JOB1]` → response id `A` (on `JOB1`) 2. Create candidate with the same details and `job_ids: [JOB2]` → response id `B` (on `JOB2`) 3. Later get/update the candidate or application on `JOB2` → use `B`, not `A` ## How do I find a candidate by email? List candidates with the `query` parameter set to the email address: ```plain text GET /ats/{connection_id}/candidate?query=person@example.com ``` After you add a candidate to a second job, the same email may return **two** candidates (`A` on the first job and `B` on the second). They are different Workable records with different ids. ## Do updates carry over between the two copies? No. Changing profile details on the original candidate doesn't update the copy, and the reverse is also true. Treat each id as its own record. ## Why not use email as the candidate id? Email identifies a person, but after you add them to a second job Workable has **two** candidate records with the same email. [Unified.to](https://unified.to/) keeps Workable's native candidate ids so list, get, and application create stay unambiguous. ## Which events tell me about new copies? On Workable, candidate created and updated events come from native or virtual webhooks, and application events from virtual webhooks (as of 20 October 2026). Workable's `candidate_created` event feeds the native candidate created event, and only `candidate_moved` (a stage change) feeds native candidate updated. Workable has a `candidate_deleted` event, but [Unified.to](https://unified.to/) doesn't deliver deleted events for any Workable object (as of 21 October 2026), so a 404 on an old id is the signal to list again. ## Requirements and tips - Changing an application's stage, answers or rejection needs a Workable member id. Pass `user_id` when you can; otherwise [Unified.to](https://unified.to/) tries to resolve an admin or a member of the job's team. - Store each candidate id as soon as create succeeds, together with the job it belongs to. - If an old candidate id returns 404, list with `query` set to the email to recover the current ids on each job. ## Workday vs Workday Legacy — Why Unified.to Supports Both URL: https://docs.unified.to/guides/workday_vs_workday_legacy_why_unified_supports_both # Workday vs Workday Legacy — Why Unified.to Supports Both ------ _July 22, 2025_ Workday provides **two main API integration formats**: 1. **Workday REST API**: Workday's newer API, still adding endpoints. 2. **Workday SOAP API** (Workday Web Services, often called _Legacy_): the older API, which still covers objects and actions REST doesn't yet. [Unified.to](https://unified.to/) supports **both**, as two integrations, Workday and Workday (Legacy), so you can use whichever covers the objects you need. ## Why You Might Need Both While Workday is steadily improving their REST API, there are still important gaps. Here's why using both makes sense today: 1. **Application create / list**: Workday's REST API does not support job applications. List, get, create, and update are only available through the Legacy SOAP integration. 2. **Candidate lists & updates**: REST supports get and create for candidates (prospects), but listing and updating candidates still require Legacy. 3. **Create / update job**: Creating and updating jobs still relies on the Legacy integration. REST supports list and get for job postings. 4. **ATS activity & application statuses**: available on Legacy only today. 5. **Broader HRIS / payroll / payments coverage on Legacy**: objects like HR benefit, deduction, bank account, location, payslip, taxonomy, job (positions), and Payment are Legacy-only. REST uniquely covers LMS content, accounting purchase orders, and HR earnings (with create/update/remove). 6. **Coverage changes**: Workday keeps adding REST endpoints, so check each integration's Feature Support tab for current coverage. The support matrix below shows [Unified.to](https://unified.to/)'s coverage for **Workday** (REST) and **Workday (Legacy)** (SOAP), from the integration pages in the docs as of 19 October 2026. | | Workday (REST) | Workday Legacy (SOAP) | | ------------------------- | --------------------------------- | ------------------------- | | ATS Activity | | List, Get, Create | | ATS Application | | List, Get, Create, Update | | ATS Application Status | | List | | ATS Candidate | Get, Create | List, Get, Create, Update | | ATS Company | List, Get | List, Get | | ATS Document | List, Get, Create | List, Get, Create | | ATS Interview | List, Get | List, Get, Create, Update | | ATS Job | List, Get | List, Get, Create, Update | | ATS Scorecard | List, Get, Create | List, Get, Create, Update | | HR Attendance | List, Get | List, Get | | HR Bank Account | | List | | HR Benefit | | List, Get | | HR Company | List, Get | List, Get | | HR Deduction | | List, Get, Create, Update | | HR Document | List, Get | List, Get | | HR Earning | List, Get, Create, Update, Remove | | | HR Employee | List, Get | List, Get, Create | | HR Group | List, Get | List, Get, Create, Update | | HR Job | | List, Get | | HR Location | | List, Get, Create, Update | | HR Payslip | | List | | HR Taxonomy | | List | | HR Timeoff | List, Get, Create, Update, Remove | List, Get, Create, Update | | HR Timeshift | List, Get, Create, Update, Remove | List, Get | | LMS Content | List, Get | | | Accounting Bill | List, Get | List, Get, Remove | | Accounting Contact | List, Get | List, Get, Create, Update | | Accounting Invoice | List, Get | List, Get, Remove | | Accounting Payment Term | | List, Get, Create | | Accounting Purchase Order | List, Get | | | Payment | | List, Get, Remove | ## Frequently Asked Questions & Answers **Question: Do I have to send XML requests for Workday Legacy?** **Answer:** No! You always send JSON requests to Unified.to. We handle the conversion to XML behind the scenes for the SOAP API. **Question: Can I use both the REST and Legacy APIs at the same time?** **A****nswer:** Yes. Each is its own connection, so you can, for example, read documents through Workday (REST) and create applications through Workday (Legacy), both returning the same unified models. **Question: Is the Workday (Legacy) integration going to be deprecated?** **Answer:** The docs show no deprecation for Workday (Legacy) as of 19 October 2026, and it still covers objects the REST integration doesn't, such as applications, deductions and payslips. **Question: Is authentication different for Legacy vs REST?** **Answer:** Yes. Workday (REST) uses OAuth 2.0 with a client ID, client secret and refresh token. Workday (Legacy) uses an Integration System User (ISU): the [integration page](https://docs.unified.to/integrations/workday_legacy) asks for the Workday host, tenant name, ISU username and ISU password, and the SOAP API receives them as a WS-Security UsernameToken inside the SOAP envelope rather than in an HTTP Authorization header. [Unified.to](https://unified.to/) handles both. **Question: Are the data models the same between REST and Legacy?** **Answer:** Yes. [Unified.to](https://unified.to/) maps both onto the same unified models, so an employee or a job has the same fields whichever Workday API is behind it. Which objects and methods each integration supports differs, as the table shows. **Question: Can I switch from Legacy to REST later?** **Answer:** Yes, object by object, as REST coverage grows. Because both integrations return the same unified models, your code for an object stays the same; what changes is which connection you call, and each connection needs its own authorization. **Question: Will using both cost more?** **Answer:** No. [Unified.to](https://unified.to/) prices by API call with unlimited connections, so a second Workday connection adds no fee of its own. ## Which Workday integration should you use? Workday is moving more features to its REST API, but some objects and actions are still available only through the SOAP-based Legacy API, so [Unified.to](https://unified.to/) supports **both**. **Our recommendation:** Use the Workday (REST) integration wherever it covers your workflow (including time off writes, earnings, LMS, and purchase orders), and use Workday (Legacy) for gaps such as applications, candidate list/update, job create/update, broader HRIS/payroll objects, and payments. [Unified.to](https://unified.to/) handles each API's authorization and format, including converting your JSON requests to SOAP for Legacy. **Have more questions?** Reach out to our support team for help choosing between the two Workday integrations. ## Working with hierarchical tree data in Storage, Messaging, and KMS APIs URL: https://docs.unified.to/guides/working_with_hierarchical_tree_data_in_storage_messaging_and_kms_apis # Working with hierarchical tree data in Storage, Messaging, and KMS APIs ------ _November 7, 2024_ This guide explains how to retrieve and traverse hierarchical data structures (i.e., trees) when working with APIs that support parent-child relationships. ## Overview Many APIs organize their data in tree-like structures, where records can have parent-child relationships with each other. 1. The **File Storage API** represents both folders and files as a single object type, making tree traversal straightforward. While you can think of folders as the 'containers' for other files and folders, the API treats them all as the same object type. 2. The **Messaging API** and **Tasks API** use a simple parent-child relationship where each object type can contain children of the next type (e.g., projects contain tasks, tasks contain comments, channels contain messages). Objects may also have parents of the same type. 3. The **KMS API** has spaces and pages. Pages nest under pages through `parent_id` on supported integrations (Atlassian Confluence, Notion, ClickUp, Coda, Gladly). The space model also has `parent_page_id` for knowledge bases that nest spaces under pages, but no integration returns it as of 19 October 2026. This guide will show you how to efficiently traverse these hierarchical structures to retrieve all the data you need. ## Before you begin This guide assumes you have: - Basic understanding of [tree data structures](https://en.wikipedia.org/wiki/Tree_(abstract_data_type)) ## Understanding parent-child relationships When an API endpoint supports a `parent_id` parameter, it typically indicates that the data is organized in a hierarchical structure. To fully traverse this structure, you'll need to: 1. Get the top-level entities (parent nodes) 2. For each parent, retrieve its children by passing the parent's ID 3. Recursively repeat this process for any children that can also be parents 4. For KMS Page, you can use its `has_children` field to determine if there are children pages to query. If `has_children` is equal `false` , then no children pages exist. Most integrations don't return it (it's on Confluence, Notion, ClickUp, Coda and Gladly as of 19 October 2026), so if the field is `undefined` or missing, treat it as `true`. ### Example: Messaging platforms Let's look at how this works with Discord: 1. Discord servers (also called "guilds") are top-level parents 2. Channels exist within servers as children 3. Messages are also related to channels through their `parent_id` 4. To get all channels: - First retrieve all guilds (servers) - Then get channels for each guild by passing the guild's ID as `parent_id` ```javascript async function getAllChannels(connectionId) { // Get all top-level guilds first const guilds = await sdk.messaging.listMessagingChannels({ connectionId, }); let allChannels = []; // For each guild, get its channels for (const guild of guilds.data) { const channels = await sdk.messaging.listMessagingChannels({ connectionId, parentChannelId: guild.id, }); allChannels = allChannels.concat(channels.data); } return allChannels; } ``` **API reference:** [Messaging API](https://docs.unified.to/messaging/overview) ### Example: File storage systems File storage systems are another common example of hierarchical data. Here's how to recursively traverse a file system to get all files: ```javascript async function getAllFiles(connectionId, parentId) { const response = await sdk.storage.listStorageFiles({ connectionId, parentId, }); if (!response.data) { return []; } let allFiles = []; for (const item of response.data) { if (item.type === 'FOLDER') { // Recursively get files from subfolders const subfolderFiles = await getAllFiles(connectionId, item.id); allFiles = allFiles.concat(subfolderFiles); } else { allFiles.push(item); } } return allFiles; } ``` **API reference:** [File](https://docs.unified.to/storage/overview)[ ](https://docs.unified.to/storage/overview)[Storage API](https://docs.unified.to/storage/overview) ## Best practices When working with hierarchical data: 1. **Implement pagination**: Some nodes might have many children. Use the `limit` and `offset` parameters to handle large datasets. ```javascript async function getAllChannelsWithPagination(connectionId, parentId = undefined) { let allChannels = []; let offset = 0; const limit = 100; while (true) { const response = await sdk.messaging.listMessagingChannels({ connectionId, parentChannelId: parentId, limit, offset, }); if (!response.data || response.data.length === 0) { break; } allChannels = allChannels.concat(response.data); offset += limit; } return allChannels; } ``` 1. **Handle rate limits**: When recursively fetching data, you might hit API rate limits. Implement backoff strategies and respect the provider's limits. 2. **Cache parent IDs**: If you need to traverse the same structure multiple times, consider caching the parent-child relationships to reduce API calls. ## Integration-specific gotchas ### Working with SharePoint SharePoint implements a distinct three-level hierarchy that requires special attention: 1. **Sites** (top level) 2. **Drives** (within sites) 3. **Folders/Files** (within drives) To work with SharePoint's file system: 1. First retrieve the site ID (top-most parent) 2. Use the site ID to list drives 3. Use the drive ID to create or access folders and files **Important notes:** - You cannot create files or folders directly at the site level - you must first select a drive - Write operations are only supported for folders and files, not for sites or drives - Pagination is not supported for sites and drives listings ## See also - [How to use the Unified File Storage API](https://docs.unified.to/guides/how_to_use_the_unified_file_storage_api#how-to-use-the-unified-file-storage-api) --- # Agent Skills ## apollo-oauth-app URL: https://docs.unified.to/skills/apollo-oauth-app/SKILL.md # Apollo.io API Credentials **Read this first: Apollo does have an OAuth 2.0 authorization-code flow, and it is the model Apollo intends for a platform that connects other organizations' Apollo accounts.** But it is not a public developer console. There is no `developers.apollo.io` signup where you create a client and walk away with a secret. You register the app **from inside an ordinary Apollo account** — Settings → Integrations → API Keys → **OAuth registration** — you supply "details about the purpose of your organization", and Apollo's own words are: *"Once registration is approved, you can use the Apollo playground to test the OAuth flow."* So: self-serve **form**, Apollo-side **approval**. Apollo publishes no turnaround, no fee and no eligibility bar for that approval. Plan for it as a wait, not a click (§4). The other model is **per-customer API keys**: the customer creates a key in their own Apollo account and hands it over, sent in a request header. Technically this works and Apollo's own Marketplace security requirements assume some partners do it. Legally it is narrower than people expect — Apollo's API Terms §3 say *"You may not access the APIs via a third party's API credentials or integrate the APIs with your product or services, unless Apollo has authorized or approved such access or integration."* A multi-tenant platform holding customer keys is squarely inside that sentence and needs Apollo's approval either way (§9). And the Marketplace requires OAuth **as the sole authorization method**, so the key path is a bridge, not a destination. Three things cost real time here, and none of them are the OAuth flow itself. **You get exactly four redirect URLs**, which is exactly the number a four-region platform needs and leaves zero spare (§6). **Scopes are locked in** — Apollo warns that editing them means repeating the whole authorization setup, so a connector that adds object types later pays for a short list now (§7). And **Apollo's authorize endpoint is hash-routed**, so a client that builds the query string the ordinary way sends parameters the page never sees (§5). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Credential model** | OAuth 2.0 client, or per-customer API keys? (§1) | | **App name and logo** | Shown to every customer on the Apollo consent screen (§4) | | **Redirect URL(s)** | Every callback host, final, up front — **maximum 4** (§6) | | **Scope set** | The exact strings, complete, first time — changing them later means redoing setup (§7) | | **The Apollo account that will own the app** | Registration happens inside a real Apollo workspace, not a separate dev account (§3) | | **Customer plan tier(s)** | Free / Basic / Professional / Organization. Decides limits and which endpoints answer (§8) | | **Credit budget and owner** | Enrichment and search spend the customer's credits, not yours (§9) | | **Marketplace intent** | Listing requires OAuth-only and 3 connected Apollo teams (§10) | | **An Apollo workspace to test against** | Apollo publishes no sandbox; testing is against a real workspace (§4) | ## Quick Start 1. Decide the **credential model** — OAuth client or per-customer keys (§1). Most wrong turns start here. 2. Check whether credentials already exist; re-registering orphans every live customer authorization (§2). 3. Get into the Apollo account that will own the app; confirm the acting user's permission profile (§3). 4. Submit **OAuth registration** and wait for Apollo's approval; then use the built-in playground (§4). 5. Build the authorize call **hash-first** — parameters after the `#`, or Apollo never sees them (§5). 6. Give Apollo **every** callback host in that one form. You get four (§6). 7. Pin the **complete** scope set now; Apollo calls the set locked in (§7). 8. Check plan gating and rate limits before promising any sync cadence (§8). 9. Brief the customer on **credits** before the first enrichment run (§9). 10. Verify with a real authorize → callback → refresh → refresh-again round trip (§11), then hand the secret to a human, never to source control (§12). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **OAuth 2.0 exists, is documented, and is the intended partner model.** Apollo's authentication reference splits it plainly: *"Apollo users use an API key, and partners building on behalf of mutual users use OAuth 2.0."* The OAuth page is titled **"OAuth 2.0 (Partners)"**. - **Registration is in-product, and approved by Apollo.** Settings → Integrations → API Keys → **OAuth registration**. You provide app name, logo, redirect URLs and scopes plus "details about the purpose of your organization". Apollo: *"Once registration is approved, you can use the Apollo playground to test the OAuth flow."* **No published SLA, cost or eligibility criteria.** Do not invent one. - **There is no separate developer-account signup.** `developer.apollo.io` is a dashboard hanging off your normal Apollo login (keys, usage, subscription, and — once registered — the OAuth integration page and playground). Any runbook that tells someone to "create an Apollo developer account" is describing something that does not exist. - **Endpoints: authorize `https://app.apollo.io/#/oauth/authorize`, token and refresh `https://app.apollo.io/api/v1/oauth/token` — both on the app host, while data calls go to `https://api.apollo.io/api/v1`.** The authorize URL is **hash-routed** (§5). - **Up to 4 OAuth redirect URLs**, HTTPS only, entered comma-separated in one field (§6). - **Scopes are locked in.** Apollo: *"if you edit these scopes, you need to repeat this entire authorization flow to set up OAuth again."* - **Access tokens last 30 days** (`expires_in: 2592000`). **Refresh rotates and revokes**: *"Once you use the refresh token to generate new tokens, the existing tokens are automatically revoked."* - **`read_user_profile` is granted automatically on every OAuth token** — but if you pass a `scope` parameter on the authorize call, Apollo's docs say you must include it explicitly in that parameter. - **The authorizing customer needs a specific permission.** On plans with custom permission profiles, *"Can authorize third-party apps/integrations via OAuth"*; on plans without, **Admin** or **Billing and Seat Manager**. A user without it is redirected to your callback carrying `status_code=403&error_message=You do not have permission to connect integrations…` — an *error on your redirect URL*, not an Apollo error page. Handle it or the customer sees a raw query string. - **API keys come in two kinds.** Scoped (default, you pick endpoints; `403` on anything else) and **master** (all endpoints). Some endpoints — the workspace user list among them — work **only** with a master key. - **An API key is a workspace identity, not a person.** Apollo: *"Every API-key request acts as your workspace's longest-standing active admin."* OAuth tokens act as the granting user. Record ownership differs by credential type. - **Plan gating is real but narrower than folklore.** Apollo's FAQ says *"all Apollo plans include access to our API"* and *"All pricing plans include at least basic access… but more advanced functionality is only available on certain plans."* The documented `403` cause is *"Your plan doesn't include API access, or the endpoint isn't available through the public API for your credentials"* (`API_INACCESSIBLE`). Separately, **free accounts must be registered with a work email address** to use certain search, enrichment and record-retrieval endpoints. - **Errors are mid-migration.** A structured `error_details` object (`code`, `message`, `suggestions`, `context`) is rolling out. Apollo states the legacy root-level `error` / `error_code` / `message` fields are **removed everywhere on 16 February 2027**. Anything branching on a root-level error field has a deadline. - **Apollo runs an MCP server** at `https://mcp.apollo.io/mcp` whose authorization-server metadata is public, advertises PKCE `S256` and exposes a **dynamic client registration** endpoint. It is a useful cross-check on scope spelling, and nothing more — **it is not a back door to REST OAuth credentials.** Its scope list is also not the REST list: several scopes the REST endpoint pages name are absent from it. - **Marketplace listing requires OAuth as the sole authorization method** and **at least 3 unique Apollo teams** connected. Apollo's Partner team responds to applications "within 5 business days". If the portal does not look like this, stop and report what you actually see. ## 1. Which credential, and who issues it One API surface (`https://api.apollo.io/api/v1`), two ways in, and they are not interchangeable: | | **OAuth 2.0 client** | **Per-customer API key** | | --- | --- | --- | | Who issues it | Apollo, after approving your in-product registration | The customer, in their own Apollo workspace | | How it is sent | `Authorization: Bearer ` | An API-key request header | | Acts as | **The user who granted it** | **The workspace's longest-standing active admin** — not the key's creator | | Access control | Scopes fixed at registration | Endpoint list fixed per key; master key = everything | | Lifetime | Access token 30 days; refresh rotates | Until regenerated or deleted | | Multi-tenant fit | Designed for it. One client, many customers, no secret leaves their side | Works, but you hold N customer secrets | | Marketplace | **Required** — sole permitted method | Disqualifies a listing | | Terms position | Apollo has approved the integration by approving registration | API Terms §3 needs Apollo's approval anyway (§9) | **The honest recommendation: OAuth.** It is what Apollo built for this shape, it keeps customer secrets out of your database, its per-user attribution is what customers expect when records appear in their workspace, and it is the only path to a listing. **When per-customer keys are still the right answer:** you need something working before Apollo's approval lands; the customer refuses OAuth; or the connector needs an endpoint a scoped key reaches more conveniently than the approved scope set does. Treat it as a documented interim state with an owner and a date, not a default — and read §9 first. > **Product fact — dated.** As of **2026-09-20**, this platform's Apollo connector supports **both** models. In > **API-key** mode it asks the customer for a single key and sends it in Apollo's API-key request header, pointing them > at Apollo's own "create an API key" documentation page. In **OAuth** mode it uses **one shared OAuth client held by > the platform** (customers do not bring their own), runs the authorization-code flow against Apollo's app host, > exchanges and refreshes with a form-encoded POST carrying client ID and secret, and sends a bearer token on data > calls to Apollo's API host. It requests a **per-object, per-direction** scope set covering person and company > enrichment, CRM companies, contacts, deals and deal stages, the workspace user list, custom fields, and outreach > email events. It carries a workaround for Apollo's **hash-routed authorize URL** (§5) and reads the signed-in user's > profile after authorization to name the connection. It records Apollo's rate limits only as a pointer to Apollo's > pricing-comparison page, and the API-documentation link it stores for Apollo (an old GitHub-hosted docs site) > **now returns 404** — the live documentation is `docs.apollo.io`. Its error surfacing reads Apollo's **legacy > root-level error field**, which Apollo removes on 16 February 2027 (see Platform state). **Confirm all of this with > the connector's owner before acting on it** — connector configuration changes independently of this skill. ## 2. Reuse the existing credentials, or register new ones **A new OAuth client means a new client ID, and every existing customer authorization is bound to the old one.** Every customer re-authorizes. Reuse what exists for: adding a redirect URL, rotating a compromised secret, or diagnosing a failure. Note the asymmetry Apollo created: **adding a redirect URL is an edit; adding a scope is a redo.** Apollo's own warning is that editing scopes means repeating the entire authorization setup. Before you assume "we'll just add the scope later", read §7 and price that in. Register a **new** client only when the user explicitly wants one: a separate product, a replacement for a compromised client, or a deliberate migration off per-customer keys (which is a migration project — every customer reconnects). For **per-customer API keys** the calculus is gentler: each customer's key is independent, so regenerating or deleting one affects exactly one workspace. Say which path you are taking before you start. ## 3. Account and permissions There is no developer-account signup to complete. What you need is: - **An Apollo account that will own the app.** Registration lives at Settings → `https://app.apollo.io/#/settings/integrations` → **API Keys** (which opens the developer dashboard at `https://developer.apollo.io/`). Whoever owns that account owns the client ID and secret, so pick deliberately — a shared company workspace, not an individual's trial. - **Permission to create keys.** Apollo's FAQ: *"You must be an admin for your Apollo account or be assigned the necessary permission profile to create API keys."* A customer contact who reports there is no API Keys section is telling you about their permission profile, not about Apollo. - **Permission to authorize, on the customer side.** Different permission, different people: *"Can authorize third-party apps/integrations via OAuth"* on plans with custom permission profiles; **Admin** or **Billing and Seat Manager** otherwise. Name this in your connect instructions, because the failure lands on *your* callback as a 403 query string (§11). - **A workspace to test against.** Apollo publishes no sandbox or developer tenant. Testing — including any write — happens against a real workspace whose records and credits are real. Say that out loud before anyone runs a create. Anything only a human can do — signing in, email verification, accepting terms, submitting the registration form, being granted an Admin profile — hand back rather than looping. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the redirect URLs from §6 and the scope strings from §7 — then continue once they report back with the client ID. ## 4. Register the OAuth app, and wait 1. **Settings → Integrations → API Keys → OAuth registration.** 2. Fill in, exactly: - **App Name** — what every customer sees on the consent screen. - **App Logo** — same. - **OAuth Redirect URL** — all of them, comma-separated, in this one field (§6). - **Scopes** — the complete set (§7). Apollo adds `read_user_profile` and `app_scopes` by default. - **Purpose of your organization** — what Apollo reviews. Describe the platform accurately: it connects Apollo's API on behalf of mutual customers, who authorize access themselves. **Do not invent** customer counts, volumes, security certifications, retention periods or compliance claims — collect the questions and hand them back. 3. **Submit**, and wait for approval. Apollo publishes no turnaround. Do not promise one. 4. On approval, **copy the client ID and secret**. Apollo: *"The only time the secret is displayed is when it is generated… it will not be shown again."* The client ID remains visible on the OAuth integration page afterwards. 5. **Use the playground before writing code.** Once registered, the developer portal exposes **OAuth Integration → Playground**: get an authorization code, exchange it for an access token, fetch data. Three clicks that prove the redirect URL, the scopes and the approval all landed — before any of your own code is in the picture. **Data licensing is a separate conversation.** Apollo: *"Integrations for the purpose of sharing, exposing, or reselling data to non-Apollo users requires a custom contract with Apollo."* If the product surfaces Apollo-sourced data to anyone who is not an Apollo customer, that is a contract question for Apollo, not a scope you can request. ## 5. The authorize call — hash-routed, and why clients break on it Apollo's authorize endpoint is a front-end route behind a fragment: ``` https://app.apollo.io/#/oauth/authorize?client_id=&redirect_uri=&response_type=code&scope=&state= ``` The query string sits **after** the `#`. A client that appends parameters the ordinary way — treating the URL as a normal URL and letting a URL library place the query — produces `https://app.apollo.io/?client_id=…#/oauth/authorize`, the single-page app never receives them, and Apollo answers with a missing-parameter complaint about `client_id` that reads like a registration problem and is not one. Build the URL so the parameters follow the fragment path, and eyeball the finished string once. Parameters, as Apollo documents them: `client_id` (required), `redirect_uri` (required, one of the registered URLs), `response_type=code` (required), `scope` (optional — omit it and the registered set applies; include it and you must list `read_user_profile` explicitly if you want it), `state` (optional — treat it as mandatory; it is your only CSRF defence). Scopes are separated by **URL-encoded spaces**. **Exchange** — `POST https://app.apollo.io/api/v1/oauth/token`, form-encoded, with `grant_type=authorization_code`, `code`, `client_id`, `client_secret`, and `redirect_uri` only if you are switching to a different registered URL. Response: `access_token`, `token_type: "Bearer"`, `expires_in: 2592000`, `refresh_token`, `scope`, `created_at`. **Refresh** — same endpoint, `grant_type=refresh_token` with `refresh_token`, `client_id`, `client_secret`. Optionally a `scope` parameter to *narrow* the new token (it must be a subset of what you registered). The response carries a **new** access token **and a new refresh token**, and *"the existing tokens are automatically revoked."* Store the new refresh token from every response, or the second refresh fails. Thirty days is long enough that a client which ignores rotation looks healthy for a month before it does not — refresh on a schedule and confirm the write-back, rather than lazily at first failure. ## 6. Redirect URLs — you get four One field, comma-separated, **HTTPS required**, **maximum 4**. For this platform that is one callback per data center: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` That is **exactly four — the cap, with nothing spare.** There is no room for a fifth region, a staging host or a `localhost` for a developer's laptop. Confirm the current list with the platform owner rather than assuming, and if a new region is on the roadmap, raise the ceiling with Apollo *now* rather than discovering it on launch day. Apollo does not document its matching rules, so assume exact string matching and no trailing-slash forgiveness. The `redirect_uri` on the authorize call must be one of these; on the token exchange, send it only when deliberately switching to another registered URL. ## 7. Scopes — get them right the first time Apollo's scopes are per endpoint, and **each endpoint's reference page names its own** in an "Endpoint essentials" table, alongside the scoped-key path and the credit cost. That table is the authority. Read it for every endpoint the connector calls; do not extrapolate from a sibling, because **Apollo's spelling is not systematic** — contact and account scopes are **singular** on writes and **plural** on search. Verified on 2026-09-20 against Apollo's endpoint reference pages: | Capability | Endpoint | Scope | | --- | --- | --- | | Search saved contacts | `contacts/search` | `contacts_search` | | Create a contact | `contacts/create` | `contact_write` *(singular)* | | Update a contact | `contacts/update` | `contact_update` *(singular)* | | Search saved accounts | `accounts/search` | `accounts_search` | | Create an account | `accounts/create` | `account_write` *(singular)* | | Update an account | `accounts/update` | `account_update` *(singular)* | | List deals | `opportunities/search` | `opportunities_list` | | View a deal | `opportunities/show` | `opportunity_read` | | Create a deal | `opportunities/create` | `opportunity_write` | | Update a deal | `opportunities/update` | `opportunity_update` | | List deal stages | `opportunity_stages/index` | `opportunity_stages_list` | | List workspace users | `users/search` | `users_list` **(master key in API-key mode)** | | Current user profile | `users/api_profile` | `read_user_profile` *(default — granted automatically)* | | List fields | `fields/index` | `custom_fields_list` | | Person enrichment | `people/match` | `people_match` | | Organization enrichment | `organizations/enrich` | `organizations_enrich` | | Search outreach emails | `emailer_messages/search` | `emailer_messages_search` | | People search (database) | `mixed_people/api_search` | `mixed_people_api_search` | | Organization search (database) | `mixed_companies/search` | `mixed_companies_search` | | API usage and limits | `usage_stats/api_usage_stats` | `api_usage_stats_read` | | Credit usage | `usage_stats/credit_usage_stats` | `credit_usage_stats_read` | Two rules that outrank the list: 1. **Locked in means locked in.** Apollo warns that editing the registered scopes means repeating the whole authorization setup. A connector whose object coverage grows quarterly should register the scopes for its *roadmap*, not just today's objects — while still requesting only what it can justify, because that same set is what every customer reads on the consent screen and what Apollo reviews. 2. **Scopes never exceed the human.** Apollo: *"Apollo developer tools follow your Apollo permissions, plan access, and credit availability. If your Apollo account can't access a feature, perform an action, or use credit-consuming data, developer tools like Apollo API, MCP, and CLI can't bypass those limits."* A perfect scope set on a customer whose plan lacks the feature still returns `403`. A useful cross-check, not a source of truth: Apollo's MCP authorization server publishes its scope vocabulary at `https://mcp.apollo.io/.well-known/oauth-authorization-server`, which is handy for confirming an exact spelling. It is the **MCP** server's set — several REST scopes above do not appear in it — so never treat its absence as evidence. **Watch for deprecated endpoints while you are in here.** The older custom-fields endpoint is marked deprecated in favour of the fields endpoint (with a `source: custom` filter); both currently carry the same scope, so a connector can drift onto a deprecated path without any scope symptom to warn it. ## 8. Plan gating and rate limits **Plan gating.** Correct the folklore in both directions: API access is not a paid-only feature, and it is not uniformly available either. Apollo's FAQ says all plans include at least basic API access; specific endpoints and "more advanced functionality" are plan-dependent, and free accounts additionally need a **work email address** for certain search, enrichment and record-retrieval endpoints. The symptom is a `403` (`API_INACCESSIBLE`) on *one* endpoint while everything else works — which reads like a broken permission and is a plan. **Rate limits are per team, per endpoint, in three simultaneous windows** (minute, hour, day). All keys and users in a workspace share them, and child workspaces share their parent's. Every applicable window must be satisfied. | Requests | Free | Basic | Professional | Organization | | --- | --- | --- | --- | --- | | Per minute | 50 | 200 | 200 | 200 | | Per hour | 200 | 400 | 400 | 600 | | Per day | 600 | 2,000 | 2,000 | 6,000 | **Enrichment endpoints** get 1,000/minute on all paid plans with **no hourly or daily limit** (free: 50/minute, 20 for bulk). **Search endpoints** get 200/minute, 6,000/hour and **50,000/day** on paid plans. Searching the customer's own saved records is *not* a database search and falls back to the plan table above — a distinction that quietly explains why a CRM sync hits a ceiling that a prospecting workflow never sees. A few endpoints are tighter still: the analytics report endpoint is **5 requests per hour**. Read the limits off the response rather than a table: `x-rate-limit-minute`, `x-rate-limit-hourly`, `x-rate-limit-24-hour`, the matching `x-*-usage` and `x-*-requests-left` headers, and — on a `429` — **`retry-after` in seconds**. Windows are **fixed and unaligned to the clock**: a window opens on your first request to that endpoint and closes when its duration elapses. Empty `x-rate-limit-*` means no limit in that window for that endpoint. These headers appear only *after* authentication and authorization succeed, so a 401 or 403 carries none of them. For a legacy plan, or to confirm anything above, call the usage-stats endpoint rather than trusting the table — Apollo says to use it as the source of truth. ## 9. Credits — a successful integration can still cost the customer money This is the failure mode with no error message. Rate limits govern how *fast* you may call; **credits govern how much data you may receive**, they are the customer's, and they are separate budgets. Apollo is explicit that a higher enrichment rate limit "isn't a license to send unlimited requests: your credit balance still governs how much data you can enrich." Verified 2026-09-20 from Apollo's API pricing page. Everything not listed consumes **0 credits** — creating, updating, listing and managing records is free: | Endpoint | Credits | | --- | --- | | People enrichment / bulk people enrichment | **1–9 per person**, charged only when credit-consuming data is found: 1 for demographics or email, **+8 if a mobile phone is returned** | | Person enrichment *with waterfall* | Email typically **1–4**, "some vendor configurations or successful higher-cost matches may result in 20+"; phone typically **8–25**, "some configurations may result in 45+". **Some vendors consume credits per lookup even when no data is found** | | Organization enrichment / bulk | 1 per organization | | Get complete organization info | 1 per company | | Get complete person info | 1 per person | | Organization search | **1 per page** (up to 100 results) | | Organization job postings | 1 per page | | News articles search | 1 per page | | Conversation info / export | 1 per conversation **that has AI insights**, else 0 | Three consequences worth stating to whoever owns the integration, in writing: - **Pagination multiplies cost.** Per-page charging means a wide search paged to exhaustion is charged per page, and Apollo says so: *"If you paginate through results or run repeated searches, your total credit usage can increase."* A retry loop over a credit-consuming endpoint spends real money. - **Waterfall enrichment is a customer-side configuration you cannot see** and it can multiply the cost of a single call by five or more, including on misses. If the connector enriches, the customer's waterfall settings are part of your cost model whether or not anyone told you. - **Credit exhaustion is not a rate limit and does not resolve by waiting.** Apollo's structured error rollout includes distinct codes for credit exhaustion; branch on `error_details.code`, surface it to the customer as a billing condition, and stop the job rather than retrying it. Apollo exposes a credit-usage endpoint, and the developer dashboard shows consumption for the current billing cycle. **Test with a small request and check usage before scaling any workflow** — Apollo's own recommended procedure. **Legacy plans differ.** Apollo notes that credit usage on legacy plans "may differ" from the published table and points customers at their own in-app credits page. Do not quote these numbers to a customer as *their* numbers. ## 10. Terms, and whether you may hold a customer's key at all This is the question most people skip, and Apollo answers it directly. From the **API Terms of Service** (last updated 13 August 2024): - **§3 Third Party Access** — *"You may not access the APIs via a third party's API credentials or integrate the APIs with your product or services, unless Apollo has authorized or approved such access or integration."* Read it twice: it covers **both** halves of the thing a multi-tenant platform does. Approved OAuth registration is the clean way to be inside "authorized or approved"; a pile of customer API keys is not, on its own. - **§2 License** — the licence is *"solely for your internal business purposes"* and *"You may not sublicense, sell, or distribute (including but not limited to sharing) the APIs."* - **§5 Restrictions** — no using the API *"to replicate or compete with any Apollo products or services, as determined by Apollo in its sole discretion"*, and no *"sell, access or sublicense the API for use by a third party."* - **§7 Confidentiality** — *"You shall keep confidential all API access credentials, passwords, and tokens, and shall prevent third parties from making unauthorized use of your credentials."* Apollo's **Marketplace security requirements** add operating rules that apply whether or not you list: Apollo end-user data stored outside Apollo must be **encrypted at rest**; TLS 1.2+ with HSTS (minimum one year); secrets must never live in source code, repositories, URL strings, referrer headers or application logs; keys should be **rotated, with 90 days recommended**; *"An application must not insecurely store or share credentials belonging to Apollo user accounts such as user passwords or user API tokens"*; and security incidents must be reported to Apollo **within 24 hours**. Note the wording — Apollo regulates *insecure* handling of customer tokens rather than banning the pattern, and separately expects partners to document how a customer creates *"a unique API key with the unique app name, with access strictly limited to the functionality that the application needs"*. Both models are contemplated; only one is listable. **Marketplace listing** (optional, and the reason to care about the above): a direct integration with Apollo's REST API or MCP server; **OAuth 2.0 as the sole authorization method**; **at least 3 unique Apollo teams connected**; brand guidelines; agreement to the Terms and the security requirements. Applications go through Apollo's Partner team, who respond **within 5 business days**; an approved application leads to a listing you claim and Apollo reviews before publishing. **Stop and ask** before answering anything you would have to invent on any of these forms: certifications, retention periods, subprocessors, breach procedures, customer counts, volume projections, or a named responsible individual. ## 11. If you are on per-customer API keys: the admin runbook Send the customer this, verbatim-ish. It is short because the Apollo side is short; the traps are at the end. 1. **You need to be an Apollo admin**, or hold a permission profile that allows managing API keys. If you cannot see the section below, that is why. 2. In Apollo, go to **Settings → Integrations → API Keys** (this opens Apollo's developer dashboard). 3. Click **API Keys → Create new key**. 4. **Name it after the integration** and write a real description. Whoever audits your keys in a year will have only this to go on. 5. **Select only the endpoints the integration needs.** Leave **Set as master key** *off* unless the integration requires an endpoint that demands one — the workspace user list is the common example. A master key can take any API action your Apollo account allows; treat it like a password. 6. Click **Create API key**, then **copy it immediately** and paste it into the place your vendor asked for. Do not email it, and do not paste it into a ticket or chat. 7. To confirm it works before handing it over, call Apollo's health endpoint (`GET /api/v1/auth/health`) with the key in the API-key header. Both values in the response should be `true`. (Apollo notes a common cause of failure: **a stray space or newline after the key** when it was pasted.) 8. Keys can be **regenerated or deleted** from the same page, and usage and per-endpoint rate limits are visible under **Usage**. Regenerating breaks the integration until the new value is delivered — plan it. Three things to tell them in the same message, because each produces a support ticket otherwise: - **The key is not "yours" in Apollo's eyes.** Records the integration creates are attributed to the workspace's longest-standing active admin, not to whoever made the key. If that matters, the integration should set an owner field explicitly where the endpoint offers one. - **Adding an endpoint later means editing or replacing the key.** A scoped key returns `403` on anything not selected, and that `403` looks exactly like a plan problem. - **Rate limits and credits are shared by the whole team.** Your integration draws from the same per-team budgets as every other tool and every person using Apollo in the browser. ## 12. Verify end-to-end A registration that half-worked surfaces later as a customer who cannot connect. Prove it: 1. **Use Apollo's playground first** (§4) — authorization code, token, data — before any of your own code is involved. 2. Run a real connect through your platform's own flow and complete the callback. Check the finished authorize URL character by character; the hash routing (§5) fails silently in the direction that looks correct. 3. Confirm the connection stored an access **and** a refresh token, and that one read call succeeds. 4. **Read the granted `scope` off the token response** and diff it against what you asked for. This is the cheapest possible check that the registered set and the requested set agree. 5. **Refresh, then refresh again using the token returned by the first refresh.** Two minutes of work that catches a client ignoring rotation — otherwise a failure that appears thirty days later. 6. Test with a customer user who **lacks** the OAuth-authorization permission, and confirm your callback renders something human for the 403 query string rather than leaving them staring at a URL. 7. Run one **credit-consuming** call and check the customer's credit usage moved by the amount you predicted (§9). 8. Watch the rate-limit headers during a real sync, not a single call — and note which window binds first. 9. Re-run against a **second** workspace on a **different plan tier**, not the one that registered the app. | Symptom | Cause | | --- | --- | | Authorize page says a required parameter such as `client_id` is missing | Query string placed before the `#` on the hash-routed authorize URL — the page never receives it (§5) | | Customer lands back on your callback with `status_code=403&error_message=You do not have permission…` | The authorizing user lacks *Can authorize third-party apps/integrations via OAuth* (or Admin / Billing and Seat Manager) (§3) | | Redirect mismatch for one region | That callback host is not among the **4** registered redirect URLs (§6) | | `403` on **one** endpoint, everything else fine | Scoped key without that endpoint, an endpoint that requires a **master** key, or the customer's plan does not include it (`API_INACCESSIBLE`) (§7, §8) | | The workspace user list `403`s | Master key required in API-key mode; `users_list` scope in OAuth mode (§7) | | Free-plan customer gets `403` on search or enrichment only | Free accounts must be registered with a **work email address** for those endpoints (§8) | | A scope the customer needs was never requested | Scopes are locked in at registration; adding one means repeating the authorization setup (§7) | | Worked for a month, then 401 everywhere | 30-day access token, never refreshed (§5) | | Second refresh fails, first succeeded | Refresh tokens rotate and the old pair is revoked; store the new one every time (§5) | | `429` under light load | Per-team, per-endpoint, three-window limits shared with every other tool in the workspace; back off by `retry-after` (§8) | | `429` on a sync of the customer's own records while prospecting calls are fine | Saved-record search is not a database search and uses the lower plan limits (§8) | | Calls succeed, customer complains about credits | Enrichment and per-page search spend credits, and waterfall configuration can multiply them (§9) | | Enrichment stops returning data, no rate-limit headers | Credit exhaustion, not a rate limit; waiting will not fix it (§9) | | Records appear under the wrong owner | API-key requests act as the workspace's longest-standing active admin, not the key's creator (§1) | | Error handling breaks on a date in 2027 | Legacy root-level error fields are removed 16 February 2027; branch on `error_details.code` (Platform state) | ## 13. Hand off — never commit the secret - **Do not** write a client secret, refresh token or customer API key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel — Apollo's own security requirements name source code, repositories, URL strings, referrer headers and application logs explicitly. Values go to the human running this, for their secret store or console. - The **client ID** is not a secret and may be recorded. The secret is, and Apollo shows it **once**. - If a code change is needed (a callback host, a scope string, a token host), keep it secret-free and say plainly what the human must set out of band. - Report a secret once so it can be pasted into the secret store, say clearly that it is now in the transcript and can be regenerated, then move on. - Close with: which credential model you ended up on; the client ID if OAuth; where the secret was delivered; the authorize and token endpoints; the **exact** scope strings registered and the fact that changing them is a redo; the four redirect URLs as registered; the customer plan tiers in play and the rate-limit ceiling that follows; **who owns the credit budget and what the expected consumption is**; Apollo's approval status and date; and whatever is still waiting on Apollo. ## Stop and ask Hand back to a human rather than guessing when: the OAuth registration form asks for organizational, security, compliance or volume claims you would have to invent; **Apollo's approval has not arrived** and someone wants a date (there is no published SLA — say so in the first reply); the platform needs a **fifth** redirect URL and the cap is 4; a scope is missing and adding it means redoing the authorization setup for every customer; someone proposes moving live per-customer key connections to OAuth, or the reverse (either way, every customer reconnects); the product would expose Apollo data to non-Apollo users (that is a data-licensing contract, not a scope); someone asks you to store customer API keys without having read API Terms §3; the only available test workspace is a customer's production account and the task involves writes or credit-consuming calls; someone wants to treat the MCP server's dynamic client registration as a REST-API credential route; a promised sync cadence does not fit the per-team rate limits; or the portal does not match the **Platform state** section. When a portal behaves in a way the docs did not describe, say what you found rather than picking the option that lets you keep going. ## References Verified to resolve on 2026-09-20. - Build with Apollo (surfaces, auth models, plan scope) — https://docs.apollo.io/docs/build-with-apollo - Authentication (API key vs OAuth; which user requests act as) — https://docs.apollo.io/reference/authentication - OAuth 2.0 (Partners) — registration, flow, refresh, token lifetimes — https://docs.apollo.io/docs/use-oauth-20-authorization-flow-to-access-apollo-user-information-partners - Create an API Key (master vs scoped, managing keys, usage) — https://docs.apollo.io/docs/create-api-key - Test an API Key (health endpoint) — https://docs.apollo.io/docs/test-api-key - Rate Limits (per-team, per-endpoint, three windows, headers) — https://docs.apollo.io/reference/rate-limits - API Pricing and Credits — https://docs.apollo.io/docs/api-pricing - Status Codes and Errors (`error_details`, legacy-field removal date) — https://docs.apollo.io/reference/status-codes - Changelog: API errors now include structured details — https://docs.apollo.io/changelog/api-errors-now-include-structured-details - Developer FAQs (plan access, who can create keys, permissions ceiling) — https://docs.apollo.io/docs/developer-faqs - Feature Your Integration (Marketplace requirements and security requirements) — https://docs.apollo.io/docs/add-your-integration-to-apollos-marketplace - Set Up Your Marketplace Listing — https://docs.apollo.io/docs/set-up-your-marketplace-listing - View API Usage Stats and Rate Limits — https://docs.apollo.io/reference/view-api-usage-stats - View Credit Usage Stats — https://docs.apollo.io/reference/view-credit-usage-stats - Get a List of Users (master key / `users_list`) — https://docs.apollo.io/reference/get-a-list-of-users - Get Current User Profile (`read_user_profile`, default scope) — https://docs.apollo.io/reference/get-current-user-profile - Get a List of Fields — https://docs.apollo.io/reference/get-a-list-of-fields - Get a List of All Custom Fields (deprecated) — https://docs.apollo.io/reference/get-a-list-of-all-custom-fields - Search for Contacts — https://docs.apollo.io/reference/search-for-contacts - People Enrichment (credit rules) — https://docs.apollo.io/reference/people-enrichment - Organization Enrichment — https://docs.apollo.io/reference/organization-enrichment - List All Deals — https://docs.apollo.io/reference/list-all-deals - List Deal Stages — https://docs.apollo.io/reference/list-deal-stages - Search for Outreach Emails — https://docs.apollo.io/reference/search-for-outreach-emails - OpenAPI Specification — https://docs.apollo.io/reference/openapi-specification - Full documentation index (every page and endpoint) — https://docs.apollo.io/llms.txt - Apollo MCP — https://docs.apollo.io/docs/apollo-mcp - Apollo MCP authorization-server metadata (scope-spelling cross-check only) — https://mcp.apollo.io/.well-known/oauth-authorization-server - API Terms of Service (third-party access, confidentiality) — https://www.apollo.io/terms/api - Terms of Service — https://www.apollo.io/terms - Technology Partners (partnerships contact form) — https://www.apollo.io/partners/technology - Data reseller / API reseller partnerships — https://www.apollo.io/partners/api-reseller - Marketplace application form — https://apolloio.notion.site/12eab2b3b49680319a5dd39fe6d492be?pvs=105 - Pricing and plan comparison — https://www.apollo.io/pricing - Apollo developer dashboard (sign-in required) — https://developer.apollo.io/ - Apollo integrations settings (sign-in required) — https://app.apollo.io/#/settings/integrations ## asana-oauth-app URL: https://docs.unified.to/skills/asana-oauth-app/SKILL.md # Asana OAuth2 App Registration Get a working Asana OAuth2 client — an app in the developer console, redirect URLs, a scope decision, client ID and secret — for a platform that connects many customers' Asana workspaces. Asana's registration form is short and the OAuth flow is textbook. Two things are not, and both cost a release if you get them wrong. First, **scopes**: Asana spent 2025 moving from a single full-access grant to per-resource granular scopes, and the two modes are **mutually exclusive** — an app registered for granular scopes that sends the old value fails, and an app registered for Full permissions that sends specific scopes also fails. Worse, granular scopes still do not cover every endpoint, so "use least privilege" is not always an available answer (§5). Second, **distribution**: a newly created app is visible to *no* workspace, so authorization fails for everyone, including you, until you set it (§7). Neither failure looks like a scope or distribution problem from the outside. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, icon, description | Shown on the consent screen and in the admin console | | **Asana account** that will own the app | Any Asana user; note whose account it is (§2) | | **Redirect URLs** | Every callback host your platform serves (§4) | | **Which endpoints the connector actually calls** | Decides granular vs Full permissions (§5) | | **Company URL, support URL, privacy policy URL** | Required for the app listing; asked for at App Directory submission (§7) | | **Is an App Directory listing wanted?** | Optional — not required to serve customers (§7) | | **Do customers need a sandbox / is one needed for testing?** | Request lead time is up to a week (§2) | ## Quick Start 1. Confirm a **new app** is needed — existing connections are bound to the current client ID (§1). 2. Sign in to Asana and open the developer console; request a sandbox if you need a safe domain (§2). 3. **Create new app** — name, redirect URL, permission scopes (§3). 4. Add **every** redirect URL, exactly (§4). 5. Make the scope decision: granular scopes, or the Full permissions toggle — not both (§5). 6. Capture client ID and secret from the **OAuth** tab; note the one-hour access token (§6). 7. Set **Manage distribution** — without it nobody can authorize; then consider admin approval and limits (§7). 8. Verify from a **second** account in a different workspace, not the one that owns the app (§8). 9. Hand the credentials over — never commit them (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Granular OAuth scopes exist and are the default path for new apps.** Announced **9 April 2025** as a developer preview with a redesigned consent screen; before that, an Asana app requested full access to the user's account and there was no scope selection at all. At registration you now pick the scopes your app may ever request, in the developer console under **OAuth → Permission scopes**. - **Legacy full access still works, as an explicit opt-out.** The console keeps a **Full permissions** toggle, and the corresponding authorize-time value is the literal scope string `default`. Asana's own OAuth guide still documents both, unchanged as of this check, and gives the reason plainly: *"scopes are not yet available for every Asana API endpoint."* No removal date has been announced. Treat it as supported-but-on-notice, not as a permanent answer. - **Existing apps were not force-migrated.** The April 2025 note said existing apps "continue using full permissions until these self-serve granular scopes are made available later"; Asana's **21 August 2025** release notes then shipped "granular API scopes for existing apps … without requiring app recreation". So an older app is almost certainly still on full access, and moving it to granular scopes is a deliberate action someone has to take. - **A new app gets whatever you pick at registration, and the choice is enforced at both ends.** Registered for granular scopes, the app *must* send an explicit `scope` parameter (this used to be optional). Registered for Full permissions, it must send `scope=default` or no `scope` at all. Mixing them returns `forbidden_scopes` (§5). - **PKCE is supported and recommended, not required.** Plain authorization-code exchange with a client secret still works. Implicit grant (`response_type=token`) was removed back in January 2020. - **The scope catalogue is explicitly "subject to revision"** and has real gaps — sections and team creation have no scope at all today (§5). The scopes reference page was last updated 2026-08-14, so it is moving. If the developer console does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer connection is bound to the old one** — every customer re-authorizes. Reuse the existing app for: adding a redirect URL, changing the scope set, resetting a leaked secret, widening distribution, or diagnosing an authorization failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, or a deliberate split of environments. Note that even on the *same* app, changing the scope set changes what users must consent to — a token only carries the scopes granted when it was issued, so broadening scopes is a re-authorization event for every customer. Say which path you are taking before you touch anything. ## 2. Account: sign in or sign up - The developer console is at **`https://app.asana.com/0/my-apps`** (also reachable from your profile settings). Any Asana user can create apps and personal access tokens there; no separate developer account, no approval gate. - The app is owned by the **individual Asana user** who creates it, not by a company. For a platform integration, use a shared or service-owned account the team controls — not a person who might leave. Ask who owns it and record it. - **Developer sandbox** — a temporary Asana domain with limited users, requested through a form, granted only to developers building a third-party integration intended for the App Directory or to existing Starter-or-higher customers. It can include Enterprise/Advanced/Starter features on request, is valid for at most one year, and **takes up to a week to provision**. If testing needs a sandbox, start the request before anything else. Hand control back to the user for anything only they can do: signup email verification, 2FA, accepting terms, or submitting the sandbox form. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (§4 redirect URLs, §5 scope list), then continue once they report back with the client ID. ## 3. Create the app In the developer console: **Create new app**. Three fields carry the weight: 1. **App name** — what users see on the consent screen and in their authorized-apps list. Editable later under **Basic information**. 2. **Redirect URL** — see §4. 3. **Permission scopes** — the pre-approved list of scopes the app may ever request, or the Full permissions toggle. See §5. Editable later under **OAuth → Permission scopes**. Icon, description and other basics can be added at creation or later. The sidebar tabs worth knowing: | Tab | Holds | | --- | --- | | **Basic information** | App name, icon, description | | **OAuth** | **Client ID and client secret**, redirect URLs, **Permission scopes**, secret **Reset** | | **Manage distribution** | Which workspaces may authorize the app — nobody, until you set it (§7) | | **App listing details** | Company/support/privacy URLs shown to users and to admins approving the app | | **App components** | In-product UI surfaces; triggers a heavier review if you publish (§7) | Asana's docs are internally inconsistent about where the client secret lives (one table says *Basic information*). The registration guide and the console agree it is the **OAuth** tab; trust that. ## 4. Redirect URLs Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes: - **Multiple redirect URLs are supported.** This is a change from the long-standing one-URL-per-app limit that older forum threads and runbooks still describe; Asana's own MCP setup guide now instructs developers to register two. If the console shows only a single field, stop and report that rather than registering four separate apps. - The `redirect_uri` you send must match a registered value **exactly** — no path or trailing-slash forgiveness. Asana's own docs warn that a copied value can lose its trailing slash. - **HTTPS is enforced** for non-native apps; Asana refuses plaintext redirect endpoints because the code travels in the query string. Native/CLI apps use the special value `urn:ietf:wg:oauth:2.0:oob`. - A mismatched `redirect_uri` — like a mismatched `client_id` — is one of the few errors shown as **plain text on Asana's page** instead of being redirected back to you, because Asana has nowhere it trusts to send it. Every other error comes back on the redirect URI. ## 5. Scopes — the decision that defines the app Scopes are space-delimited in the authorize URL and **must be URL-encoded** (spaces become `%20`). The format is `:` where action is `read`, `write` or `delete`, and **they do not imply each other**: `tasks:write` does not grant `tasks:read`. Request both, always, for anything you both read and modify. The scopes a task/project connector typically needs: | Scope | For | | --- | --- | | `tasks:read` `tasks:write` `tasks:delete` | Tasks, subtasks, dependencies, project/tag membership, search | | `projects:read` `projects:write` `projects:delete` | Projects, per-team and per-workspace project lists, custom field settings | | `stories:read` `stories:write` | Comments and activity on tasks and goals | | `attachments:read` `attachments:write` `attachments:delete` | Files on tasks | | `users:read` | Users, workspace and team membership lists, and *any* nested user field (see below) | | `teams:read` `team_memberships:read` | Teams and their members | | `workspaces:read` | The workspace list — how you discover what the token can see | | `custom_fields:read` `custom_fields:write` | Custom field definitions and enum options | | `tags:read` `tags:write` | Tags | | `webhooks:read` `webhooks:write` `webhooks:delete` | Webhook subscriptions — easy to forget until sync silently stops | | `openid` `email` `profile` | Identity only, via OpenID Connect. Not data access | Others exist (portfolios, goals, roles, time tracking, out-of-office, project/task templates, custom types, jobs, `workspaces.typeahead:read`). Read the scopes reference rather than guessing a name — a name that does not exist is rejected as `invalid_scope`. **Three rules that bite:** 1. **Full permissions and granular scopes are mutually exclusive.** With Full permissions on, sending specific scopes fails with `forbidden_scopes: Your app is not allowed to request user authorization for '' scopes.` — send no `scope` parameter, or `scope=default`. With granular scopes registered, sending `default` fails with `forbidden_scopes: … for default identity scopes`. Requesting a scope you never registered fails the same way. 2. **You may request a subset of what you registered, but requests are never additive.** To add a scope later, send the user back through authorization with the **complete** list — the new token replaces the old grant. Check what a live token actually carries with the token introspection endpoint rather than assuming. 3. **Nested objects are redacted unless you hold their scope.** On any related object in a response, only `gid`, `name`/`title`, `resource_type` and `resource_subtype` come back by default. Anything more, requested through `opt_fields`, needs that object's scope: `opt_fields=assignee.email` without `users:read` is a **403**, not a quietly missing field. A connector that maps assignee emails, follower names or project owners needs `users:read` even though it never calls a user endpoint. **Where granular scopes genuinely cannot reach.** As of this check the catalogue has no scope for **sections** (`GET`/`POST /projects/{gid}/sections`, `GET /sections/{gid}`) and **no `teams:write`** — creating a team has no granular scope. A connector that manages project sections or creates teams therefore cannot run on granular scopes today; Full permissions is the only working configuration for it. This is also not hypothetical drift: in December 2025 the API demanded a `custom_field_settings:read` scope that the console did not offer, and Asana's own advice was to fall back to `scope=default` until it was fixed (by inheriting from the parent resource) in February 2026. Expect the gap list to move; re-derive it from the scopes reference for the endpoints your connector actually calls. > **As of 2026-09-20, Unified.to's Asana connector requests the legacy full-access permission value — the literal > `default` scope — for every object it supports** (tasks, projects, comments, files, people/teams and custom field > metadata, reads and writes alike), plus `openid`, `email` and `profile` for the login flow. It does **not** request > granular per-resource scopes, and it does **not** use PKCE for Asana. In practice that means the app it is pointed > at **must** have the Full permissions toggle on: register granular scopes instead and every authorization fails at > the consent screen with `forbidden_scopes`. It also means the connector inherits whatever deprecation timeline > Asana eventually puts on full access, and that its stored notes about which scopes "don't exist" reflect the 2024 > catalogue, not today's. Confirm the current state with the connector's owner before registering anything. Say in your summary which mode you chose and why. If you choose Full permissions, say explicitly that it is a deliberate fallback because of the endpoint gaps — customer security reviewers ask, and "we didn't look" is a bad answer. ## 6. Capture the credentials From the developer console → your app → **OAuth** tab: **client ID** and **client secret**. The secret can be re-read there, and **Reset** issues a new one. Capture and record: - **Client ID** and **client secret** - Authorize: `GET https://app.asana.com/-/oauth_authorize` - Token exchange and refresh: `POST https://app.asana.com/-/oauth_token` - Revoke: `POST https://app.asana.com/-/oauth_revoke` · Introspect: `POST https://app.asana.com/-/token_info` - API base: `https://app.asana.com/api/1.0/` — one host for everyone, no per-customer domain to discover - OIDC user info: `https://app.asana.com/api/1.0/openid_connect/userinfo` · discovery: `https://app.asana.com/api/1.0/.well-known/openid-configuration` - Whether the app is registered for Full permissions or a granular scope list, and the exact scope strings Authorize parameters: `client_id`, `redirect_uri`, `response_type`, **`state` (documented as required)**, `scope`, plus `code_challenge` / `code_challenge_method=S256` if you use PKCE. `response_type` is `code`, `id_token`, or the space-delimited `code id_token` when you want an ID token alongside the code. **Token behaviour:** - **Access tokens last one hour** (`expires_in: 3600`). Short — a connector must refresh, not re-authorize. - **Refresh tokens are long-lived and do not rotate.** They are returned only on the initial code exchange; a refresh response does not hand back a new one, so keep using the original. Introspection reports a refresh token's remaining life in the order of years. There is no documented idle expiry, which makes a sudden `invalid_grant` a revocation or a scope change, not a timeout. - Both the exchange and the refresh are **form-encoded POSTs carrying `client_id` and `client_secret`** — this is a confidential client; there is no public-client variant to fall back on. - The exchange response includes a `data` object with the authorizing user's `gid`, `name` and `email` — enough to identify the connection without a separate call. - **Revocation takes the refresh token, not an access token** (bearer tokens are rejected outright). Revoking the refresh token kills the bearer tokens derived from it. - **Treat tokens as opaque.** Asana explicitly warns that token formats may change without notice — any client-side validation of their shape is a future outage. **The PAT alternative, and why it is not the answer here.** The same console issues personal access tokens: long- lived, no OAuth flow, and carrying exactly the access of the user who created them. They are fine for a script or a one-off backfill. They are not a multi-tenant answer — a PAT is one person's credential in one person's workspaces, every action is attributed to them, it dies when they leave or lose access, and you would be asking each customer to generate and paste a password-equivalent. Asana's own guidance is that an app acting on behalf of users should use OAuth. If someone proposes PATs to skip registration, say this plainly. Resetting the client secret invalidates the old one: existing access tokens live out their hour, but every exchange and refresh fails until the new secret is deployed. Never reset without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be reset, then move on. ## 7. Distribution, admin control, and limits **Distribution is not optional.** A newly created app is visible to no workspace, and the authorize endpoint refuses everyone — including the app's own creator — with *"This app is not available to your Asana workspace or organization."* In **Manage distribution**, choose: - **Specific workspaces** — one or more workspaces *you are a member of*. Any member of those workspaces can then authorize. Selecting the mode but adding no workspace leaves the app broken in exactly the same way as not setting it, which is a common self-inflicted wound. - **Any workspace** — any Asana user with the authorize URL can connect. This is what a multi-tenant connector needs, and it is **self-serve: no review, no approval, no listing required**. Narrowing from Any workspace back to Specific blocks *new* authorizations only; existing tokens keep working. **Workspaces and organizations.** A workspace is the top-level container; an *organization* is a workspace tied to a company's email domains, and you tell them apart by the `is_organization` property. Users can belong to several, and the token sees exactly what its user sees — Asana has no tenant-admin grant in the OAuth flow. Discover reachable workspaces with `GET /workspaces` rather than asking the customer. Asana announced in April 2023 that authorizations would become **workspace-scoped**, so a user in several workspaces authorizes once per workspace; current docs do not restate this, so confirm at the consent screen during §8 rather than assuming either behaviour. **Admins can block you.** Organization-level super admins have an **Apps** tab in the admin console (not available to divisions) and can run an approve-list mode in which users cannot connect anything unapproved — they see a "request admin approval" prompt instead. Your **App listing details** (company, support URL, privacy policy) are what that admin reads when deciding. Fill them in even if you never publish. A connector that works at one customer and is refused at another, with no error from Asana's API, is usually this. **Service Accounts are the admin-installed alternative, and are Enterprise-only.** A super admin creates one; it has complete access to all data in the organization, including private user data, and it authenticates with its own token rather than through your OAuth app. Its scopes are a different system (SCIM user provisioning, audit logs, exporting, workspace events, AI Studio usage, plus a Full Permissions option for the standard API). Recommend it only when a customer explicitly wants org-wide, non-user-attributed access — and say out loud that it is a broader grant than an OAuth connection, not a narrower one. **Rate limits are per token, not per app or per workspace:** | Limit | Value | | --- | --- | | Requests/minute | **150** on free domains, **1,500** on paid | | Concurrent requests | 50 GET, 15 POST/PUT/PATCH/DELETE (reads and writes limited independently) | | Search API | 60 requests/minute, separate from the above | | Duplication / instantiation / export jobs | 5 concurrent per user, shared with jobs started in the web UI | | Cost-based limiter | Expensive responses (many `opt_fields`, deep graph traversal) are costed **after** the response is built and deducted from a per-minute quota — you can be throttled at low request volume | `429` responses carry `Retry-After` in seconds, and **rejected requests still count against the quota**, so an impatient retry loop extends the outage. The per-token shape means one noisy customer cannot throttle another, but a connector that fans out across a customer's whole workspace on one token can throttle itself. **App Directory listing is optional.** Submitting for review is not required to obtain a token or to serve customers; it is for discoverability. The app must be shared to Any workspace, the listing fields must be filled, and Asana's team aims for an **initial response within a week** — longer for apps with app components, which get a security and QA review of their endpoints and authorization. Do not promise a customer a listing date. ## 8. Verify end-to-end Authorizing in your own workspace with the app distributed to that workspace proves almost nothing. Test the path a customer takes: 1. Authorize as a **different user in a different workspace**, through your platform's real connect flow, with the distribution setting you intend to ship. 2. Read the consent screen: confirm the permissions listed match the scope mode you registered, and note whether it asks the user to pick a workspace. 3. Confirm the token response carries a **refresh token** and the expected `data` user block, then force a **refresh** and confirm the same refresh token still works afterwards. 4. Call one endpoint per object the connector maps — including one with `opt_fields` that reaches into a nested object — so a missing `users:read` surfaces now and not in production. 5. If webhooks are in play, create one. Webhook scopes are separate and are the classic omission. | Symptom | Cause | | --- | --- | | Plain-text error page, never redirected back | `client_id` or `redirect_uri` does not match a registered value (§4) | | "This app is not available to your Asana workspace or organization" | Distribution not set, or set to Specific with no workspace added (§7) | | `forbidden_scopes: … for default identity scopes` | App is registered for granular scopes but the client sends `default` (§5) | | `forbidden_scopes: … for '' scopes` | App is on Full permissions but the client sends specific scopes — or the scope was never registered (§5) | | `invalid_scope` | Scope name does not exist in Asana's catalogue, or the API wants a scope the console does not offer (§5) | | 403 on a field that exists in the docs | Nested field requested via `opt_fields` without that object's scope (§5) | | 403 only at one customer, no API error visible | Admin app-approval mode in their organization (§7) | | Auth works, writes fail | `write` does not imply `read` and vice versa — register both (§5) | | Everything 401s after an hour | Client is not refreshing; access tokens live 3600s (§6) | | `invalid_grant` on refresh | Token revoked, app disconnected by the user, or scopes changed — re-authorize (§6) | | 429 at low request volume | Cost-based limiter on expensive `opt_fields` responses (§7) | ## 9. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a redirect host, a scope list), keep it secret-free and say what the human must set out of band. - Close with: app name and the Asana account that owns it; client ID; where the secret was delivered; the authorize, token, revoke and API base URLs; **the scope mode (Full permissions vs granular) and the exact scope strings**; the distribution setting; whether a listing or sandbox was requested; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the endpoints the connector needs have no granular scope and someone must accept Full permissions as a deliberate choice; moving an existing app from full access to granular scopes is proposed (it re-authorizes every customer); a customer's admin has blocked or must approve the app; someone proposes a Service Account or personal access tokens in place of OAuth (both are broader or single-user grants); an App Directory submission asks for compliance, legal, security-review or volume claims; a sandbox request form asks for company details you were not given; or the developer console does not match the **Platform state** section above. ## References Official Asana docs only; every URL below returned HTTP 200 on 2026-09-20. The developer console itself lives at `https://app.asana.com/0/my-apps` and requires sign-in. - OAuth (registration, endpoints, scopes, PKCE, troubleshooting) — https://developers.asana.com/docs/oauth - OAuth scopes reference (the full catalogue and its endpoint map) — https://developers.asana.com/docs/oauth-scopes - Authentication overview (OAuth vs PAT vs Service Accounts) — https://developers.asana.com/docs/authentication - Personal access tokens — https://developers.asana.com/docs/personal-access-token - OpenID Connect — https://developers.asana.com/docs/openid-connect - Share your app / distribution and listing details — https://developers.asana.com/docs/share-your-app - Publish your app (App Directory review) — https://developers.asana.com/docs/publish-your-app - App listing guidelines — https://developers.asana.com/docs/app-listing-guidelines - Rate limits — https://developers.asana.com/docs/rate-limits - Input/output options (`opt_fields`) — https://developers.asana.com/docs/inputoutput-options - Workspaces and organizations — https://developers.asana.com/docs/workspaces - Developer sandbox — https://developers.asana.com/docs/developer-sandbox - Deprecations process and `Asana-Change` headers — https://developers.asana.com/docs/deprecations - OAuth client libraries and Postman collection — https://developers.asana.com/docs/getting-started-with-asana-oauth - Changelog: OAuth permission scopes (9 April 2025) — https://forum.asana.com/t/new-oauth-permission-scopes/1048556 - Admin app management and approval modes — https://help.asana.com/s/article/app-management-and-integrations?language=en_US - Service accounts — https://help.asana.com/s/article/service-accounts?language=en_US - App Directory — https://asana.com/apps ## atlassian-cloud-oauth URL: https://docs.unified.to/skills/atlassian-cloud-oauth/SKILL.md # Atlassian Developer Console — shared OAuth 2.0 (3LO) registration mechanics Everything common to registering an OAuth 2.0 (3LO) integration for **any** Atlassian Cloud product: the developer console app, the one callback URL, the grant type, the scope families, `offline_access`, the client ID and secret, rotating refresh tokens, the cloudid indirection, and distribution. The product skills — Atlassian Jira, Atlassian Confluence — assume this file and cover only what their product adds on top (its own scope family, its own API generations, its own permission model). **Read this first, then the product skill.** If you are registering a plain Atlassian 3LO app with no particular product in play, this file is the whole job. **One app can serve several products, but that is a decision, not a default.** Jira, Jira Software, Jira Service Management, Confluence and Assets are separate APIs with separate scope lists in the same console app. Adding "Jira API" does not give you Confluence scopes. Where a platform maintains separate connectors per product, each usually wants its own app and its own credentials — confirm with the connectors' owners rather than assuming either way. Four things here are expensive to get wrong, and three of them are invisible at registration time: 1. **A 3LO app takes one callback URL.** Not a list. If your platform serves several callback hosts, that is several apps, or a redesign — decide before you register (§4). 2. **An access token is not bound to a site.** You discover the site's **cloudid** from a separate endpoint and address every API call as `/ex/{product}/{cloudid}/...`. A client that hardcodes `https://customer.atlassian.net` authenticates perfectly and then fails on every request (§6). 3. **Scopes come in two incompatible-feeling families** — classic and granular — and changing the set forces every existing user to re-consent (§5). 4. **Refresh tokens rotate.** Each refresh returns a new one and disables the old one. A client that does not persist the rotated token fails on the *second* refresh, not the first, which means it passes your smoke test (§8). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, description, avatar | Shown on the Atlassian consent screen | | **Atlassian account** that will own the app | Console apps belong to an account, not an org (§2) | | **A Cloud site to test against** | A free site is fine; a sandbox needs Premium/Enterprise (§2) | | **Callback URL** — exactly one per app | Blocking. See §4 before promising anything | | **Grant type**: account-level or resource-level | Chosen at creation, shapes what tokens can reach (§3) | | **Which Atlassian products** | Jira, Jira Software, Jira Service Management, Confluence, Assets are separate APIs | | **Scope set**, and whether it is classic or granular | The product skill supplies the strings (§5) | | **Does the client persist rotated refresh tokens?** | Blocking — see §8 | | **Distribution**: private, shared, or submitted for approval | §9 | ## Quick Start 1. Confirm a **new app** is needed — existing user grants are bound to the current client ID (§1). 2. Sign in to the Atlassian Developer Console with the account that should own the app (§2). 3. **Create → OAuth 2.0 integration**, name it, and choose the grant type (§3). 4. **Permissions → Add** the product API(s) you need, then add scopes to each (§5, and the product skill). 5. **Authorization → OAuth 2.0 (3LO) → Configure →** enter the single **Callback URL** and save (§4). 6. Confirm the client knows how to resolve and store the **cloudid** (§6). This is the usual failure. 7. Copy **Client ID** and **Secret** from **Settings** (§7). 8. Confirm `offline_access` is requested and rotated refresh tokens are persisted (§8). 9. Decide distribution — private, shared via link, or submitted for Atlassian approval (§9). 10. Verify with a real authorize → callback → accessible-resources → API call → double refresh (§10). 11. Hand the credentials over — never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Apps are created in the Developer Console** at `https://developer.atlassian.com/console/myapps/` — profile icon → *Developer console* → **Create** → **OAuth 2.0 integration**. Console apps are listed with a **3LO** lozenge. Connect apps are not listed there at all. - **Two grant types are offered at creation**, and the choice changes what a token can reach: - **Account-level grant** — consent applies at the Atlassian account level; one grant can cover multiple sites, and sites can be *added to the same grant later* when the user consents again. - **Resource-level grant** — consent is limited to the site(s) the user picks on the consent screen; tokens from that grant cannot be used with any other site. Treat this as effectively permanent for a live app: changing it re-consents everybody. Atlassian's OAuth changelog also records a 2026 option for newly created 3LO apps to opt into **resource-restricted tokens**, scoping issued access tokens to the one site the user picks at consent. Check the changelog for the current shape before relying on either name. - **One callback URL per app.** The console's Authorization → OAuth 2.0 (3LO) → Configure screen has a single *Callback URL* field, and the docs describe it in the singular. There is no documented way to register a list (§4). - **Scopes are split into classic and granular**, per product API, with a soft ceiling: *"It's recommended that you use less than 50 scopes in an application."* Which family to prefer is **product-specific** — the general recommendation leans classic, but a product's newer API generation may document granular scopes only. The product skill decides this; do not generalise from one product to another (§5). - **Changing scopes forces re-consent.** Atlassian states it plainly on the console management page: *"Note that users who previously consented to the scopes will need to re-consent to the new scopes."* - **Refresh tokens rotate**, with a **90-day inactivity expiry** reset on every rotation and a **10-minute reuse leeway** for concurrency. No absolute cap is documented (§8). - **Apps are private by default.** Distribution → *Enable sharing* turns on link-based sharing; users installing an unapproved integration are *"warned that the app has not yet been reviewed by Atlassian"* (§9). - **App ownership is no longer strictly single-account.** Atlassian's OAuth changelog records a 2026 change allowing **multiple admins** on a console app and transfer of ownership. That softens, but does not remove, the offboarding problem in §2 — confirm what the console actually offers on the app you are working on. - **Atlassian names two practices as non-compliant.** From the 3LO pages: *"Apps that collect API tokens or instruct customers to create individual 3LO apps don't comply with our Security requirements for cloud apps and Acceptable use policy."* The stated best practice is *"Build a single, distributable 3LO app for your integration."* If the platform you are registering for does either thing, that is a finding to raise, not a step to work around (§9). - **No install cap is documented** for shared-but-unapproved 3LO apps — unlike several other portals. Do not promise a number; the constraint that bites is the unreviewed-app warning, not a counter. - **Admins cannot revoke one user's grant** from the Connected Apps screen — the grant is between the app and the user; an admin can only uninstall. If the console does not look like this, stop and report what you actually see rather than clicking on. **Atlassian Cloud only.** **Data Center / Server** does not use this at all — different auth models, registered inside the customer's own instance, with no Atlassian-hosted developer console, no `auth.atlassian.com`, and no cloudid. If the customer's URL is not `*.atlassian.net` (or a custom Cloud domain), you are in the wrong document; the product skill says what the Data Center path actually is for that product. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing user grant is bound to the old one** — every connected user re-authorizes. Reuse the existing app for: changing scopes (accepting the re-consent, §5), rotating a compromised secret, flipping distribution, or diagnosing a failure. Register a **new** app only when the user explicitly wants one: a second callback host that the one-URL limit forces into its own app (§4), a replacement for a compromised app, a separate product, or a deliberate grant-type change. Say which path you are taking before you touch the console. ## 2. Account and test site - **Atlassian account** — sign in at `https://developer.atlassian.com`, then profile icon → *Developer console*. There is no separate "developer program" enrolment for a 3LO integration; any Atlassian account can create one. The app belongs to that account, so use a shared/role account rather than an individual's, or you inherit an offboarding problem later — even where multiple admins can now be assigned (see Platform state). - **A Cloud site to test against** — a free Atlassian Cloud site is enough for an end-to-end check. You need site admin rights on it to install and to see the consent screen behave. The product skill gives the signup link. - **A sandbox** is a different thing and is gated: Atlassian sandboxes require **Premium or Enterprise**, and creating one requires **organization admin** permissions. Do not plan around a sandbox unless the user already has one. Hand control back to the user for anything only a human can do: account signup and email verification, MFA, accepting developer terms, or creating a site. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the callback URL (§4) and the scope list from the product skill — then continue once they report back with the client ID. ## 3. Create the app and enable 3LO 1. Profile icon → **Developer console** → **Create** → **OAuth 2.0 integration**. 2. Enter the **app name** and choose the **grant type** — account-level or resource-level (see Platform state). For a multi-tenant connector where one customer may have several sites, account-level is usually what you want; resource-level is the tighter, site-pinned option. Either way, §6 still applies. 3. **Permissions → Add** the API(s) the integration calls. Jira platform, Jira Software, Jira Service Management, Confluence and Assets are **separate APIs with separate scope lists**. Add each product you actually call, and no more. 4. **Authorization → OAuth 2.0 (3LO) → Configure** → enter the **Callback URL** → **Save changes** (§4). 5. **Settings** holds the app name, description, avatar, **Client ID** and **Secret** (§7), and **Delete app**. Two console facts worth knowing before you go looking for them: - The **App ID** on the Overview page is *not* the OAuth `client_id`. The client ID lives on **Settings**. - **You can only delete an app if it is not installed anywhere.** Uninstall first, then delete. ## 4. The callback URL — you get one `redirect_uri` must match the registered Callback URL, and the console takes a single value. This is the constraint that decides your architecture, so settle it before registering. For Unified.to the callbacks are one per data center: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` With one callback URL per app, that means either: - **One app per data center** — four apps, four client ID/secret pairs, each registered with its own callback. This is the normal answer, and it is what the credential store must be able to hold. Say so explicitly in your handoff. - **One app, one callback**, with that single host fanning out internally. This is a platform change, not a console setting — do not assume it exists. Multiply that by products: a platform with separate Jira and Confluence connectors and four data centers is eight apps, not four. Confirm the current callback list with the platform owner rather than assuming; data centers get added. Other notes: - The `redirect_uri` you send on **both** the authorize request and the token exchange must match the registered value. Mismatches surface at connect time, not at save time. - The callback is your platform's URL, not Atlassian's, and does **not** vary per customer site — unlike the API base URL, which does (§6). Do not conflate the two. - Saved changes can take a short while to propagate; a failure immediately after saving is worth one retry before it means anything. ## 5. Scopes — the two families, and `offline_access` The product skill supplies the scope strings. This section is how Atlassian treats them. Atlassian runs **two parallel scope families** over the same APIs, and the console presents them per API: - **Classic scopes** are broad and product-wide — a handful per product, shaped `read:-` (for example `read:jira-work`, `read:confluence-content.all`). - **Granular scopes** are per-resource and follow a `verb:resource:product` shape — `read:issue:jira`, `read:page:confluence`, `write:comment:confluence` — and there are several hundred of them. What matters operationally: - **Which family to use is a product question, not a portal question.** Atlassian's general guidance leans toward classic scopes where they are available, but a product's newer API generation may document granular scopes only, in which case classic is not an option for those endpoints. **Read the product skill before choosing**, and check the **OAuth scopes required** field on the specific endpoint you intend to call. - **Pick a family and stay in it per product.** Atlassian's 3LO pages stop short of an outright prohibition on mixing, but everything around it treats the two as an either/or choice, and the console's own advice when you approach the scope ceiling is to *"ensure you're using classic scopes to the maximum extent possible and remove any unnecessary granular scopes."* A set that is mostly one family with one or two of the other bolted on is the shape most likely to behave surprisingly — a granular scope present in the token while the endpoint checks the classic one, or the reverse, produces a 401 with a scope-mismatch message on an endpoint you are certain you are entitled to call. If you find such a mix, treat it as a defect to resolve deliberately, not a harmless accident. - **You can only request scopes you already added to the app.** The docs are explicit: *"Only choose from the scopes that you have already added to the APIs for your app in the developer console."* An authorize URL asking for a scope the console does not know about fails at the consent screen. - **Changing scopes re-consents everyone.** Every user who already granted the old set must grant again. For a live connector this is a customer-visible migration, not a config tweak — plan it, do not slip it into a release. - **Scopes never exceed the user's own permissions.** An app holding a project- or space-administration scope still cannot act if the authorizing user lacks that permission in the product. A scope failure and a permission failure look similar and are fixed in different places. - **Under 50 scopes per app** is the recommendation, with a live count shown in the console. - **`offline_access` is a scope**, not a flag, and without it you get no refresh token at all (§8). An authorization that is only ever used once — an identity/login flow — can legitimately omit it; anything that must keep working tomorrow cannot. - **Identity scopes are their own small family.** `read:me` (the authenticated user's public profile, via the User Identity API) and `read:account` are not product scopes and do not appear on a product's scope page. Treat a login-only authorization as its own scope set. - If an operation's reference page says *"Apps can't access this REST resource"*, no scope will help — it is not available to 3LO at all. **Other Atlassian products are other scope families.** One console app *can* serve several products if you add each product's API and its scopes, but a platform that ships separate connectors per product usually wants separate apps. Decide that with the connectors' owners, not by default. ## 6. The cloudid indirection — read this before writing any client code This is the single most common way an Atlassian integration fails after a flawless registration. **An access token is not bound to a site.** It is bound to a *grant*, and a grant can cover one site or several. The site's identity is a UUID Atlassian calls the **cloudid**, and you have to go and ask for it: ``` GET https://api.atlassian.com/oauth/token/accessible-resources Authorization: Bearer Accept: application/json ``` The response is an **array** of sites the token can be used with: ```json [ { "id": "8594f221-9797-5f78-1fa4-485e198d7cd0", "name": "Site name 2", "url": "https://your-domain2.atlassian.net", "scopes": ["write:jira-work", "read:jira-user"] }, { "id": "1324a887-45db-1bf4-1e99-ef0ff456d421", "name": "Site name 1", "url": "https://your-domain1.atlassian.net", "scopes": ["write:jira-work", "read:jira-user", "manage:jira-configuration"] } ] ``` Every API call then goes to `https://api.atlassian.com/ex/{product}/{cloudid}/{api}`, where `{product}` is the product's own segment (`jira`, `confluence`, …) and `{api}` is that product's REST base path. **The `{api}` part is product-specific and is where people get it wrong** — the product skill spells out the exact path for each API generation. Getting `{product}` right and `{api}` wrong yields a 404 that looks like a permissions problem. The traps, in the order they bite: - **A client that hardcodes `https://.atlassian.net` breaks.** OAuth 2.0 (3LO) tokens are not accepted there. Authorization succeeds, the token looks fine, and every subsequent request fails. The site URL in the response is for *display*; the `id` is what you address. - **The array can hold more than one site.** Under an account-level grant, sites can be added to the same grant over time. Taking `[0]` picks an arbitrary site and silently reads the wrong one. Either let the customer choose, or match the site you were told to connect by its `url`, and say which you did. - **One site can carry several products.** A single cloudid commonly serves both Jira and Confluence on the same Atlassian site; the product segment in the path, not the cloudid, selects which. A fallback that reuses a cloudid resolved for one product while keeping another product's path segment silently addresses the wrong API. - **Resolving it once and caching it forever is only half right.** The cloudid of a given site is stable, but the *set* of sites a token reaches is not — it changes when the user re-consents. Atlassian's instruction is to call `accessible-resources` with the current token and use what comes back, so re-check on re-authorization and when a call starts 403-ing, not only at first connect. - **The per-site `scopes` array is the real answer** to "does this token have what I need?" — it is scoped to that site, and it is how you detect a partial grant before a customer reports a broken feature. ## 7. Capture the credentials From the Developer Console → your app → **Settings**: the **Client ID** and the **Secret**. Capture alongside them: - **Authorization endpoint** — `https://auth.atlassian.com/authorize` - **Token endpoint** — `https://auth.atlassian.com/oauth/token` (POST, JSON body) - **API base** — `https://api.atlassian.com/ex/{product}/{cloudid}` (§6) - **`audience=api.atlassian.com`** — a *required* authorize parameter, and Atlassian-specific. Omitting it is an early, confusing failure. - **`prompt=consent`** — also required, so the grant screen actually renders. - Which data center / environment this app's single callback serves (§4), and which product(s) its APIs cover. The full authorize URL Atlassian documents: ``` https://auth.atlassian.com/authorize? audience=api.atlassian.com& client_id=YOUR_CLIENT_ID& scope=REQUESTED_SCOPE_ONE%20REQUESTED_SCOPE_TWO& redirect_uri=https://YOUR_APP_CALLBACK_URL& state=YOUR_USER_BOUND_VALUE& response_type=code& prompt=consent ``` Scopes are **space-delimited**. `state` is required for security and must be unguessable and bound to the user. Rotating the secret invalidates the old one: existing access tokens keep working until they expire, but every token exchange and refresh fails until the new secret is deployed. Never rotate without an explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 8. Token lifetimes and rotating refresh tokens | Thing | Value | Source | | --- | --- | --- | | Refresh token | Only issued if `offline_access` is in the authorize `scope` | 3LO docs | | Refresh token behavior | **Rotating** — each use returns a new one and **disables the one you used** | 3LO docs | | Inactivity expiry | **90 days**, *reset* by each rotation | 3LO docs | | Reuse leeway | **10 minutes** — within it, breach detection does not fire on repeated exchange of the same token | 3LO docs | | Absolute maximum lifetime | **Not documented** — do not assume one exists, and do not assume one does not | — | | Access token | `expires_in` (seconds) in the token response — read it, do not hardcode | 3LO docs | The refresh call: ``` POST https://auth.atlassian.com/oauth/token Content-Type: application/json { "grant_type": "refresh_token", "client_id": "...", "client_secret": "...", "refresh_token": "..." } ``` The response carries a new `access_token` **and a new `refresh_token`**, and *"the refresh token you used for the request is disabled."* **The failure mode that passes your smoke test.** A client that does not store the rotated refresh token refreshes successfully the first time — it still holds a valid token — and fails the *second* time, hours or days later, with: ``` 403 Forbidden {"error": "invalid_grant", "error_description": "Unknown or invalid refresh token."} ``` Atlassian lists the causes for that exact error: the user changed their Atlassian account password, the refresh token expired (the user must re-authorize from scratch), or *"Your app is not replacing the previous refresh token with the new refresh token returned during access token request."* So there are two separate questions, and a platform can answer yes to the first and no to the second: 1. **Does the client implement rotation** — does it read `refresh_token` out of the refresh response at all? 2. **Does it persist it**, atomically, before the next refresh, including under concurrent refreshes of the same connection? The 10-minute leeway exists precisely because concurrency here is real. The other quiet killer is **inactivity**. A connection nobody refreshes for 90 days is dead and the customer must re-authorize. Idle connections are the ones that break, so a periodic refresh is a feature, not a nicety. A platform that treats the refresh token as expiring at, say, 89 days is deliberately staying inside that window — confirm the number rather than assuming it. ## 9. Distribution, approval and the Marketplace Apps are **private by default** — only the creating account can install and use them. To share: **Distribution → Enable sharing** toggle, then **Authorization → OAuth 2.0 (3LO) → Configure** and copy the **Authorization URL(s)** to distribute. Note what this does and does not mean: - **3LO apps install per user**, not per site. There is no admin-installs-for-everyone path here; every user who connects goes through consent themselves. - **Unapproved integrations show a warning.** Users are *"warned that the app has not yet been reviewed by Atlassian."* For a commercial connector that warning is a conversion and trust problem, and it is the real reason to seek approval. - **Approval is separate from a listing.** To get the integration reviewed and approved, follow Atlassian's *Listing a third party integration on the Atlassian Marketplace* process — and, per the console docs, *"you don't need an informative Atlassian Marketplace listing to submit your integration for approval."* - **A Marketplace listing for a 3LO integration is informational only.** Enabling sharing does not put the app on the Marketplace, and when listed, 3LO integrations *"appear as informational listings only, with limited Marketplace features"* — the listing links out to your own site, where you handle distribution. Review requires giving Atlassian access to a test instance of your software. - **No install cap is documented.** Do not quote one. **Two practices Atlassian names as non-compliant.** From the 3LO pages: *"Apps that collect API tokens or instruct customers to create individual 3LO apps don't comply with our Security requirements for cloud apps and Acceptable use policy."* The stated best practices are to build a **single, distributable 3LO app**, identify it clearly in the Marketplace, and transition customers away from custom 3LO apps and API tokens. If the platform you are registering for asks each customer to create their own app, or accepts Atlassian API tokens as an auth method, say so in your summary and flag it to the owner. That is a product decision with a compliance dimension — report it; do not quietly register around it. **Data security policies can block a compliant app.** An organization admin can attach an **app access rule** to a data security policy and block apps from reading user-generated content in selected spaces or projects, independently of scopes and consent. The product skill says what that looks like for its API. Note the asymmetry Atlassian documents: apps authenticating with **Atlassian API tokens or user credentials cannot be blocked this way**, which means the compliant OAuth path is the blockable one and the non-compliant token path is not. That is worth saying out loud when someone proposes tokens as a workaround. **Data residency.** Atlassian's data residency covers data held in Atlassian's cloud for eligible products on eligible plans. It does **not** cover data a third-party app stores on its own infrastructure outside Atlassian. If a customer asks whether connecting your integration keeps their data in-region, the honest answer is that Atlassian's controls stop at Atlassian's boundary and your platform's own residency story takes over. Do not answer a customer's residency question on the platform's behalf — hand it to someone who can. ## 10. Verify end-to-end Authorizing from the account that owns the app proves less than you think. Test the path a customer takes: 1. Run a real **authorize → callback** round trip through the platform's connect flow, on a **different** Atlassian account and a site that account administers, with `audience`, `prompt=consent`, `state` and the exact `redirect_uri`. 2. Confirm the token response contains a **`refresh_token`** — if not, `offline_access` was missing from `scope`. 3. Call **`/oauth/token/accessible-resources`** and look at the whole array: how many sites came back, and does the per-site `scopes` list contain everything you asked for? 4. Make one real read against **`https://api.atlassian.com/ex/{product}/{cloudid}/...`** — not against the customer's `.atlassian.net` host — using the exact API path the product skill gives. 5. Force a **refresh**, then force a **second refresh using the token returned by the first**. This is the step that catches a client ignoring rotation, and it is the step most often skipped. 6. If the app is shared, have someone outside the owning account connect, and confirm whether they see the unreviewed-app warning (§9). | Symptom | Cause | | --- | --- | | Consent screen errors before showing anything | Missing `audience=api.atlassian.com`, or `prompt=consent` omitted (§7) | | `redirect_uri` rejected | Not byte-identical to the one registered callback, or sent on authorize but not on exchange (§4) | | Consent screen rejects a scope | Scope not added to the app's API in the console, or the wrong product's API not added (§5) | | Auth succeeds, every API call 401s or 404s | Client is calling `.atlassian.net` instead of `/ex/{product}/{cloudid}` (§6) | | Auth succeeds, calls 404 on one product only | Right cloudid, wrong product segment or wrong API base path (§6) | | No `refresh_token` in the token response | `offline_access` not in the authorize `scope` (§8) | | First refresh works, later ones return `invalid_grant` | Rotated refresh token not persisted (§8) | | Connection dies after a quiet ~3 months | 90-day inactivity expiry; the user must re-authorize (§8) | | Reads the wrong site | `accessible-resources[0]` taken blindly on a multi-site grant (§6) | | 401 "scope does not match" on an endpoint you have the scope for | Classic/granular family mismatch for that endpoint (§5) | | Endpoint says apps can't access it | Not available to 3LO at all — no scope fixes it (§5) | | Everyone suddenly has to re-consent | Scopes were changed on the app (§5) | | Works for the app owner, warns everyone else | App shared but not approved (§9) | | Content silently missing for one customer only | A data security policy app access rule (§9) | | Nothing resembles this document; no `auth.atlassian.com` | It is Data Center/Server, not Cloud — wrong auth model entirely | ## 11. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a callback host, a scope, cloudid handling), keep it secret-free and say what the human must set out of band. - Close with: app name and App ID; the owning Atlassian account; the **client ID**; where the secret was delivered; the grant type chosen; the single callback URL and which data center it serves (and whether more apps are needed, §4); which product APIs were added; the exact scope strings and whether they are classic or granular; confirmation that `offline_access` is requested; the state of rotated-refresh-token persistence and who owns any remaining work; the distribution state and whether approval is being sought; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the platform needs more callback hosts than one app allows and nobody has decided between multiple apps and a single callback (§4); the client hardcodes a site URL or takes the first entry from `accessible-resources` without a rule (§6); the client does not persist rotated refresh tokens (§8); a scope change would re-consent live customers (§5); the choice between account-level and resource-level grants is not obviously the user's (§3); whether one app should serve several Atlassian products is genuinely open (§5); the platform collects Atlassian API tokens or asks customers to create their own 3LO apps, which Atlassian names as non-compliant (§9); a Marketplace submission asks for security, privacy, compliance or support commitments; a customer asks about data residency for data held outside Atlassian (§9); the target is Data Center/Server; or the console does not match the **Platform state** section above. **No browser automation:** if the session cannot drive a browser, do not simulate clicks or claim a step was done. Produce the exact click path and literal values for the user, and resume when they report back. ## References Portal-wide and product-neutral only; each product skill carries its own. Every link verified to resolve on 2026-09-20. - Developer Console — https://developer.atlassian.com/console/myapps/ - Enabling OAuth 2.0 (3LO) — https://developer.atlassian.com/cloud/oauth/getting-started/enabling-oauth-3lo/ - Implementing OAuth 2.0 (3LO) — https://developer.atlassian.com/cloud/oauth/getting-started/implementing-oauth-3lo/ - Making calls to the API (the `/ex/{product}/{cloudid}` form) — https://developer.atlassian.com/cloud/oauth/getting-started/making-calls-to-api/ - Implementing the refresh token flow — https://developer.atlassian.com/cloud/oauth/getting-started/refresh-tokens/ - Managing your OAuth 2.0 (3LO) apps (distribution, scope re-consent, deletion) — https://developer.atlassian.com/cloud/oauth/getting-started/managing-oauth-apps/ - OAuth 2.0 (3LO) FAQ — https://developer.atlassian.com/cloud/oauth/getting-started/faq/ - OAuth 2.0 changelog (grant types, resource-restricted tokens, app ownership) — https://developer.atlassian.com/cloud/oauth/changelog/ - Authentication and authorization for developers (which auth model applies where) — https://developer.atlassian.com/developer-guide/auth/ - What is a data security policy? (app access rules) — https://developer.atlassian.com/cloud/admin/data-security-policy-guide/ - Security requirements for cloud apps — https://developer.atlassian.com/platform/marketplace/security-requirements/ - Listing a third party integration on the Atlassian Marketplace (the approval path) — https://developer.atlassian.com/platform/marketplace/knowledge-base/listing-a-third-party-integration-on-the-atlassian-marketplace/ - List and manage apps — https://developer.atlassian.com/platform/marketplace/listing-and-managing-apps/ - App approval guidelines — https://developer.atlassian.com/platform/marketplace/app-approval-guidelines/ - Atlassian Acceptable Use Policy — https://www.atlassian.com/legal/acceptable-use-policy - Understand data residency — https://support.atlassian.com/security-and-access-policies/docs/understand-data-residency/ - Create a sandbox (Premium/Enterprise, org admin) — https://support.atlassian.com/organization-administration/docs/create-a-sandbox/ - Manage API tokens for your Atlassian account (the non-compliant path, for identification) — https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/ ## atlassian-confluence-oauth-app URL: https://docs.unified.to/skills/atlassian-confluence-oauth-app/SKILL.md # Atlassian Confluence Cloud OAuth 2.0 (3LO) App Registration Registering the app is the shared part. **What is specific to Confluence is that the scope decision and the API generation decision are the same decision**, and that a perfectly scoped token can still come back with an empty wiki because Confluence's permissions are per space and per page. Confluence's v2 REST API is documented with **granular scopes only**, while the Confluence scope page still says to prefer classic scopes where they are available. Both statements are true, and they point in opposite directions unless you know which API generation you are calling. Getting that wrong produces a 401 that reads like a permissions bug on an endpoint you are certain you are entitled to call. ## Built on: `atlassian-cloud-oauth` **Read `atlassian-cloud-oauth` first, then this file.** It owns all the portal mechanics, and this file does not repeat them: - The Developer Console, creating an **OAuth 2.0 integration**, enabling 3LO, and the account that owns the app. - **One callback URL per app**, and what that forces on a platform serving several data centers. - Account-level vs resource-level grants, where the client ID and secret live, and the required `audience` and `prompt=consent` authorize parameters. - The classic/granular scope model in general, `offline_access`, the under-50-scopes ceiling, and the rule that changing scopes re-consents every existing user. - **Rotating refresh tokens** — 90-day inactivity expiry, 10-minute reuse leeway, and the failure that only appears on the *second* refresh. - The **cloudid indirection**: `accessible-resources`, `/ex/{product}/{cloudid}/...`, and the array being multi-site under an account-level grant. - Distribution, the unreviewed-app warning, Marketplace approval, and the two practices Atlassian names as non-compliant (collecting API tokens, or having customers create individual 3LO apps). If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Confluence-specific ones: | Input | Notes | | --- | --- | | **Which API generation will the client call — v1, v2, or both?** | This decides the scope family (§1, §2) | | **Read-only, or create/update content?** | Write scopes are per resource in granular, product-wide in classic (§1) | | **Which content types?** | Pages, blogposts, comments, attachments, whiteboards, databases, folders and custom content are **separate granular scopes** (§1) | | **Is CQL search needed?** | Search has a classic scope and a granular equivalent — do not add both by reflex (§1) | | **Do page restrictions matter?** | Restrictions are a v1-only API (§2, §4) | | **Cloud or Data Center?** | Different auth model entirely — stop here if Data Center (§7) | | **Expected call volume** | Confluence bills API traffic against an hourly point budget (§6) | ## Quick Start 1. Work the base skill's console steps, adding the **Confluence API** under Permissions (not the Jira API — they are separate scope lists on the same app). 2. Settle §2 first: which REST generation the client calls. It determines §1. 3. Add the scope family that generation documents, and keep it to one family (§1). 4. Confirm `offline_access` is in the set for anything that must survive the hour. 5. Confirm the client builds Confluence URLs as `/ex/confluence/{cloudid}/wiki/api/v2/...` — the `/wiki` segment is the one people drop (§3). 6. Before promising a customer anything about coverage, read §4: the authorizing user's space and page permissions are the real ceiling. 7. Finish the base skill's capture, round-trip and handoff steps, adding the Confluence checks in §8. ## Confluence platform state (verified 2026-09-20 — re-verify before trusting) - **Two live REST generations.** v1 (`/wiki/rest/api/...`) and v2 (`/wiki/api/v2/...`) both have current reference documentation on developer.atlassian.com. v2's own introduction describes itself as *"the reference for the Confluence Cloud REST API v2, with definitions and performance intended to be an improvement over v1."* - **v1 is being retired endpoint by endpoint, not all at once.** Individual v1 operations that have v2 equivalents carry their own deprecation notices and removal dates, and those dates have been extended more than once. As of this check the Confluence Cloud changelog carries **no blanket v1 removal entry**, and v1 groups with no v2 equivalent — content restrictions, CQL search — are still the only way to do those things. Treat "v1 is gone" as false and "v1 is safe forever" as equally false: check the specific operation's page (§2). - **v2 operation pages document granular scopes only.** Every v2 group checked — page, space, attachment, comment, content properties, space permissions — lists a single granular scope (`read:page:confluence`, `read:space:confluence`, `read:attachment:confluence`, `read:comment:confluence`) and no classic alternative. - **v1 operation pages document both**, labelled *"Classic (RECOMMENDED)"* alongside the granular equivalent — for example content restrictions list classic `write:confluence-content` next to granular `read:content-details:confluence` and `write:content.restriction:confluence`. - **The Confluence scope page still recommends classic**: *"Where available, the recommendation is to use classic scopes."* Read that as scoped to where classic scopes *are* available, which for v2-only endpoints is nowhere. - **`search:confluence` is a classic scope**, not granular, despite its shape. Its granular equivalent for the CQL search endpoint is `read:content-details:confluence`. - **Confluence rate limits are a points budget, not a request count** — a global pool by default, with a per-tenant pool available, `429` on exhaustion and `X-RateLimit-*` headers on the way there (§6). - **Data security policy app access rules can silently hide content** from a correctly scoped app, per space, with a documented per-policy container limit (§5). If the scope tables or the endpoint groups do not look like this, stop and report what you actually see. ## 1. The Confluence scope family Confluence has its own classic list and its own granular list. Neither overlaps with Jira's. **Classic** — broad, product-wide, and what v1 endpoints document: | Scope | Covers | | --- | --- | | `read:confluence-content.all` | Read all content the user can see, including bodies | | `read:confluence-content.summary` | Content summaries without bodies | | `read:confluence-space.summary` | Space summaries | | `read:confluence-content.permission` | Content restrictions and permission checks | | `read:confluence-props` / `write:confluence-props` | Content and space properties | | `read:confluence-user` | User details | | `read:confluence-groups` / `write:confluence-groups` | Groups and membership | | `search:confluence` | CQL search over content and space summaries | | `write:confluence-content` | Create and update content | | `write:confluence-space` | Create and update spaces | | `write:confluence-file` | Upload attachments | | `manage:confluence-configuration` | Confluence administration | | `readonly:content.attachment:confluence` | Download attachments in read-only mode | **Granular** — per resource, `verb:resource:confluence`, and what v2 endpoints document. The families that matter for a content connector: `read:page:confluence`, `read:blogpost:confluence`, `read:space:confluence`, `read:comment:confluence`, `read:attachment:confluence`, `read:folder:confluence`, `read:whiteboard:confluence`, `read:database:confluence`, `read:embed:confluence`, `read:custom-content:confluence`, `read:hierarchical-content:confluence` (ancestors, children, descendants), `read:content-details:confluence` (details, properties, search), `read:content.metadata:confluence`, `read:space.permission:confluence`, `read:configuration:confluence`, `read:user:confluence`, `read:group:confluence`, plus the `write:` and `delete:` forms of each. **Which generation to use now, and therefore which family:** - **Calling v2 means granular.** There is no classic scope listed on a v2 operation page, so an app whose reads go through `/wiki/api/v2/...` needs the granular scopes and nothing the classic list offers will substitute. This is the case for any new Confluence content integration, because v2 is where pages, blogposts, whiteboards, databases, folders and the hierarchy endpoints live. - **Calling v1 can use either**, and the pages mark classic as recommended. If the app *only* touches v1, classic is fewer scopes and less churn. - **Calling both — the common case — is the trap.** Content restrictions and CQL search have no v2 equivalent (§2), so a v2-based app that also searches or reads restrictions lands in both families at once. Prefer resolving that with the **granular equivalent** the v1 page also lists — CQL search accepts `read:content-details:confluence`, which a v2 content app already holds — rather than bolting the classic scope onto a granular set. Adding `search:confluence` to an otherwise granular app is exactly the mixed-family shape the base skill warns about, and it is the first thing to check when search 401s with a scope message. - **Granular is not free.** One content type per scope means a connector reading pages, blogposts, comments, attachments, folders, whiteboards, databases and the hierarchy is already eight or more scopes before users and groups. That is fine against the under-50 ceiling, but every one of them is a line on the consent screen and every addition later re-consents every customer. Decide the content-type list once. - **Identity scopes are separate.** `read:me` and `read:account` come from the Atlassian account identity API, not the Confluence scope page, and a login-only authorization is its own set. Note that `ACCESS_EMAIL_ADDRESSES` is a **Connect-app scope**, not a 3LO one — if it appears in a 3LO authorize URL, check whether it is doing anything. ## 2. v1 and v2 — what only exists where | Generation | Base path | Pagination | Scope family documented | | --- | --- | --- | --- | | v1 | `/wiki/rest/api/...` | `start` / `limit`, `_links.next` | Classic (recommended) **and** granular | | v2 | `/wiki/api/v2/...` | Cursor — `limit` + `cursor`, `Link` header and `_links.next` | Granular only | v2's pagination is cursor-based and Atlassian is explicit that you follow the link rather than compute an offset: the next URL appears *"in both the `Link` header and under the `_links.next` property"*, and when there is no next page neither is present. A client that pages by incrementing an offset against v2 will quietly under-read. **Still v1 only, as of this check:** - **Content restrictions** (`/wiki/rest/api/content/{id}/restriction`) — there is no v2 restrictions group. Any feature that reads or sets page-level restrictions is a v1 call. - **CQL search** (`/wiki/rest/api/search`, and `/search/user`) — v2 has no search endpoint; filtering on the v2 list endpoints is not a substitute for CQL. **Moved or reshaped in v2**, which is where migrations break: - A **space is addressed by a numeric id** in v2, where v1 keyed spaces by their **space key**. The key still exists and is still what users see; it is no longer the path parameter. A client that stores the key as "the space id" works against v1 and 404s against v2. - **Comments are split** into footer and inline, addressed under their parent (`/pages/{id}/footer-comments`, `/pages/{id}/inline-comments`, and `/blogposts/{id}/...`), with individual comments at `/footer-comments/{id}` and threaded replies at `/footer-comments/{id}/children`. A v1 client that treated "comments" as one list will silently miss half of them. - Body formats are requested explicitly in v2 (`body-format`), and Atlassian has capped some listing endpoints at **50 results when `body-format` is included**, down from 250. A list that suddenly returns fewer rows than its `limit` asked for is usually this, not a permissions change. **Before relying on any v1 call, open that operation's reference page and look for its own deprecation notice and removal date.** Atlassian deprecates Confluence v1 per endpoint, and has repeatedly extended the dates, so neither a blanket "v1 is fine" nor a blanket "v1 is dead" is checkable — only the specific operation is. ## 3. Addressing Confluence through the cloudid The base skill covers `accessible-resources` and the `/ex/{product}/{cloudid}` form. Confluence's product segment is `confluence`, and the part people get wrong is what follows it: ``` https://api.atlassian.com/ex/confluence/{cloudid}/wiki/api/v2/pages # v2 https://api.atlassian.com/ex/confluence/{cloudid}/wiki/rest/api/search # v1 ``` Two specific traps: - **`/wiki` is part of the path.** Atlassian's own 3LO example for Confluence shows a v1 call as `.../ex/confluence/{cloudid}/rest/api/space`, without it. Both forms circulate. Build against the path the API reference for your generation prints — v2 is documented as `/wiki/api/v2/...` throughout — and verify with one real call rather than reasoning about it. - **One cloudid, several products.** A single Atlassian site commonly runs both Jira and Confluence, and the same cloudid addresses both; the product segment selects which. So a cloud-ID resolution path shared between connectors can produce a URL whose cloudid is right and whose product segment belongs to the other product. That failure presents as a 404 on every call, not as an auth error. Content ids in v2 are numeric strings for pages, blogposts and spaces; attachment ids are opaque strings, and attachments carry a `downloadLink` in the response rather than a constructible URL — follow it rather than building one. ## 4. Permissions — the reason a valid token sees an empty wiki Confluence authorization is a stack, and OAuth scopes are only the outermost layer. A 3LO token acts **as the authorizing user**, so the app's effective access is the intersection of the granted scopes and that user's own permissions: 1. **Global permission** — the user needs *"Permission to access the Confluence site ('Can use' global permission)"*, which is what the v2 list endpoints state as their requirement. 2. **Space permissions** — per space, per user or group. Getting a single space requires *"Permission to view the space."* A user who is not permitted in a space does not see it at all, and neither does the app. 3. **Page restrictions** — per page, and they narrow further within a space the user can otherwise read. The operational consequence, and the thing to tell a customer before they open a support ticket: **listing endpoints silently return only what the user can see.** Atlassian states it for pages — *"Only pages that the user has permission to view will be returned"* — and the same shape holds elsewhere. There is no error, no partial-content warning, and no way to distinguish "this space is empty" from "this user cannot see this space" from the response. So: - **"The connector isn't syncing our engineering space"** is far more often a space-permission problem than a scope problem, and re-registering the app fixes nothing. Check which account authorized, and what that account can see in the Confluence UI. - **Who authorizes matters as much as what you ask for.** A connection made by a narrowly permissioned service user reads a narrow wiki, permanently and invisibly. If broad coverage is the product promise, say up front that the authorizing user must be able to see everything the customer expects to sync. - **Page restrictions are only readable through v1** (§2). A connector that cannot see restrictions cannot explain its own gaps. ## 5. Data security policy app access rules Confluence adds an admin-side block that sits outside scopes and consent entirely. An organization admin can attach an **app access rule** to a data security policy, blocking apps from reading user-generated content in selected spaces — Atlassian documents a limit of **15 containers (spaces) per policy**. What that does to your app: - It blocks **pages, blogposts and other user-generated content** in those spaces. Space key and name, audit records and templates remain readable, so the space still appears — with nothing in it. - Blocked items *"will not appear in subsequent search or any other data retrieval results"*, and the app cannot update them. This is omission, not an error code. - The signal is a response header, `Atlassian-DataSecurityPolicy: app_access_blocked`. A client that never looks at it cannot tell this apart from a permissions gap (§4) or an empty space. - Reference pages mark each operation **exempt** or **not exempt** from app access rules, so you can tell in advance which of your calls can be blocked. The asymmetry worth naming out loud: apps authenticating with **Atlassian API tokens or user credentials cannot be blocked this way**. The compliant OAuth path is the blockable one. That is not an argument for the token path — the base skill records that Atlassian names token collection as non-compliant — but it does explain why a customer's admin may see different behavior from two integrations. ## 6. Rate limits Confluence Cloud meters API traffic as an **hourly points budget**, not a request count, and the budget depends on which pool the app is in: - **Global pool (the default)** — the app shares *"a single 65,000 point hourly quota across all tenants."* For a multi-tenant connector that is the number that matters, and it is shared across every customer. - **Per-tenant pool** — a larger, per-customer budget that scales with the customer's edition and user count, capped well above the global figure. When the budget is exhausted the API returns **`429 Too Many Requests`** with `Retry-After`, and there is *"no partial throttling"* — further requests are denied until the window resets. On the way there, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` and `X-RateLimit-NearLimit` (true below 20% remaining) let a client back off before it is cut off, and `RateLimit-Reason` says which pool was breached. Two consequences for a connector: a 429 is **not** an authorization problem and must never trigger a re-authorization or a token refresh; and a full-wiki initial sync on the shared global pool is the most likely way one customer degrades every other customer's connection. Confirm which pool the app is in before promising a sync window. ## 7. Confluence Data Center is not this **Confluence Data Center / Server does not use any of this.** No Atlassian-hosted developer console, no `auth.atlassian.com`, no cloudid, no `/ex/confluence/...`. Authentication is against the customer's own instance — personal access tokens, or basic auth, with Confluence Data Center separately able to act as an **OAuth 2.0 provider** for its own integrations. The REST API is the Data Center reference, not the Cloud one, and the endpoint paths differ. If the customer's URL is not `*.atlassian.net` or a custom Atlassian Cloud domain, stop: nothing in this file or the base skill applies, and a Cloud client ID will never authenticate against it. ## Product fact — what the Confluence connector asks for > **As of 2026-09-20, Unified.to's Confluence connector requests:** a **granular** Confluence scope set, narrowed per > unified object and per read direction rather than one flat union, with `read:me` and `offline_access` in every > data-object set. Reading pages pulls the widest set — page, folder, attachment, hierarchical-content, database, > whiteboard, content, embed and content-details granular scopes — **plus the classic `search:confluence`**, which is > the one classic scope in an otherwise granular app. That is the mixed-family shape §1 warns about, and it may be > removable: the CQL search endpoint also accepts the granular `read:content-details:confluence`, which this set > already contains. Spaces, comments, users and groups each request their own narrow granular read scope. The > identity / login authorization is different again — Atlassian account identity scopes plus an email-address scope > that Atlassian documents as a **Connect-app** scope rather than a 3LO one, and **no `offline_access`**, so that > flow gets no refresh token by design. > > Two things beyond scopes. The connector expects an **Atlassian organization admin API key** alongside the OAuth > credentials, and it offers a **non-OAuth path** — Confluence site URL plus account email plus an Atlassian API > token. Collecting API tokens is exactly the pattern Atlassian's 3LO pages name as non-compliant (see the base > skill), so raise it rather than registering around it. > > On the API side the connector's page reads currently go through the **v1** CQL search and v1 content endpoints > rather than v2, with the v2 equivalents present but disabled. That makes §2's per-endpoint v1 deprecation exposure > a live concern rather than a theoretical one, and it is the likeliest reason the classic search scope is in the set > at all. It pages with `limit` and a next link, with a maximum page size of 250 for content objects and a smaller > one for users and groups — note §2's 50-result cap when a body format is requested, which a 250 limit will not > override. > > This note is dated, not live. **Confirm all of it with the connector's owner before registering anything** — the > connector, not this document, is the source of truth for what it sends. ## 8. Confluence-specific end-to-end checks On top of the base skill's authorize → accessible-resources → refresh round trip: 1. Make one real read against `https://api.atlassian.com/ex/confluence/{cloudid}/wiki/api/v2/pages` and confirm the `/wiki` segment and the product segment are both right (§3). 2. **Page it.** Follow `_links.next` or the `Link` header at least twice, and confirm the client is not incrementing an offset against v2 (§2). 3. Authorize as a **restricted user** — someone who cannot see one known space — and confirm the connector reports the gap rather than reporting success on a partial wiki (§4). 4. If search is in scope, call the CQL search endpoint and confirm which scope actually satisfies it before shipping a mixed-family scope set (§1). 5. Read a page that carries **page restrictions** and confirm the client's behavior is deliberate (§4). 6. Check the response headers on a content read for `Atlassian-DataSecurityPolicy` (§5), and log it — this is the only distinguishable signal for an admin-blocked space. 7. Watch `X-RateLimit-Remaining` during an initial sync (§6). | Symptom | Cause | | --- | --- | | 401 with a scope message on search, everything else fine | Classic `search:confluence` vs granular family mismatch — try `read:content-details:confluence` (§1) | | 404 on every Confluence call, auth fine | `/wiki` missing from the path, or the wrong product segment on a shared cloudid (§3) | | 404 addressing a space that exists | v2 wants the numeric space id; the space **key** is a v1 concept (§2) | | Connected successfully, zero pages returned | The authorizing user cannot see those spaces (§4) | | One space appears but is always empty | Space permissions (§4), or a data security policy app access rule (§5) | | Half the comments are missing | Only footer or only inline comments are being read (§2) | | Lists return 50 rows when 250 were requested | A body format was requested on a listing endpoint (§2) | | Sync silently stops partway | Cursor pagination not followed — offset paging against v2 (§2) | | `429` mid-sync | Hourly points budget exhausted; back off, do **not** re-authorize (§6) | | A v1 call that worked last quarter now 404s | That operation's own deprecation window closed (§2) | | Nothing resembles this; the site is not `*.atlassian.net` | Confluence Data Center — different auth model entirely (§7) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - The client calls **both v1 and v2** and nobody has decided how to keep the scope set in one family (§1, §2). - A feature depends on **content restrictions or CQL search**, both v1-only, and the v1 deprecation exposure has not been assessed (§2). - Coverage has been promised that depends on **who authorizes** — broad-wiki sync via a narrowly permissioned service account cannot be fixed in the console (§4). - A customer's admin has applied a **data security policy app access rule** and someone proposes switching to Atlassian API tokens to get around it (§5). - The expected volume does not fit the **global points pool** and nobody owns the per-tenant pool question (§6). - The target turns out to be **Confluence Data Center** (§7). - Anyone asks to add or remove a Confluence scope on a live app — that re-consents every connected customer. - The scope tables or endpoint groups do not match the **Confluence platform state** section above. ## References Confluence-specific only; the base skill carries the portal-wide Atlassian references. Every link verified to resolve on 2026-09-20. - OAuth 2.0 (3LO) apps — Confluence Cloud — https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/ - Confluence scopes for OAuth 2.0 (3LO) and Forge apps (classic and granular) — https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/ - Confluence Cloud REST API v2 intro (cursor pagination, base path) — https://developer.atlassian.com/cloud/confluence/rest/v2/intro/ - Confluence Cloud REST API v1 intro — https://developer.atlassian.com/cloud/confluence/rest/v1/intro/ - v2 Page endpoints (scopes, permission statements) — https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/ - v2 Space endpoints (numeric space id) — https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/ - v2 Blog post endpoints — https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-blog-post/ - v2 Comment endpoints (footer vs inline) — https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-comment/ - v2 Attachment endpoints (`downloadLink`) — https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-attachment/ - v2 Space permission endpoints — https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space-permissions/ - v1 Content restrictions (no v2 equivalent) — https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-restrictions/ - Security for Confluence Cloud apps (scopes intersected with user permissions) — https://developer.atlassian.com/cloud/confluence/security-overview/ - Data security policy developer guide (app access rules, blocked-content header) — https://developer.atlassian.com/cloud/confluence/data-security-policy-developer-guide/ - Confluence Cloud rate limiting (points pools, headers) — https://developer.atlassian.com/cloud/confluence/rate-limiting/ - Confluence Cloud changelog — https://developer.atlassian.com/cloud/confluence/changelog/ - Using the Confluence REST API — https://developer.atlassian.com/cloud/confluence/using-the-rest-api/ - Basic auth for REST APIs (the API-token path) — https://developer.atlassian.com/cloud/confluence/basic-auth-for-rest-apis/ - Deprecation notice — basic authentication with passwords — https://developer.atlassian.com/cloud/confluence/deprecation-notice-basic-auth/ - Confluence Data Center REST API (*not* this skill) — https://developer.atlassian.com/server/confluence/confluence-server-rest-api/ - Confluence Data Center OAuth 2.0 provider API (*not* this skill) — https://developer.atlassian.com/server/confluence/confluence-oauth2-provider-api/ - Confluence Cloud free signup — https://www.atlassian.com/try/cloud/signup?bundle=confluence ## atlassian-jira-oauth-app URL: https://docs.unified.to/skills/atlassian-jira-oauth-app/SKILL.md # Atlassian Jira Cloud OAuth 2.0 (3LO) App Registration Registering the app is the shared part. **What is specific to Jira is that "Jira" is five APIs, not one** — the Jira platform, Jira Software, Jira Service Management, Confluence and Assets each carry their own scope list inside the same console app — and that Jira's classic scopes are broad enough that most connectors never need the granular family at all. The expensive Jira-specific mistake is assuming a scope you added covers a product you did not add. Adding the Jira API does not give you Jira Service Management scopes; the consent screen rejects what the console does not know about, and the failure looks like a permissions problem. ## Built on: `atlassian-cloud-oauth` **Read `atlassian-cloud-oauth` first, then this file.** It owns all the portal mechanics, and this file does not repeat them: - The Developer Console, creating an **OAuth 2.0 integration**, enabling 3LO, account-level vs resource-level grants, and where the client ID and secret live. - **One callback URL per app**, and what that forces on a platform serving several data centers. - The classic/granular scope model in general, `offline_access`, the under-50 ceiling, and scope changes re-consenting every existing user. - **Rotating refresh tokens** — 90-day inactivity expiry, 10-minute reuse leeway, and the failure that only shows up on the *second* refresh. - The **cloudid indirection**: `accessible-resources`, `/ex/{product}/{cloudid}/...`, the multi-site array, and the required `audience` and `prompt=consent` authorize parameters. - Distribution, the unreviewed-app warning, Marketplace approval, data residency, credential handoff, and the two practices Atlassian names as non-compliant (collecting API tokens, or having customers create individual 3LO apps). If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Jira-specific ones: | Input | Notes | | --- | --- | | **Which Atlassian products does the integration actually call?** | Jira platform, Jira Software, Jira Service Management, Confluence and Assets are separate APIs with separate scope lists (§2) | | **Read-only, or create/edit issues?** | Decides how far up the classic ladder you go (§1) | | **Does it administer projects or configuration?** | `manage:jira-project` and `manage:jira-configuration` are broad and visible on the consent screen (§1) | | **Does it register webhooks?** | Dynamic webhook registration is its own scope (§1) | | **Does anything depend on JQL over entity properties?** | 3LO apps cannot do this at all (§3) | | **A Jira Cloud site to test against** | A free site is enough; a sandbox needs Premium/Enterprise | ## Quick Start 1. Work the base skill's console steps, adding the **Jira API(s)** you actually call under Permissions (§2). 2. Add the classic Jira scopes for what the integration does, plus `offline_access` (§1). 3. Confirm the client addresses `/ex/jira/{cloudid}/rest/api/3/...` and not the customer's site host (§2). 4. Check §3 before promising anything that depends on JQL over entity properties or on per-user grant revocation. 5. Finish the base skill's capture, round-trip and handoff steps, adding the Jira checks in §4. ## Jira platform state (verified 2026-09-20 — re-verify before trusting) The Jira 3LO documentation was last updated 2026-09-18, so this is current as of writing. Check the linked pages before following any older runbook. - **Atlassian's stated recommendation for Jira is classic scopes**: *"When choosing your scopes, the recommendation is to use classic scopes"*, and for granular: *"Use these scopes only when you can't use classic scopes."* That is a stronger classic preference than some other Atlassian products, whose newer API generations document granular scopes only — do not carry a Confluence-shaped habit into a Jira app, or the reverse. - **Jira platform REST v3** is the current generation for 3LO apps, addressed as `/ex/jira/{cloudid}/rest/api/3/`. - **Known 3LO limitation:** Jira apps using 3LO **cannot declare searchable entity properties**, so they cannot reference entity properties in JQL. No scope fixes this; it is a property of 3LO apps. - **An admin cannot revoke one user's grant** from the Connected Apps screen — the grant is between the app and the user, so an admin can only uninstall. This bites when a customer expects per-user offboarding. If the console or the scope lists do not look like this, stop and report what you actually see rather than clicking on. **Jira Cloud only.** Jira **Data Center / Server** does not use this at all — it uses OAuth 1.0a via application links (RSA-SHA1 signed) or personal access tokens, registered inside the customer's own instance, with no Atlassian-hosted developer console, no `auth.atlassian.com`, and no cloudid. If the customer's URL is not `*.atlassian.net` (or a custom Cloud domain), you are in the wrong document. ## 1. Jira scopes **Classic scopes** are broad and are the recommended default. For the Jira platform API there are six: | Scope | What Atlassian says it grants | | --- | --- | | `read:jira-user` | View user information — usernames, email addresses, avatars | | `read:jira-work` | Read project and issue data; search issues; attachments and worklogs | | `write:jira-work` | Create and edit issues, post comments as the user, create worklogs, delete issues | | `manage:jira-project` | Create and edit project settings and project-level objects (versions, components) | | `manage:jira-configuration` | Jira administration — create projects and custom fields, view workflows, link types | | `manage:jira-webhook` | Fetch, register, refresh and delete dynamically declared webhooks | **Granular scopes** are per-resource and follow a `verb:resource:jira` shape — `read:issue:jira`, `write:comment:jira`, `delete:attachment:jira`, `write:project:jira`, and several hundred more. Atlassian's guidance for Jira is unambiguous: *"Use these scopes only when you can't use classic scopes."* What matters operationally, on top of the base skill's scope rules: - **Six classic scopes cover most Jira connectors.** Reach for granular only when an endpoint's reference page offers no classic alternative. Mixing families within one product is the shape most likely to produce a 401 with a scope-mismatch message on an endpoint you are certain you are entitled to call. - **Find the scope an endpoint needs in the OAuth scopes required field** in the Jira REST API reference. If an operation says *"Apps can't access this REST resource"*, no scope will help — it is not available to 3LO at all. - **`manage:jira-configuration` is a Jira-administration scope** and reads that way on the consent screen. It is routinely the reason an enterprise security review stalls. Only add it if an endpoint you call actually requires it. > **As of 2026-09-20, Unified.to's Jira connector requests:** `offline_access` in every per-object scope set, plus > **classic** Jira scopes — `read:jira-user`, `read:jira-work`, `write:jira-work`, `manage:jira-configuration`, > `manage:jira-project`, and `manage:jira-webhook` for webhook registration — with the scope set narrowed per unified > object and per read/write direction rather than one flat union. The one exception is the project-write path, which > **also carries the granular scope `write:project:jira`** alongside the classic project scopes: that is a mixed-family > set, and it is the first thing to check if project creation fails with a scope error. Separately, the identity / > login authorization requests only `read:jira-user` — with no `offline_access`, so that flow gets no refresh token by > design. The connector also offers a **non-OAuth path** (Jira subdomain + account email + an Atlassian API token), > which is exactly the pattern Atlassian's 3LO pages name as non-compliant (see the base skill). Confirm all of this > with the connector's owner before registering — the connector, not this document, is the source of truth for what > it sends. ## 2. "Jira" is five APIs Under **Permissions → Add**, the console offers each Atlassian product API separately, and each carries its own scope list: | API | Scope family | Notes | | --- | --- | --- | | **Jira platform** | The classic six above, plus granular `…:jira` | Issues, projects, users, webhooks | | **Jira Software** | Its own list (boards, sprints, backlogs) | Adding the Jira platform API does **not** add these | | **Jira Service Management** | `read:servicedesk-request`, `write:servicedesk-request`, `manage:servicedesk-customer` on top of the Jira classic six | Requests, queues, customers | | **Confluence** | An entirely separate family — see the Atlassian Confluence skill | Same console app, different product segment in the URL | | **Assets** | Its own list | Objects and schemas | Two consequences: - **A scope for a product whose API you never added fails at the consent screen**, not at save time, and the message reads like a scope-name problem rather than a missing-API problem. - **The product segment in the API URL follows the API you called, not the app.** Jira platform v3 reads are `https://api.atlassian.com/ex/jira/{cloudid}/rest/api/3/`; Confluence is `/ex/confluence/{cloudid}/…` with its own base path. The same cloudid commonly serves both on one site, so a URL can carry the right cloudid and the wrong product segment and 404 on every call. Unified.to maintains **separate connectors for Atlassian Confluence and Atlassian Jira Service Management**, each with its own credentials and its own scope set. One Atlassian app *can* serve several products if you add each product's API and scopes to it, but that is a decision to make with the connectors' owners, not a default. > **As of 2026-09-20, Unified.to's Jira connector** resolves the cloud ID during connection initialization: it calls > the accessible-resources endpoint, takes the **first** site in the response, stores > `https://api.atlassian.com/ex/jira/{cloudid}` as that connection's API base, and derives the site subdomain from the > returned site URL. Because the first entry is taken, a customer whose grant covers **more than one Jira site** can > be connected to the wrong one, and because resolution happens at setup, a later change in the grant is not > automatically picked up. > > **Probable bug, worth confirming before you debug anything downstream:** the connector's *fallback* cloud-ID path — > the one used when the primary resolution does not produce a site, and the one the non-OAuth API-token path uses, > reading the cloud ID from the customer site's own tenant-info endpoint — builds the API base with the > **`/ex/confluence/` product segment rather than `/ex/jira/`**. A connection that takes that branch would address the > wrong product's API on a correct cloud ID, which presents as a 404 on every Jira call rather than as an auth error. > Raise it with the connector's owner; do not work around it in the console. ## 3. Jira-only 3LO limitations - **Entity properties are not searchable from a 3LO app.** Jira apps using 3LO cannot declare searchable entity properties, and therefore cannot reference them in JQL. If a requirement depends on querying app-written entity properties, 3LO is the wrong app type and no scope changes that — raise it before building. - **Per-user grant revocation does not exist for an admin.** The grant binds the app to the user, so a site admin looking at Connected Apps can uninstall but cannot revoke one person's access. Customers with offboarding requirements need to hear this up front. ## 4. Jira-specific end-to-end checks On top of the base skill's authorize → accessible-resources → refresh round trip: 1. Make one real read against `https://api.atlassian.com/ex/jira/{cloudid}/rest/api/3/...` — confirming the product segment as well as the cloudid (§2). 2. If the integration registers webhooks, exercise that path specifically — `manage:jira-webhook` is easy to omit and the failure only appears when a webhook is registered, not at connect. 3. If Jira Service Management or Confluence endpoints are called, confirm those APIs were added to the app and their scopes granted (§2) — the per-site `scopes` array from `accessible-resources` is where you see what actually landed. 4. Connect a user who lacks *Administer Jira* and confirm the connector degrades honestly: a scope grant does not exceed the user's own permissions, so project creation fails at the API, not at consent. | Symptom | Cause | | --- | --- | | Auth succeeds, every Jira call 401s or 404s | Client is calling `.atlassian.net` instead of `/ex/jira/{cloudid}` (base skill) | | 404 on every call, cloudid looks correct | Wrong product segment in the API base — see the fallback-path note in §2 | | Consent screen rejects a scope that exists in the docs | That product's API was never added to the app (§2) | | Jira Service Management or Confluence endpoints 403 | Those APIs and their scopes are not on this app (§2) | | Project creation fails with a scope error | Mixed classic/granular project scopes (§1) | | Webhook registration fails, everything else works | `manage:jira-webhook` missing (§1) | | App has `manage:jira-project` but cannot create projects | The authorizing user lacks *Administer Jira* — a permission failure, not a scope failure (§1) | | JQL cannot see an app-written entity property | 3LO apps cannot declare searchable entity properties (§3) | | Admin wants to revoke one user and can only uninstall | Grants are per user, not per site (§3) | | Reads the wrong Jira site | First entry of `accessible-resources` taken blindly (§2, base skill) | | Nothing resembles this document; no `auth.atlassian.com` | It is Jira Data Center/Server, not Cloud — wrong auth model entirely | ## Stop and ask Beyond the base skill's list, hand back to a human when: - The integration needs endpoints from **more than one Atlassian product** and nobody has decided whether that is one app or several (§2). - A requirement depends on **JQL over entity properties**, which 3LO cannot do at all (§3). - A customer's offboarding process assumes **per-user grant revocation** (§3). - The scope set mixes **classic and granular** Jira scopes and nobody owns resolving it (§1). - The request is really about **Confluence or Jira Service Management** — separate connectors, separate scope families, and Confluence has its own skill. - The target is **Jira Data Center/Server**. - The Jira scope lists do not match the **Jira platform state** section above. ## References Jira-specific only; the base skill carries the portal-wide Atlassian references. Every link verified to resolve on 2026-09-20. - OAuth 2.0 (3LO) apps (Jira Cloud platform) — https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/ - Jira scopes for OAuth 2.0 (3LO) and Forge apps (classic vs granular) — https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/ - Jira Software scopes — https://developer.atlassian.com/cloud/jira/software/scopes-for-oauth-2-3LO-and-forge-apps/ - Jira Service Management scopes — https://developer.atlassian.com/cloud/jira/service-desk/scopes-for-oauth-2-3LO-and-forge-apps/ - Jira Cloud platform REST API v3 intro (scopes per operation, pagination) — https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/ - Jira Cloud webhooks — https://developer.atlassian.com/cloud/jira/platform/webhooks/ - Jira Cloud rate limiting — https://developer.atlassian.com/cloud/jira/platform/rate-limiting/ - Jira data security policy developer guide (app access rules) — https://developer.atlassian.com/cloud/jira/platform/data-security-policy-developer-guide/ - Basic auth for REST APIs (the API-token path) — https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/ - Jira Cloud free signup — https://www.atlassian.com/try/cloud/signup?bundle=jira-software - OAuth for Jira Data Center/Server (OAuth 1.0a — *not* this skill) — https://developer.atlassian.com/server/jira/platform/oauth/ ## attio-oauth-app URL: https://docs.unified.to/skills/attio-oauth-app/SKILL.md # Attio OAuth2 App Registration Get a working Attio OAuth2 client — an Attio account and workspace, an app in the Developer console, redirect URIs, a scope selection, client ID and client secret — for a platform that connects many customers' Attio workspaces. Four things are expensive to get wrong here, and none of them is the signup form: 1. **Scopes are not requested. They are configured.** Attio's authorize endpoint takes `client_id`, `response_type`, `redirect_uri` and `state` — and **no `scope` parameter at all**. Every customer grants exactly the scope set ticked in the app's **Scopes** tab. The app registration *is* the scope decision, and a missing scope does not fail at authorization; it fails later, as a permission error on one endpoint (§5). 2. **Reading the schema is a separate scope from reading the data.** `record_permission:read` grants records; `object_configuration:read` grants the objects and attributes that describe them — and Attio requires **both** on nearly every record endpoint. Of the ~36 scopes, `object_configuration:read` is required by more endpoints than any other. A connector with records but not object configuration reads nothing (§5, §6). 3. **The workspace defines its own objects and attributes.** Attio ships people and companies, offers deals, users and workspaces, and lets each customer add custom objects, custom lists and custom attributes of ~17 types. A scope grants a *capability*, not a schema — two customers who granted the identical scope set can return completely different shapes (§6). 4. **Tokens do not expire and there is no refresh token.** Attio states plainly that `exp` is always `null` "because Attio access tokens do not currently expire". There is nothing to refresh, and the only ways a token dies are revocation and uninstall — which arrive as a 401 with no warning (§7). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name** and **slug** | The slug is a unique identifier; Attio says pick one you're happy to keep (§3) | | **Developer account name and logo** | Public-facing if you ever publish; name rules are strict (§3, §8) | | **Which Attio account and workspace** owns the app | You need **admin** on it; prefer an isolated development workspace (§2) | | **Redirect URIs** | Every callback host your platform serves; must match exactly (§4) | | **Which objects and operations** the connector performs | Decides the scope selection — this is the whole job (§5) | | **Webhooks wanted?** | Needs `webhook:read-write` and API-created webhooks (§9) | | **Publish to the Attio App Store?** | Optional; OAuth works unpublished, with a warning (§8) | | **Whether customers run custom objects / custom attributes** | Changes what "done" means for the connector (§6) | ## Quick Start 1. Confirm a **new** app is needed — a new client ID orphans every existing customer connection (§1). 2. Sign in to Attio as a workspace **admin**, ideally of an isolated development workspace (§2). 3. **Developer console → New app** at `https://build.attio.com`; set name and slug (§3). 4. **OAuth tab → enable the OAuth 2.0 toggle**, then add **every** redirect URI, exactly (§3, §4). 5. **Scopes tab** — tick the union your connector needs, and do not forget `object_configuration:read` (§5). 6. Read §6 before promising anyone the connector "syncs Attio" — the customer defines the schema. 7. Capture client ID and client secret from the OAuth tab (§7). 8. Plan for **non-expiring tokens with no refresh token**, and handle revocation as a 401 path (§7). 9. If you need push events, enable `webhook:read-write` and register webhooks through the API (§9). 10. Verify from a **second, unrelated workspace**, as an admin, and check `GET /v2/self` (§10). 11. Hand the credentials over — never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The docs moved to `docs.attio.com`.** `developers.attio.com/docs/*` now **308-redirects to `https://docs.attio.com/`** — the docs home, not the equivalent page. `attio.com/developers/introduction` redirects the same way. Any bookmark or code comment pointing at `developers.attio.com` lands somewhere generic. - **Apps are created in the Developer console at `https://build.attio.com`** — "New app" in the sidebar, then name and slug. OAuth is a per-app **toggle** on the app's OAuth tab; scopes are on its Scopes tab. - **Endpoints, fixed for everyone:** authorize `https://app.attio.com/authorize`, token `https://app.attio.com/oauth/token`, revoke `https://app.attio.com/oauth/revoke`, API base `https://api.attio.com` (v2), identify `GET https://api.attio.com/v2/self`. - **Revocation is new.** The revoke endpoint was added on **2026-09-11**. Anything written before that date will tell you Attio has no revocation endpoint. - **The authorize call has no `scope` parameter.** Documented query parameters are `client_id` (required), `response_type=code` (required), `redirect_uri` (required, must exactly match a registered URI) and `state` (optional). **PKCE is not documented.** - **Token exchange**: Attio's tutorial posts `application/x-www-form-urlencoded` with `grant_type`, `code`, `redirect_uri`, `client_id` and `client_secret`. The endpoint reference lists `client_id`, `client_secret`, `grant_type` and `code` as required and does **not** list `redirect_uri`. Follow the tutorial: send `redirect_uri`, form-encoded. - **The documented token response is `access_token` and `token_type: "Bearer"` — nothing else.** No `expires_in`, no `refresh_token`, no `scope`. - **Tokens do not expire.** Attio's identify endpoint states: "`exp` is always `null`, because Attio access tokens do not currently expire." Manually generated API keys likewise "do not expire, but can be deleted at any time." - **Tokens are workspace-scoped, not user-scoped.** Introspection returns `sub` = the workspace ID, plus `workspace_id`, `workspace_name`, `workspace_slug` and `authorized_by_workspace_member_id` — the member who authorized it, recorded but not the scope boundary. - **The scope catalogue is 18 resources × `:read` / `:read-write` = 36 strings** (§5). There is no bare `:write`. - **Installing and managing apps and workspace integrations is an admin-only capability.** Members can view workspace settings but not change them. - **Review is not a gate on connecting other workspaces.** Attio: "If your app uses OAuth, users can install it via the standard OAuth flow even before the app has been published. However, they will see a warning that the app has not yet been approved by the Attio team." App Store listing is optional, reviewed, and aims to complete within **1 week**. - **Rate limits: 100 requests/second for reads, 25 requests/second for writes**, plus score-based limits on List records and List entries whose scores are **summed across all apps and access tokens** in the workspace over a 10-second sliding window. Individual endpoints can be lower: **List notes has been capped at 10 req/s since 2026-09-02** as a temporary stability measure, and create-call-recording is 1 req/s. Revoke is separately limited to 50 requests/minute per IP. - **Webhooks are API-created for multi-customer integrations**, signed with `Attio-Signature`, HTTPS-only, 5-second timeout, and duplicate subscriptions are rejected with 409 (§9). - **The App SDK is a different product** — TypeScript/React apps that run inside Attio, versioned on Attio's infrastructure and subject to code review. A REST/OAuth connector is not an App SDK app and does not get code reviewed; do not let SDK documentation set expectations for this run. If the Developer console does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer authorization is bound to the old one** — every customer re-authorizes. Reuse the existing app for: adding a redirect URI, changing the scope selection, rotating a compromised secret, enabling webhooks, or diagnosing an authorization failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, or a deliberate environment split. Note the Attio-specific wrinkle: because scopes live on the app and not on the authorize call, **changing the scope selection changes it for every future authorization at once**. Attio does not document whether tokens already issued pick up newly added scopes or keep the set they were granted (§5) — treat a scope addition as potentially requiring a re-authorization sweep until you have verified otherwise against a real token. Say which path you are taking before you touch the console. ## 2. Account and workspace - Sign in or sign up at Attio, then open the Developer console at `https://build.attio.com`. - **You must be a workspace admin.** Attio lists "install and manage apps and workspace integrations" among the things only admins can do; members can view workspace settings but not edit them. API keys are likewise admin-only, under **Workspace settings → Developers**. - **Use an isolated development workspace.** Attio recommends it, and says outright: "We are happy to provide your team with development workspaces as needed. Please reach out to `support@attio.com` or use the chat widget." Ask for one rather than testing against a production CRM full of real customer records. - The **developer account** itself — the identity your app is published under — carries a name and a logo, and both are subject to App Store rules if you ever publish (§8). Setting them sensibly now costs nothing. Hand control back for anything only a human can do: email verification, 2FA, accepting terms, being made an admin, requesting a development workspace, or submitting the app for review. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an ordered click path with the literal values to paste — the redirect URIs from §4 and the exact scope strings from §5 — and continue once they report back with the client ID. ## 3. Create the app **Developer console → New app.** Give the app a name and a **slug**; Attio calls the slug "a unique identifier for your app, so pick one you're happy to keep." Then: | Step | Detail | | --- | --- | | **OAuth tab** | **Enable OAuth 2.0 via the toggle at the top of the page.** Without this there is no client ID, no client secret and no flow. | | **Redirect URIs** | Configured on the same OAuth tab. See §4. | | **Scopes tab** | The whole permission model. See §5. | | **Client ID / secret** | On the OAuth tab once OAuth is enabled. See §7. | | **Share privately** | Overflow menu (three dots, top right) → *Share privately* for an install link (§8). | | **Publish app** | Button top right, when and if you want an App Store listing (§8). | There is no private-vs-public *distribution* setting to get wrong and no app-type fork: an OAuth app is installable by any workspace that completes the flow from the moment OAuth is enabled. Publishing only affects discoverability and the unapproved-app warning (§8). ## 4. Redirect URIs Register **every** callback host your platform serves. Attio's rule is exact matching: the `redirect_uri` on the authorize call "must exactly match one of the registered redirect URLs in your app's settings pages at build.attio.com". For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Two practical notes: - **Send `redirect_uri` again at the token exchange.** Attio's OAuth tutorial includes it in the form body, even though the endpoint reference does not list it among the required parameters. Sending it matches the worked example and costs nothing; omitting it relies on undocumented leniency. - **Plain HTTP is accepted for local development** — Attio's own tutorial registers `http://localhost:3050/integrations/attio/callback`. Do not leave a localhost URI on a production app. Attio does not publish a cap on the number of redirect URIs. Register the literal strings above and do not rely on trailing-slash or subpath forgiveness. ## 5. Scopes — configured on the app, never requested **This is the section that decides whether the connector works.** There is no `scope` parameter on the authorize call. Attio says it directly: "When using an OAuth access token, the scopes are specified by configuring the scope settings for your app in the Developer console." Whatever is ticked on the Scopes tab is what every customer grants, today and retroactively for every future install. The full catalogue — **18 resources, each as `:read` or `:read-write`.** There is no bare `:write`; `:read-write` is the write level and includes reading. | Scope family | What Attio says it grants | | --- | --- | | `record_permission:read` / `:read-write` | View, and optionally write, **records** | | `object_configuration:read` / `:read-write` | View, and optionally write, the **configuration and attributes of objects** | | `list_entry:read` / `:read-write` | View, and optionally write, the **entries in a list** | | `list_configuration:read` / `:read-write` | View, and optionally write, the **configuration and attributes of lists** | | `user_management:read` / `:read-write` | View **workspace members** | | `note:read` / `:read-write` | View, and optionally write, **notes** | | `task:read` / `:read-write` | View, and optionally write, **tasks** | | `comment:read` / `:read-write` | View **comments (and threads)**, and optionally write comments | | `meeting:read` / `:read-write` | View, and optionally write, **meetings** | | `call_recording:read` / `:read-write` | View, and optionally write, **call recordings, transcripts and speakers** | | `webhook:read` / `:read-write` | View, and optionally **manage, webhooks** | | `file:read` / `:read-write` | View, and upload, **files** | | `email:read` / `:read-write` | View **email metadata**. "Email content is never exposed." | | `activity_configuration:read` / `:read-write` | Configuration and attributes of **activities** | | `activity_record:read` / `:read-write` | **Activity records** | | `sequence_unsubscribe:read` / `:read-write` | View, and optionally add to, the **sequence unsubscribe list** | | `public_collection:read` / `:read-write` | Settings and information within **public collections** (legacy naming) | | `private_collection:read` / `:read-write` | Settings and information of **all** collections "regardless of their access settings" | ### The `object_configuration` trap This is the classic Attio failure, and it is not obvious from the scope names. **Records and the schema that describes them are two different scopes, and record endpoints require both.** Straight from Attio's endpoint reference: | Endpoint | Required scopes | | --- | --- | | List records (`POST /v2/objects/{object}/records/query`) | `record_permission:read`, **`object_configuration:read`** | | Get a record | `record_permission:read`, **`object_configuration:read`** | | Create / update a record | `record_permission:read-write`, **`object_configuration:read`** | | List objects (`GET /v2/objects`) | `object_configuration:read` | | List attributes, when `target` is `objects` | `object_configuration:read` | | List attributes, when `target` is `lists` | `list_configuration:read` | | List notes | `note:read`, `object_configuration:read`, `record_permission:read` | | List / create tasks | `task:read`(`-write`), `object_configuration:read`, `record_permission:read`, `user_management:read` | | List / get list entries | `list_entry:read`, `list_configuration:read` | | List workspace members | `user_management:read` | | Create / update / delete a webhook | **`webhook:read-write`** | Read that table as three rules: 1. **`object_configuration:read` is effectively mandatory.** It is required by more endpoints than any other scope, including plain record reads. Tick it even for a read-only integration. An app that grants records but not object configuration authorizes cleanly and then fails on the first list call. 2. **Lists are a parallel universe.** Records live under `object_configuration` + `record_permission`; list entries live under `list_configuration` + `list_entry`. Reading a list's attributes needs `list_configuration:read`, not `object_configuration:read`. Anything that models pipelines or processes as Attio lists needs the list pair too. 3. **Cross-resource scopes are common.** Tasks need four scopes. Notes need three. Copy the "Required scopes" line from each endpoint's reference page rather than guessing from the resource name. `private_collection:*` grants access "regardless of their access settings" — it is the one a security-conscious admin will look at hardest. Do not tick it unless the product genuinely requires seeing collections the authorizing admin's own permissions would not reach. > **As of 2026-09-20, this platform's Attio connector** correctly sends **no `scope` parameter** on the authorize > call — which is right for Attio — and is a confidential client that does **not** use PKCE. Its internally recorded > per-capability requirements amount to: `record_permission:read` / `:read-write`, `object_configuration:read` / > `:read-write`, `list_configuration:read` / `:read-write`, `note:read` / `:read-write`, `task:read` / > `:read-write`, `meeting:read`, and `user_management:read` / `:read-write` — and **no `webhook` scope at all**, > even though it registers webhooks through the API, which Attio gates behind `webhook:read-write`. Treat that list > as the floor, not the spec: because Attio ignores requested scopes entirely, only the Scopes tab matters, so tick > the union of everything above **plus `webhook:read-write`** if webhooks are wanted. Confirm the exact current set > with the connector's owner before ticking boxes. ## 6. The object and attribute data model — read this before promising anything Attio is not a fixed-schema CRM, and this is the thing that most often makes an integration "work" in testing and disappoint in production. - **Objects are the tables.** Attio's own analogy: "In relational database terms, objects are roughly tables." **People and companies are enabled by default; deals, users and workspaces are optional**, and a customer can create **custom objects** on top. Only admins can enable and deactivate standard objects. - **Records are the rows.** An instance of an object. - **Attributes are the columns**, and "Some attributes, such as the name on a person, are system defined. Others, you define yourself, either in Attio's UI or over the API." Attribute types include text, number, select, status, rating, currency, date, timestamp, checkbox, domain, email address, phone number, location, personal name, record reference, actor reference and interaction. - **Lists are a second axis.** Lists aggregate records into a process, and **lists carry their own attributes** — Attio's example is a deal that has extra attributes only relevant inside one list. A value can therefore live on the record, on the list entry, or both. Four consequences for a connector that cannot know the shape in advance: 1. **Discover, do not assume.** `GET /v2/objects` and the attributes endpoints are how you learn what this workspace actually has. That is exactly why `object_configuration:read` is required alongside records (§5) — and why a connector that hardcodes `people`, `companies` and `deals` breaks the moment a customer's value lives on a custom object or a custom attribute. 2. **An object that is not enabled simply does not exist.** Deals, users and workspaces are optional. A perfectly valid workspace may have no deals object, and the right behaviour is a clear message, not a crash. 3. **Slugs versus IDs.** Objects, lists and attributes are addressable by slug or UUID; slugs are human-editable and IDs are not. Store IDs for anything you must resolve again later. 4. **The same scope grant returns different data per customer.** Two customers who tick the identical boxes can return entirely different attribute sets. "We support Attio" is a claim about capabilities, not about fields — say so before a customer infers otherwise. ## 7. Capture the credentials, and understand the token From the app's **OAuth tab** in the Developer console: - **Client ID** and **client secret** - Endpoints, fixed and identical for everyone: - authorize — `https://app.attio.com/authorize` - token — `https://app.attio.com/oauth/token` - revoke — `https://app.attio.com/oauth/revoke` - REST API — `https://api.attio.com` (v2); identify — `GET https://api.attio.com/v2/self` **The flow:** ``` GET https://app.attio.com/authorize ?client_id=&response_type=code &redirect_uri=&state= POST https://app.attio.com/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=&redirect_uri= &client_id=&client_secret= → { "access_token": "...", "token_type": "Bearer" } ``` Then `Authorization: Bearer ` on every API call. Attio also accepts HTTP Basic with the token as the username and a blank password, but recommends Bearer. **Token behaviour — the part most OAuth checklists get wrong here:** - **No expiry.** Attio: "`exp` is always `null`, because Attio access tokens do not currently expire." There is no `expires_in` in the documented response. - **No refresh token.** None is documented in the token response, and there is no documented refresh grant. Any refresh machinery in a client is inert against Attio — harmless, but do not let its presence convince anyone that expiry is handled. - **A token is workspace-scoped.** `sub` is the workspace ID. `authorized_by_workspace_member_id` records who clicked, but the grant is the workspace's. - **Revocation is the real failure mode.** `POST https://app.attio.com/oauth/revoke` with the token in the `Authorization` header, or form-encoded as a `token` parameter; `204 No Content` on success, `400` if no token was found or if the header and body disagree. A customer uninstalling or revoking from their side produces the same outcome with no notice to you. Because tokens never expire, **a 401 means "gone", not "refresh me"** — do not build a refresh-and-retry loop that will never succeed. - **`GET /v2/self` is the health check.** It needs no scopes, accepts any Attio token type, and returns `200` with `{"active": false}` — not an error — for unknown, revoked or deleted tokens. Poll it rather than inferring liveness from a business endpoint. Rotating the client secret breaks every token exchange until the new value is deployed; existing access tokens are not documented as being invalidated by rotation, but do not rely on that. Never rotate without explicit go-ahead. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. > **As of 2026-09-20, this platform's Attio connector** uses the authorize, token and API base URLs above, sends > `client_id`, `redirect_uri`, `response_type` and `state` on the authorize call, and presents the result as a > `Bearer` token. It treats the access token as non-expiring, which matches Attio. Two divergences from Attio's > documented worked example are worth raising with the connector's owner rather than assuming they are fine: it > posts the code exchange as **JSON** rather than `application/x-www-form-urlencoded`, and it **omits `redirect_uri` > from the exchange body**. It also carries refresh-token configuration that Attio does not document. Confirm the > current behaviour with the connector's owner — this is a snapshot, not a contract. ### The single-workspace alternative If the user actually has one Attio workspace and no multi-tenant requirement, **an API key is the right answer and this run produces no OAuth credentials at all.** Workspace settings → **Developers** → **+ New access token**, name it, set its scopes. Admins only. "Tokens do not expire, but can be deleted at any time", and — unlike OAuth — the **scopes of an existing key can be edited later**. Attio's scope catalogue is identical for both token types. Say this out loud before building an OAuth app for a single-workspace use case. ## 8. Install, approval, and the App Store Three things people conflate: 1. **Who installs (the customer's side).** Attio lists "install and manage apps and workspace integrations" as an **admin-only** capability, and the token is workspace-scoped. Expect a customer **admin** to be the one who completes your flow. A member who tries will not be able to grant workspace-wide access — plan the support message for that, because "the connect button did nothing" from a non-admin is a common ticket. 2. **Review is not a gate on connecting.** Attio: "If your app uses OAuth, users can install it via the standard OAuth flow even before the app has been published. However, they will see a warning that the app has not yet been approved by the Attio team." So a brand-new app can onboard real customers today; the cost is a scary warning on the consent screen. Publishing removes it. **Private invites** (overflow menu → *Share privately*) are an App SDK sharing mechanism and are not what an OAuth connector needs, though they carry the same unapproved warning. 3. **Publishing to the App Store is optional and reviewed.** Developer console → **Publish app**. Attio aims to complete reviews **within 1 week**. Requirements are specific and rejections are cheap to avoid: - **Developer account**: must not contain "Attio" or impersonate another company; no "dev"/"test" in the name; correctly capitalized; must have a logo. - **App name**: must not contain "Attio"; unofficial apps must not imply they are the official integration for a service; official apps match the service's exact name and capitalization; unofficial apps use title case. - **Listing text**: three sections — Overview, How It Works, Configure — each **100–3,000 characters**, English, high quality, headers must not end with a colon, and Attio concepts spelled "records", "objects", "lists", "people". - **Submission video**: initial submissions **must** include a screen recording walking through installation, authentication/configuration and core features — not a generic marketing video. - **Links**: HTTPS, and every link **must return 200**. - **Logos**: PNG, no transparency, **minimum 560×560**, 1:1, no rounded corners (Attio applies a 30% radius), with padding. - Code review applies to **App SDK** apps, not to a REST/OAuth connector running on your own infrastructure — for which Attio states there is "no versioning or review process on Attio's side" for shipping updates. Do not promise a customer an App Store listing before it is accepted, and do not invent compliance, security or data-retention claims on the submission — ask. ## 9. Webhooks Attio has two ways to create webhooks, and only one works for a multi-customer connector. - **API-created (what you want).** `POST /v2/webhooks`, scope **`webhook:read-write`**. Attio: "Creating webhooks over the API is essential for those building integrations for Attio that will operate for many customers." It is also the only way to use filters. - **Settings-page-created.** Note Attio's own caveat: "webhooks created with tokens that were created through our OAuth sign up flow will not be shown in the developer settings page." So do not expect to eyeball a customer's OAuth-created webhooks in a UI. The operational rules that cost the most when missed: - **Verify `Attio-Signature`** (duplicated as `X-Attio-Signature` for legacy middleware): hex HMAC-SHA256 of the **raw request body**, interpreted as UTF-8, using the webhook secret returned when the webhook was created. Restringifying parsed JSON changes the bytes and breaks the signature. Attio recommends signature verification **over IP allowlisting**, though it does publish a fixed egress IP list for restrictive firewalls. - **HTTPS only.** HTTP targets are refused. - **Respond 2xx within 5 seconds.** Anything else retries up to **10 times with exponential backoff over roughly 3 days**, after which the webhook is marked **degraded** and Attio emails you. - **At-least-once delivery.** Deduplicate on the `Idempotency-Key` header, which is stable across retries. - **Subscriptions must be unique** per (target URL, event type, filter) within a workspace, or the request is rejected `409 uniqueness_conflict`. Key order and operation order are ignored when comparing filters, and a `null` filter equals an empty `$and`. This bites when re-subscribing a customer without tearing the old one down. - **Delivery is rate limited to 25 requests/second per target URL.** A single shared callback host across many customers is a shared budget. - **Filters** are `$and`/`$or` over `field` (dot notation, e.g. `id.object_id`, `parent_object_id`, `actor.type`), `operator` (`equals` / `not_equals`) and `value`. Filters are only editable and viewable over the API. - **Event coverage worth knowing:** `record.created` / `.updated` / `.deleted` / **`.merged`**, `note.created` / `.updated` / `.deleted` and a separate **`note-content.updated`** (Attio notes that body updates do *not* fire `note.updated`), `task.*`, `comment.*`, `list.*`, `list-entry.*`, `list-attribute.*`, `object-attribute.*`, `workspace-member.created`, `call-recording.created`. - **V1 webhook events are deprecated** (`entry.created`, `entry-attribute.updated`, `entry.deleted`) in favour of the `list-entry.*` V2 equivalents with different payload shapes. > **As of 2026-09-20, this platform's Attio connector** registers webhooks through the API, filtered by object ID, > and keeps the returned webhook ID so it can delete the subscription later. Two things to raise with its owner: > it does not record a `webhook` scope among its requirements even though `POST /v2/webhooks` needs > `webhook:read-write`, and there is no sign that it verifies the `Attio-Signature` header on inbound deliveries. > Its recorded rate-limit note also says 100 requests/second across the whole API, which predates both the 25/s > write limit and the 10/s cap on listing notes. ## 10. Verify end-to-end Testing in the workspace that owns the app proves little — you are an admin there and the schema is yours. Test the path a customer takes: 1. Authorize from a **second, unrelated workspace**, through your platform's real connect flow, as that workspace's **admin**. Note the unapproved-app warning if the app is not published (§8) and confirm your product's copy prepares customers for it. 2. Confirm the token response really is just `access_token` and `token_type`, and that nothing in your client is waiting for an `expires_in` or a `refresh_token` that will never arrive. 3. Call `GET /v2/self`. Confirm `active: true`, and check `scope` — it is a **space-separated list of exactly what the app was configured with**. This is the fastest way to prove the Scopes tab matches reality. 4. Call `GET /v2/objects` and the attributes endpoint for one object. **If this 403s, `object_configuration:read` is missing** — and every record read will fail the same way (§5). 5. Read records from the second workspace. Then have that workspace add a **custom attribute** and confirm the connector neither crashes nor silently drops it (§6). 6. If the workspace has no deals object, confirm the connector reports that clearly rather than failing opaquely. 7. Exercise one write and confirm the `:read-write` scope is actually ticked — read and write are separate strings. 8. If webhooks are on: register one, confirm it appears via `GET /v2/webhooks`, verify a signature against the **raw** body, and re-run subscription to confirm you get a clean result rather than a `409`. 9. **Revoke** from the customer side, or via `POST /oauth/revoke`, and confirm your platform treats the resulting 401 as "reconnect", not "refresh". 10. Drive a realistic sync and watch for `429`s — especially on notes (10/s) and on writes (25/s). | Symptom | Cause | | --- | --- | | Authorization succeeds, then every record list fails with a permission error | `object_configuration:read` not ticked — it is required alongside `record_permission:read` (§5) | | Reads work, writes fail | `:read-write` not ticked; there is no implicit upgrade from `:read` (§5) | | List entries fail although records work | Lists need `list_configuration` + `list_entry`, a separate pair (§5) | | `POST /v2/webhooks` refused | `webhook:read-write` missing from the app's scopes (§9) | | Tasks or notes fail despite `task:read` / `note:read` | Those endpoints also require `object_configuration:read`, `record_permission:read`, and for tasks `user_management:read` (§5) | | Changing the requested scopes in client code has no effect | Attio has no `scope` parameter — scopes come from the app's Scopes tab (§5) | | Token exchange fails right after consent | `redirect_uri` not byte-identical to a registered URI, or omitted from the exchange (§4) | | Authorize URL rejected before consent | `redirect_uri` does not exactly match a registered URI, or `response_type` is not `code` (§4) | | Consent screen shows a scary "not approved by Attio" warning | The app is unpublished — expected, and not a blocker to connecting (§8) | | A non-admin customer cannot complete the flow | Installing apps and integrations is admin-only, and the token is workspace-scoped (§8) | | Token stops working with no warning; refresh attempts fail | Revoked or uninstalled. Tokens never expire, so 401 means gone — reconnect, do not refresh (§7) | | Client waits forever for `expires_in` / `refresh_token` | Neither is in the documented token response (§7) | | Custom fields a customer can see are missing from the sync | Attributes are per-workspace; discover them instead of hardcoding (§6) | | "Deals is not enabled on this workspace" | Deals, users and workspaces are optional objects (§6) | | `409 uniqueness_conflict` when subscribing | Same (target URL, event type, filter) already registered; tear down first (§9) | | Webhook signature never matches | Signature computed over restringified JSON instead of the raw UTF-8 body (§9) | | Webhooks stopped and an email arrived | Repeated non-2xx or >5s responses marked the webhook degraded after ~3 days of retries (§9) | | Note edits never arrive | Body changes fire `note-content.updated`, not `note.updated` (§9) | | Duplicate webhook deliveries | At-least-once delivery; deduplicate on `Idempotency-Key` (§9) | | 429 on a modest sync | Writes are 25/s, listing notes is capped at 10/s, and record-query scores are summed across *all* apps and tokens in that workspace (§ Platform state) | | An old doc link lands on a generic docs homepage | `developers.attio.com/docs/*` 308-redirects to `docs.attio.com/` (§ Platform state) | ## 11. Hand off — never commit the secret - **Do not** write the client secret, an access token, a webhook secret or an API key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Attio says the same in its own tutorial: access tokens "are highly sensitive data" and production apps must encrypt them before storing. Values go to the user, for their secret store or console. - If a code change is needed (a redirect host, form-encoding the exchange, sending `redirect_uri`, treating 401 as reconnect rather than refresh, signature verification), keep it secret-free and say what the human must set out of band. - Close with: app name and slug, the Attio workspace and account that own it, the client ID, where the secret was delivered, the authorize/token/revoke endpoints, **the exact list of scope strings ticked on the Scopes tab**, the redirect URIs registered, whether webhooks are enabled and under which scope, whether the app is published or still showing the unapproved warning, and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the user actually has **one** workspace and an API key is the right answer (that run produces no OAuth credentials at all); the scope selection needs to change on a live app, because Attio does not document whether already-issued tokens pick up new scopes; `private_collection:*` is on the table, because it reaches collections the authorizing admin's own permissions would not; a customer's admin must be the one to install and only a member is available; the App Store submission asks for compliance, security, data-retention or volume claims, or needs a demo video; the connector must handle customer-defined custom objects or attributes and nobody has decided what "supported" means; or the Developer console does not match the **Platform state** section above. ## References - Developer platform overview — https://docs.attio.com/docs/overview - Developer console (create and manage apps) — https://build.attio.com - Connect an app to Attio through OAuth (the worked flow) — https://docs.attio.com/rest-api/tutorials/connect-an-app-through-oauth - OAuth authorize endpoint — https://docs.attio.com/docs/oauth/authorize - OAuth token endpoint — https://docs.attio.com/docs/oauth/token - OAuth introspect endpoint — https://docs.attio.com/docs/oauth/introspect - OAuth revoke endpoint — https://docs.attio.com/docs/oauth/revoke - Identify the current token (`GET /v2/self`) — https://docs.attio.com/rest-api/endpoint-reference/meta/identify - Authenticating requests (OAuth vs API key, scopes) — https://docs.attio.com/rest-api/guides/authentication - REST API overview — https://docs.attio.com/rest-api/overview - OpenAPI specification (authoritative scope catalogue and per-endpoint scopes) — https://api.attio.com/openapi/api - How to access the OpenAPI spec — https://docs.attio.com/rest-api/endpoint-reference/openapi - Objects and lists (the data model) — https://docs.attio.com/docs/objects-and-lists - Users and workspaces (workspace members, workspace-scoped access) — https://docs.attio.com/docs/users-and-workspaces - Slugs and IDs — https://docs.attio.com/docs/slugs-and-ids - Attribute types — https://docs.attio.com/rest-api/attribute-types/attribute-types - List objects (required scopes) — https://docs.attio.com/rest-api/endpoint-reference/objects/list-objects - List attributes (objects vs lists scopes) — https://docs.attio.com/rest-api/endpoint-reference/attributes/list-attributes - List records (required scopes) — https://docs.attio.com/rest-api/endpoint-reference/records/list-records - Create a webhook — https://docs.attio.com/rest-api/endpoint-reference/webhooks/create-a-webhook - Configuring webhooks (signing, retries, filters, egress IPs) — https://docs.attio.com/rest-api/guides/webhooks - Handling rate limits — https://docs.attio.com/rest-api/guides/rate-limiting - Paginating API results — https://docs.attio.com/rest-api/guides/pagination - Filtering and sorting — https://docs.attio.com/rest-api/guides/filtering-and-sorting - The publication lifecycle (develop, share privately, publish) — https://docs.attio.com/share/the-publication-lifecycle - Private invites — https://docs.attio.com/share/private-invites - Publishing to the App Store (review, ~1 week) — https://docs.attio.com/share/publishing-to-the-app-store - App listing requirements — https://docs.attio.com/share/app-listings - Logo requirements — https://docs.attio.com/share/logos - Listing image requirements — https://docs.attio.com/share/listing-images - Shipping updates (no review for REST API apps) — https://docs.attio.com/share/shipping-updates - Code review requirements (App SDK only) — https://docs.attio.com/share/code-review - REST API changelog — https://docs.attio.com/changelog/rest-api - Documentation index (every page, machine readable) — https://docs.attio.com/llms.txt - Help: manage members, admins and teams (admin-only capabilities) — https://attio.com/help/reference/workspace-settings-billing/manage-members-and-admins - Help: generate an API key (single-workspace alternative) — https://attio.com/help/reference/apps/generating-an-api-key - Help: manage standard objects — https://attio.com/help/reference/workspace/objects - Help: apps in Attio — https://attio.com/help/academy/introduction/apps - Public App Store — https://attio.com/apps - Attio developer platform (marketing overview) — https://attio.com/platform/developers ## bamboohr-oauth-app URL: https://docs.unified.to/skills/bamboohr-oauth-app/SKILL.md # BambooHR API Credentials **Read this before you start: BambooHR has two credential models, and the one you need may not be OAuth.** 1. **Per-user API key + company subdomain, over HTTP Basic auth.** This is what most BambooHR integrations run on today. It needs no relationship with BambooHR at all: each customer generates a key inside their own account and hands it to you with their subdomain. It is still fully documented and most endpoints accept it. 2. **OAuth2 authorization code, with a client ID and secret from the BambooHR Developer Portal.** Historically this was partner-gated — you had to contact BambooHR. **Since 2025-04-15 developers can self-register** at `https://developers.bamboohr.com`, register an organization, create an application, pick scopes, and get a client ID and secret without a partnership. What remains gated is *production* standing: the Developer Terms let BambooHR require review and approval before granting production access, and a **Marketplace listing plus a sandbox account still require the Marketplace Partner Program** (which has a 100-customer bar and a security-audit requirement). So: you can almost certainly get OAuth credentials by yourself. You cannot get a sandbox, a listing, or a guarantee of production standing by yourself. Decide which of those you actually need before doing anything (§1). Three things about this platform are expensive to get wrong, whichever model you pick. **Every host is per-customer** — authorize, token and API all live on `https://{companyDomain}.bamboohr.com`, so your connect flow must collect the subdomain *before* it can even build an authorize URL. **The credential inherits the permissions of the user behind it**, so a key or token from a limited user returns `200` with partial data rather than an error — the single most misdiagnosed BambooHR problem (§8). And **BambooHR answers some list endpoints with `404` when credentials are bad**, so "not found" is routinely an auth failure wearing a disguise (§10). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Which credential model** | OAuth app, per-customer API keys, or both (§1) | | **New app or edit to an existing one** | A new client ID re-authorizes every existing OAuth connection (§1) | | **Developer Portal account owner** | Email and organization name; who accepts the Developer Terms (§2) | | **App name, description, logo** | Shown to the customer's user during authorization (§3) | | **Redirect URIs** | Every callback host you serve, exact strings (§4) | | **Scope set** | Which objects the connector reads and writes (§5) | | **Test BambooHR account** | Which account to develop against; the Terms cap you at two (§2) | | **Customer subdomain(s)** | Needed for any real authorize or API call (§7) | | **Are you pursuing a Marketplace listing?** | Business decision — do not assume either way (§9) | ## Quick Start 1. Pick the credential model, and confirm whether a **new** OAuth app is really needed (§1). 2. Self-register at the Developer Portal and create/claim a test BambooHR account (§2). 3. Register the application: organization → app → scopes → redirect URIs (§3). 4. Add **every** redirect URI, exactly (§4). 5. Select scopes from BambooHR's published catalog — include `openid` and `offline_access` (§5). 6. Check what the connector on your side actually expects before you commit to a scope set (§6). 7. Capture client ID and secret; for the API-key path, walk the customer through key creation instead (§7). 8. Work out which **user** the credential belongs to — this decides what data you get (§8). 9. Check rate limits, production review and the listing gate (§9), then verify end to end (§10) and hand off (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) Dates below are BambooHR's own changelog entries. If the portal or docs do not look like this, stop and report what you actually see rather than clicking on. - **2025-03-31 — OAuth 2.0 became the primary documented auth method**, replacing legacy API-key generation via `oidcLogin`. - **2025-04-14 — new applications can no longer use `oidcLogin`** (the OpenID Connect → `id_token` → BambooHR API key flow). Existing apps may keep using it with the `legacy.login` scope. If you are reading an older runbook that exchanges an ID token plus an "Application Key" for a user's API key, that path is closed to you. - **2025-04-15 — Developer Portal self-registration opened.** Register your organization, create an application, select scopes, obtain client ID and secret. Before this you had to contact BambooHR directly. - **2025-05-12 — `POST /api/v1/login` marked deprecated**, OAuth 2.0 recommended. No sunset date has been set, and BambooHR states that a deprecated label alone does not announce a cutoff. - **2025-07-03 — API routing centralized** to `https://{companyDomain}.bamboohr.com/api/`, replacing `https://api.bamboohr.com/api/gateway.php/{companyDomain}/`. The old structure still works; new work uses the new one. - **2026-02-18 — send credentials on every request.** BambooHR still returns `401` with `WWW-Authenticate: Basic realm="..."` when credentials are absent; clients that wait for that challenge double their round trips and burn rate-limit budget. That header will not be included in future API versions. - **2026-09-16 (four days ago) — rate-limited requests now return `429` with `Retry-After`, not `503`.** The limits themselves did not change. `503` now means genuine unavailability. Note the collision this creates: `429` was already documented as "the account has reached its employee limit" (§10). - **API keys are not deprecated.** Endpoint documentation added through 2026 keeps saying "supports both Basic Auth and OAuth 2.0". Treat Basic auth as a first-class path, not a legacy one. - **The scope catalog is published.** The OpenAPI security scheme behind the API reference carries **220 named scopes** (counted 2026-09-20), with `/authorize.php` and `/token.php` as relative authorize and token URLs — they are relative because the host is the customer's. ## 1. Decide: which model, and reuse or register new **Choose the credential model first.** - **API key + subdomain** — no BambooHR relationship, no review, no app. The customer does the work; you do onboarding UX. The costs are all operational: keys are tied to a human user and die when that user is disabled (§8). - **OAuth2** — better security story, granular scopes, refresh tokens, and the model BambooHR steers new integrations toward. Costs: a Developer Portal app, a redirect-URI registration, scope selection, and exposure to the production review clause in the Developer Terms. - **Both** is common and usually right for a multi-tenant connector: OAuth for new customers, API keys for those who will not run an OAuth flow. **Then decide reuse vs new.** A new OAuth app means a **new client ID**, and every existing OAuth connection is bound to the old one — every customer would re-authorize. Reuse the existing app for: adding a redirect URI, adding a scope, rotating a compromised secret, or diagnosing a failure. Register a new app only when the user explicitly wants one (a replacement for a compromised app, a separate product, a deliberate migration off `oidcLogin`). Say which path you are taking before you touch the portal. ## 2. Accounts: Developer Portal and something to test against **Developer Portal** — `https://developers.bamboohr.com/login`. Free account, self-registration open since 2025-04-15. Use of the portal is subject to the BambooHR Developer Terms of Service, which the account owner accepts. **Something to develop against.** BambooHR's own getting-started list includes "create a test BambooHR account to develop against". The Developer Terms constrain this sharply, and these are the constraints most likely to trip up automated testing: - **No more than two test accounts.** - **Fake or dummy data only** — no customer integration data in a test account. - **Test accounts must be used manually.** No scripts or bots to generate or drive them. A free-trial account (`https://www.bamboohr.com/signup/`) is the usual way to get one. A *sandbox* in BambooHR's sense is different: it is provisioned once you are accepted into the Marketplace Partner Program (§9). **Human-only steps.** Email verification, MFA, accepting the Developer Terms, signing the Marketplace Agreement, submitting a W-9/W-8 — hand these back rather than looping on them. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact ordered click path with the literal values to paste — the redirect URIs from §4 and the scope list from §5 — and continue once they report back with the client ID. ## 3. Register the application In the Developer Portal: register your **organization**, then create an **application**, **select the scopes** it needs, and **register its redirect URIs**. That sequence is BambooHR's own description of self-registration; the exact field labels are behind a portal login and are not published, so **report what the form actually shows** rather than asserting the shape of it. If the portal asks for something this skill does not mention — an environment toggle, a review submission, a company-verification step — write it down and add it here afterwards. The portal is also where redirect URIs are managed after creation, per BambooHR's Marketplace requirements. ## 4. Redirect URIs Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes that cost a cycle each: - **Exact match.** BambooHR's docs are explicit: "even small differences (such as an extra slash or capitalization change) can cause authentication errors." The same string must appear in the authorize request *and* the token exchange, where `redirect_uri` is a required parameter for both `authorization_code` and `refresh_token` grants. - **The redirect URI is yours and constant; the authorize host is the customer's.** The authorize host `https://{companyDomain}.bamboohr.com/authorize.php` changes per customer, the callback does not. One serves every tenant; carry the tenant in `state`, which is exactly how BambooHR's own partner OIDC guidance says to do it. - **Whether the portal accepts several redirect URIs per app is not documented publicly.** BambooHR's partner guidance talks about whitelisting URIs (plural) and routing tenants via `state`, but confirm in the portal. If an app accepts only one, that is a finding to report — a separate app per data center, or a single shared callback host, is an architecture decision, not something to improvise. ## 5. Scopes Scopes are **space-delimited**, which in the authorize URL means `+`-joined: ``` https://{companyDomain}.bamboohr.com/authorize.php?request=authorize&state=&response_type=code &scope=openid+offline_access+employee+employee:job&client_id=&redirect_uri= ``` Two are structural rather than data-bearing: | Scope | Why | | --- | --- | | `openid` | "Allows the application to authenticate with BambooHR" | | `offline_access` | **The refresh token is only returned if `offline_access` was in the original authorize request.** Add it later and every already-authorized customer is still without a refresh token. | The rest are data scopes, `.write` suffixed for mutation. A sample of the ones an HRIS/ATS connector reaches for, with BambooHR's own descriptions: | Scope | Covers | | --- | --- | | `employee` / `employee.write` | All non-sensitive employee information | | `employee:job`, `employee:compensation`, `employee:contact`, `employee:name`, `employee:custom_fields`, … | Per-field-group employee access, each with a `.write` twin | | `sensitive_employee:address`, `sensitive_employee:protected_info`, `sensitive_employee:creditcards` | Sensitive employee data, separated deliberately | | `time_off` / `time_off.write`, `time_off:requests`, `time_off:policies`, `time_off:categories` | Time off | | `time_tracking` (+ `:timesheets`, `:configurations`, `:employees`, `:kiosks`, `:time_clocks`, …) | Hours worked and time-tracking administration | | `scheduling:shifts`, `scheduling:schedules` | Shifts within schedules | | `hiring:applications`, `hiring:job_openings`, `hiring:offers`, `hiring:talent_pools` | ATS | | `company:info`, `company:details`, `company_file` | Company data and files | | `report`, `benefit`, `holidays`, `training`, `goal`, `user`, `webhooks` | Reports, benefits, holidays, training, goals, users, webhooks | Three traps here: - **There is no single scopes page in the guides.** The catalog lives in the OpenAPI security scheme behind the API reference (220 scopes on 2026-09-20) and in the portal's scope picker; individual endpoint pages and changelog entries name the scope each operation needs. Read the endpoint you actually call. - **Naming is not uniform across families.** The catalog uses `employee:job`, while BambooHR's own List Employees announcement names `employees.read`, `employees:name.read` and `employees:job.read`. Do not extrapolate a scope string from a sibling — look it up on the endpoint page, and test it. - **`email` is documented for SSO, not for the API.** BambooHR's OpenID Connect SSO guide uses `openid email company_id`, but `email` and `company_id` are **not** in the API scope catalog. If your authorize request carries `email`, verify it is accepted rather than assuming it is harmless. Ask for what the connector calls and no more. Scopes cap what a token may request; they never exceed what the authorizing user may do (§8). ## 6. What Unified.to's BambooHR connector expects **As of 2026-09-20, Unified.to's BambooHR connector supports both credential models.** Treat this as a dated observation and **confirm it with the connector's owner before registering anything** — connector configuration changes more often than a vendor portal does. - **Both auth types are configured**: a token/Basic-auth connection built from an **API key plus the company subdomain** (the key is sent as the Basic username with a throwaway password), and an **OAuth2 authorization-code** connection. - **The subdomain is collected from the customer as a first-class credential**, alongside the key or the OAuth credentials, because every host is per-company. The OAuth configuration is explicitly marked as needing that subdomain, and also as needing a **developer/application key** in addition to client ID and secret — worth clarifying with the owner, since the developer-key requirement belongs to the retired `oidcLogin` flow (§Platform state) rather than to current OAuth2. - **Hosts** are `https://{subdomain}.bamboohr.com/api` for the API, `https://{subdomain}.bamboohr.com/authorize.php` for authorization and `https://{subdomain}.bamboohr.com/token.php` for the token exchange — the per-company form, not the retired `api.bamboohr.com/api/gateway.php/...` gateway. - **Every authorization requests `openid`, `email` and `offline_access`**, plus per-object scopes: the `hiring:*` family for ATS objects, `company_file` for documents, the `employee:*` and `sensitive_employee:*` families plus `report` for employees, `time_off` and `user` for time off, `time_tracking` and `scheduling:shifts` for attendance and shifts, `company:info` for locations, and `benefit` for benefits. Note `email` against the §5 warning, and note that at least one time-tracking scope was recorded as inferred by analogy rather than read off BambooHR's catalog. - **BambooHR's sandbox is recorded as not self-serve**, gated on acceptance into the Marketplace Partner Program. - **The connector translates certain `404`s into auth errors**, because BambooHR returns `404` rather than `401` on several list endpoints when credentials are invalid. That is a real platform behaviour, not a local workaround (§10). ## 7. Capture the credentials ### OAuth app From the Developer Portal application: **client ID** and **client secret**. Assume the secret is shown once and capture it immediately; if the portal lets you re-read it, note that here. Record alongside it: - Authorize URL: `https://{companyDomain}.bamboohr.com/authorize.php?request=authorize` - Token URL: `https://{companyDomain}.bamboohr.com/token.php?request=token` - The exact scope string you registered, and the exact redirect URIs The token response looks like this — `expires_in` is **3600 seconds**, and `refresh_token` appears only if `offline_access` was requested: ``` { "access_token": "...", "expires_in": 3600, "token_type": "Bearer", "scope": "", "refresh_token": "...", "id_token": "...", "companyDomain": "" } ``` Refreshing posts `grant_type=refresh_token` with `client_id`, `client_secret`, `refresh_token` **and `redirect_uri`** to the same token URL. BambooHR's documentation shows both exchanges as a JSON-looking body without naming a content type; if the exchange is rejected without a useful message, try the other encoding before assuming the credentials are wrong (§10). Rotating the secret invalidates the old one: existing access tokens live out their hour, but every exchange and refresh fails until the new secret is deployed. Never rotate without explicit go-ahead and a cutover plan. ### Per-customer API key This is the path the customer walks, so give them the steps rather than doing it for them: 1. Sign in to BambooHR and open the **user context menu** from their name. BambooHR's docs say lower-left; some UI versions put it upper-right. Admins also reach key management at **Settings → Account → API Keys**. 2. Choose **API Keys**. The option only appears if the user has sufficient permission. 3. Create a key and **name it** — naming is required, and an unnamed pile of 160-bit hex strings is unmanageable. 4. **Copy it immediately.** Since 2018, keys are encrypted after creation and cannot be retrieved again; a lost key must be replaced. 5. Send you the key **and the company subdomain** — the text before `.bamboohr.com` in their login URL. The key is used as the HTTP Basic **username**, with any string as the password: ``` curl -i -u "{API Key}:x" "https://{companyDomain}.bamboohr.com/api/v1/employees/directory" ``` Account Owners and Admins can see every key connected to their account — creator, name, created and last-used date — and can **disable** keys (reversibly, in bulk) or delete them. Disabling is the safe first move during an incident; deletion is permanent. ## 8. Permissions: what the credential can actually see **This is the section that prevents the classic misdiagnosis.** BambooHR authenticates and permissions every API request "as if a real user were using the software". The permissions of the user behind the credential determine which employees and which fields the request may read or edit. Scopes sit *on top of* that: a scope can narrow access, never widen it. The failure mode is quiet. A key created by a department manager returns `200 OK` with the employees that manager can see — a subset — and omits field groups they cannot view. Nothing errors. The integration looks like it is syncing; it is syncing a fraction of the company, and the gap surfaces weeks later as "your sync is missing people". What to do about it: - Have the customer create the key (or authorize the OAuth flow) as a **user whose access level covers everything the integration must read**, ideally a dedicated service user rather than a person who will change roles or leave. - Say plainly that a key **dies when its user is disabled or deleted**, and that admins can disable or delete any key in the account. Offboarding an employee is a common cause of a connection that "suddenly stopped". - Know the documented carve-outs. Since 2025-03-10, only **admin** users can retrieve user email addresses from `/v1/meta/users/`. Permissioned webhooks are likewise tied to the access level of the user who created them. - When data looks partial, **compare against what that user sees in the BambooHR UI** before touching code. If the UI shows the same subset, the integration is correct and the permission grant is the bug. ## 9. Rate limits, production review, sandbox and the Marketplace **Rate limits.** BambooHR publishes no numeric limits and reserves the right in the Developer Terms to impose or adjust them at its discretion. What is documented: - Requests deemed too frequent are throttled. Since **2026-09-16** that is `429` with a **`Retry-After`** header; before then it was `503`. Honour `Retry-After`. - Repeated use of an **unknown API key** disables API access for a period, returning `403` while the block lasts. The user can still log in to the website, which makes this easy to misread as a permissions problem. - Requests sent without credentials cost an extra round trip and consume budget — send credentials preemptively. **Production review.** The Developer Terms state BambooHR may require new integrations, or material changes to existing ones, to be submitted for review and approval **before granting or restoring production access**, and may approve or reject at its sole discretion. Self-registration gets you credentials; it does not exempt you from this. **Sandbox and Marketplace listing** are the same gate. The Marketplace Partner Program requires, per BambooHR's published requirements: executing the Marketplace Agreement, a W-9 (or W-8), a **third-party security audit** (SOC 2 Type 1 or 2, ISO 27001, FedRAMP or HITRUST) or a security questionnaire, a Developer Portal account, and listing assets (documentation, logo, 4–6 product images, a help guide, a demo URL). The public application page also asks for **at least 100 total customers**, participation in Crossbeam, and a support-response commitment. **A sandbox account is provided once you are accepted.** Everything in that paragraph is a business decision — eligibility, security attestations, agreements, customer-count claims. Do not answer any of it on the user's behalf; hand it back (§Stop and ask). ## 10. Verify end-to-end Authorizing against your own test account proves less than it looks. Test the customer path: 1. Start from the **subdomain prompt** — confirm your connect flow collects it before building the authorize URL, and that a wrong subdomain fails visibly rather than silently. 2. Run a full authorize → callback → token exchange against a real company domain. 3. Confirm the response carries a **`refresh_token`** (it will not, without `offline_access`), then force a refresh and confirm the refreshed token still works. 4. Make one **read call per scope family** you registered, and compare the result against what the authorizing user sees in the BambooHR UI (§8). 5. For the API-key path, run the same read calls with `-u "{API Key}:x"` and the customer's subdomain. | Symptom | Cause | | --- | --- | | `404` on a list endpoint that should have data | Very often bad or insufficient credentials — BambooHR returns `404` here instead of `401` (§6). Check the credential before hunting for the resource. | | `401 Unauthorized` | No credentials sent. Send them preemptively rather than waiting for the `WWW-Authenticate` challenge. | | `403 Forbidden` on some endpoints, `200` on others | The user's access level, not the scope set (§8). | | `403` on everything, website login still fine | API access temporarily disabled after repeated use of an unknown API key (§9). Wait it out; fix the key. | | `429` **with** `Retry-After` | Rate limited (since 2026-09-16). Back off for the stated interval. | | `429` **without** `Retry-After` | Account employee limit reached — a completely different problem. Check the header before retrying. | | `503` | Genuine unavailability. Since 2026-09-16 it no longer means rate limiting. | | Token response has no `refresh_token` | `offline_access` was missing from the **original** authorize request. Re-authorize; a later scope change does not retrofit it. | | Redirect/`redirect_uri` error | Not an exact match with the registered URI — trailing slash, case, scheme — or omitted from the token exchange (§4). | | Authorize page 404s or shows the wrong company | Wrong `companyDomain` in the host. | | Token exchange rejected with no useful message | Try the other body encoding before assuming the secret is wrong (§7). | | Sync returns a subset of employees or missing fields | Permission inheritance (§8), not a mapping bug. | | Connection worked for months, then stopped for one customer | Their key's user was disabled, or an admin deleted/disabled the key (§7, §8). | ## 11. Hand off — never commit the key or the secret - **Do not** write a client secret or a customer API key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - **Do not paste a live key into a support email.** BambooHR's API support page asks for the call you made and your key/token details; send the endpoint, parameters and timestamps, and redact the credential. - If a code change is needed (a new callback host, a scope, subdomain handling), keep it credential-free and say what the human must set out of band. - Close with: which credential model; app name and Developer Portal organization; client ID; where the secret was delivered; the authorize/token URL shapes including the `{companyDomain}` placeholder; the exact scope string; the redirect URIs registered; which user the test credential belongs to and what access level it has; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the work requires a sandbox, a Marketplace listing, or anything from the partner application (security attestations, customer counts, agreements, W-9, Crossbeam) — these are business and legal decisions; BambooHR asks you to submit the integration for production review; the Developer Portal allows only one redirect URI per app and your platform needs four; a customer must create the credential under a higher access level or a dedicated service user; a scope you need is absent from the published catalog (including `email`) and behaviour must be tested rather than assumed; the connector's own expectations (§6) do not match what the portal offers; or the portal and docs do not match the **Platform state** section above. ## References Verified 2026-09-20 — every URL below returned HTTP 200. - Getting Started With The API (OAuth flow, API keys, Basic auth) — https://documentation.bamboohr.com/docs/getting-started - Technical Overview (status codes, throttling, `X-BambooHR-Error-Message`) — https://documentation.bamboohr.com/docs/api-details - Historical Changes to the API (OAuth 2.0, `oidcLogin`, self-registration, routing) — https://documentation.bamboohr.com/docs/past-changes-to-the-api - Planned Changes to the API (503 → 429, effective 2026-09-16) — https://documentation.bamboohr.com/docs/planned-changes-to-the-api - API reference — carries the OAuth2 security scheme and its scope catalog — https://documentation.bamboohr.com/reference/list-employees - API Support — https://documentation.bamboohr.com/docs/api-support - Permissioned Webhooks (access-level inheritance) — https://documentation.bamboohr.com/docs/permissioned-webhooks - Single Sign-On (SSO) with OpenID Connect — https://documentation.bamboohr.com/page/single-sign-on-sso-with-openid-connect - Using OpenID Connect to Authenticate and Retrieve an API Key (the retired `oidcLogin` path) — https://documentation.bamboohr.com/page/authenticate-integration - BambooHR Developer Portal — https://developers.bamboohr.com/login - Developer Terms of Service (production review, test-account limits, rate limits) — https://www.bamboohr.com/legal/developer-terms-of-service - Marketplace Program Requirements — https://www.bamboohr.com/partner-programs/marketplace-requirements - Marketplace Application (eligibility bar) — https://www.bamboohr.com/partner-programs/apps-marketplace-application - Partner Programs overview — https://www.bamboohr.com/partner-programs/overview - Admin API Keys Settings — https://www.bamboohr.com/product-updates/admin-api-keys-settings - API Key Management Enhancements (disable vs delete, last-used) — https://www.bamboohr.com/product-updates/api-key-management-enhancements - Create a New API Key (customer-facing help article) — https://help.bamboohr.com/s/article/587919 - API Error Code Definitions (customer-facing help article) — https://help.bamboohr.com/s/article/587843 - BambooHR free-trial signup (test account) — https://www.bamboohr.com/signup/ ## bitbucket-oauth-app URL: https://docs.unified.to/skills/bitbucket-oauth-app/SKILL.md # Bitbucket Cloud OAuth 2.0 Consumer Registration Get a working Bitbucket Cloud OAuth2 client for a platform that connects many customers' Bitbucket workspaces. A successful run produces: an OAuth consumer created inside a workspace you control, one callback URL per consumer, a ticked permission set, and a **Key** and **Secret** pair. **Read this first: Bitbucket Cloud is an Atlassian product, but its OAuth is not Atlassian 3LO.** There is no developer console app, no `auth.atlassian.com`, no `audience` parameter, no `offline_access` scope, no cloudid, and no `/ex/{product}/{cloudid}/…` addressing. The consumer is created in **workspace settings**, the endpoints are on `bitbucket.org`, and the API is `https://api.bitbucket.org/2.0`. If you arrived here from an Atlassian-console skill, put it down — nothing in it applies except the company name. See §1. Four things are expensive to get wrong here, and three of them are invisible at registration time: 1. **Scopes are checkboxes on the consumer, not parameters on the authorize URL.** Consent is all-or-nothing: every customer grants the consumer's whole permission set, and changing the set changes what every future authorization grants (§6). 2. **One consumer takes one callback URL, and a `redirect_uri` sent in a request must sit *under* it.** Four callback hosts means four consumers, four Key/Secret pairs (§5). 3. **Access tokens are short — one to two hours — and refresh tokens rotate and idle out after three months.** A client that does not persist the rotated refresh token passes its smoke test and fails days later (§8). 4. **The consumer lives in a workspace, so every admin of that workspace can read its Secret** — and deleting the consumer instantly kills every customer token issued from it (§3, §4). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Which workspace will own the consumer** | It is a workspace object, not a personal app (§3) | | **Consumer name** and description | Name must be unique within the account; shown on the consent dialog | | **Callback URL** — exactly one per consumer | Blocking. See §5 before promising anything | | **Permission set** to tick | Becomes the scope set for every customer (§6) | | **Private consumer or not** | Decides whether `client_credentials` is available (§9) | | **Does the client persist rotated refresh tokens?** | Blocking — see §8 | | **Is a user-delegated token actually the right credential?** | Access tokens and API tokens are real alternatives (§10) | | **New consumer, or an edit to an existing one** | A new Key orphans every existing customer connection (§2) | | **Cloud or Data Center** | This document is Cloud only | ## Quick Start 1. Confirm this is Bitbucket **Cloud**, and that an OAuth consumer — not an access token — is the right credential (§1, §10). 2. Confirm a **new** consumer is needed; an edit keeps every existing customer connected (§2). 3. Sign in as an admin of the workspace that should own the consumer (§3). 4. **Settings cog → Workspace settings → Apps and features → OAuth consumers → Add consumer** (§4). 5. Enter the name, description and the **single Callback URL**; decide the private-consumer setting (§4, §5). 6. Tick the **Permissions** the connector actually needs — this is the scope set (§6). 7. **Save**, then expand the consumer row to reveal the generated **Key** and **Secret** (§7). 8. Confirm the client stores the **rotated** refresh token on every refresh (§8). 9. Verify with a real authorize → callback → API read → double refresh on a different account (§12). 10. Hand the credentials over; never commit them (§13). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Consumers are created in workspace settings**, not a developer console: avatar → pick the workspace → **Settings cog → Workspace settings → Apps and features → OAuth consumers → Add consumer**. Atlassian's support docs add: *"If you're giving access to a workspace, make sure you have administrative access."* - **The endpoints are on `bitbucket.org`**: `https://bitbucket.org/site/oauth2/authorize` and `https://bitbucket.org/site/oauth2/access_token`. The API base is `https://api.bitbucket.org/2.0`. - **As of May 4th, 2026, all OAuth 2.0 authenticated API requests must be directed at `https://api.bitbucket.org`** with the token as a Bearer token, per Atlassian's authentication reference. A client still calling the API through `bitbucket.org` is on borrowed time. - **Three grant flows plus a custom JWT flow.** Authorization Code (4.1), Client Credentials (4.4), and the Bitbucket JWT grant `urn:bitbucket:oauth2:jwt` for Connect add-ons. **Resource Owner Password Credentials (4.3) is no longer supported.** - **Implicit grant: the two Atlassian pages disagree.** The developer reference says *"Note that Implicit Grant (4.2) and Resource Owner Password Credentials Grant (4.3) are no longer supported"*; the support article still documents implicit grant as a working flow. The developer reference is the newer page (it carries the May 2026 endpoint change). Do not build on implicit grant either way. - **Access token lifetime: the same two pages disagree.** The developer reference says *"Our access tokens expire in one hour"*; the support article says *"Our access tokens expire in two hours."* **Read `expires_in` from the token response and never hardcode either number** (§8). - **Refresh tokens rotate and idle out at three months.** *"Once a refresh token has been used to issue a new access token, a new refresh token will be issued as part of the response. The existing refresh token will expire shortly after and should not be used again."* And: *"A refresh token which is not used to generate an access token will expire after 3 months."* - **The scope parameter is not how you ask for scopes.** *"Scopes are defined on the client/consumer instance. Bitbucket Cloud does not currently support the use of the optional scope parameter on the individual grant requests."* (§6). - **App passwords are gone or going.** Atlassian's authentication reference now says flatly: *"App passwords are deprecated. Use API tokens."* Per the changelog, new app passwords could no longer be created from **9 September 2025**, brownouts run from **9 June 2026**, and app passwords are **fully deprecated on 28 July 2026** — during brownouts, API requests fail with **401** and Git-over-HTTPS with **410**. **API tokens** are the replacement: Atlassian account → Security → *Create and manage API tokens* → *Create API token with scopes*, select Bitbucket as the app, **expiry required, maximum one year**, used as Basic auth (Atlassian email + token) or, since **18 August 2026**, as a Bearer token. - **The native Bitbucket issue tracker is removed.** Per the changelog entry of **20 August 2026**, *"the API endpoints that support Issue Tracker are now fully removed."* The `issue` and `issue:write` scopes still exist in the scope list but no longer have endpoints behind them on Bitbucket's own tracker. - **There is no cross-workspace `GET /workspaces` collection any more.** Atlassian's Workspaces reference lists `GET /user/workspaces` and `GET /workspaces/{workspace}`; the cross-workspace listing is deprecated. Anything resolving "which workspaces can this token see" must use `/user/workspaces`. - **No review, approval, install cap or marketplace gate is documented for a plain OAuth consumer.** The only lifecycle operations Atlassian documents are create, reveal Key/Secret, and delete. That is genuinely simpler than most portals — and it also means nothing stops a customer's admin deleting the consumer (§4). - **Rate limits are counted per user, not per app**, and the scaled-limit tier is explicitly *not* available to OAuth (§11). If the workspace settings pages do not look like this, stop and report what you actually see rather than clicking on. ## 1. This is not the Atlassian Developer Console — confirm you are in the right place A reader coming from Jira or Confluence will expect an Atlassian 3LO app. Bitbucket Cloud does not use one. The differences are total, not cosmetic: | | **Atlassian 3LO (Jira, Confluence)** | **Bitbucket Cloud OAuth 2.0** | | --- | --- | --- | | Where the app lives | Atlassian Developer Console, owned by an Atlassian account | **Workspace settings → OAuth consumers**, owned by a workspace | | Authorize / token host | `auth.atlassian.com` | **`bitbucket.org/site/oauth2/…`** | | API host | `api.atlassian.com/ex/{product}/{cloudid}` | **`api.bitbucket.org/2.0`** — no cloudid, no indirection | | Asking for scopes | `scope` on the authorize URL | **Checkboxes on the consumer**; the request cannot widen them | | Refresh token opt-in | `offline_access` scope | None — refresh tokens come with the authorization_code grant | | Extra required authorize params | `audience`, `prompt=consent` | None documented | | Two-legged app token | Not part of 3LO | **`client_credentials`**, for private consumers (§9) | Two Atlassian app platforms *do* sit over Bitbucket and are not this: **Atlassian Connect** (heading for end of support — the changelog has been steadily removing Connect APIs through 2026) and **Forge**, which has its own scope family spelled `read:repository:bitbucket`, `write:pullrequest:bitbucket` and so on. Those Forge/API-token scopes are a different list with different rules — Atlassian states that *"Forge app and API token scopes do not implicitly grant access to other scopes, for example, write:repository:bitbucket does not implicitly grant access to read:repository:bitbucket"*, which is the opposite of how OAuth 2.0 scopes behave here (§6). **Never copy a scope string from one family into the other.** **Bitbucket Data Center is a different product for this purpose.** It has its own OAuth 2.0 provider, configured inside the customer's own instance, with endpoints on the customer's host. If the customer's URL is not `bitbucket.org`, you are in the wrong document. ## 2. Reuse the existing consumer, or register a new one A new consumer means a **new Key, and every stored token is bound to the old one** — every customer re-authorizes. Worse, if you delete the old consumer to tidy up, you revoke them immediately (§4). **Edit the existing consumer** for: changing the permission set (read §6 first — it is not free either), rotating a compromised secret, correcting the callback URL, or diagnosing a failure. **Register a new consumer** only when the user asks for one: a second callback host that the one-URL rule forces into its own consumer (§5), a replacement for a compromised consumer, or a move to a different owning workspace. Say which path you are taking before you start clicking. ## 3. Account, workspace, and who can read the secret The consumer is **a workspace object**. That has three consequences worth stating out loud before you create it: - **You need workspace admin rights** on the owning workspace to reach the OAuth consumers page at all. - **Anyone who administers that workspace can read the Secret.** Atlassian's own instructions are: *"Toggle the consumer name to see the generated Key and Secret value for your consumer."* There is no show-once screen and no separate reveal permission. **Do not create the production consumer in a shared customer-facing workspace, or in an individual's personal workspace.** Use a workspace whose admin list is the set of people you would hand the secret to anyway, and treat the admin list as part of the credential's blast radius. - **Anyone who administers that workspace can delete the consumer**, and Atlassian is explicit that deleting *"also removes all existing tokens linked to that consumer"* — i.e. instantly disconnects every customer. A free Bitbucket account and workspace are enough to create a consumer and to test end-to-end. **Workspace access tokens are a Premium feature** (§10) — if the plan matters, it matters there, not here. Hand control back to the user for anything only a human can do: Atlassian account signup, email verification, 2SV, accepting terms, creating a workspace. Do not retry a blocked step in a loop. **If this session has no browser automation** — the usual case for a CLI or cloud run — do not pretend to click. Give the user the exact ordered click path from §4 with the literal callback URL (§5) and the literal permission ticks (§6), then continue once they report back with the Key. ## 4. Create the consumer 1. Select your avatar, then open the **workspace** that will own the consumer. 2. **Settings cog** (top navigation) → **Workspace settings**. 3. Sidebar → **Apps and features** → **OAuth consumers** → **Add consumer**. 4. Fill in the form: - **Name** — *"The display name for your consumer. This must be unique within your account. This is required."* It is what customers see on the grant dialog, so use the product name, not a ticket number. - **Description** — optional. - **Callback URL** — *"Required for OAuth 2.0 consumers."* One field. See §5; this is the field that decides your architecture. - **URL** — optional, informational ("where the curious can go to learn more"). - **This is a private consumer** — a checkbox. Tick it for a server-side connector that keeps its secret on a server. Atlassian's Pipelines guide instructs *"Select the This is a private consumer checkbox"* as the prerequisite for the `client_credentials` flow (§9), and a consumer marked public cannot use that grant. The public setting is for a distributed application that **ships its credentials inside the app**, which a multi-tenant server-side connector is not. Atlassian's published docs describe this checkbox only in passing, so read the in-product help text next to it before deciding, and record what it said. - **Permissions** — the checkboxes that become the scope set (§6). 5. **Save.** The system generates the Key and Secret (§7). **Deleting a consumer** (Workspace settings → OAuth consumers → the row's menu → Delete) *"also removes all existing tokens linked to that consumer."* There is no soft delete and no grace period. If the reason for deleting is a compromised credential, Atlassian points at its app security incident management guidance for Marketplace Partners rather than a self-service rotation flow — so plan a rotation as a deploy-then-delete, never a delete-then-panic. ## 5. The callback URL — you get one field, and it is a prefix `redirect_uri` handling here is unusual and catches people who assume GitHub-style exact matching against a list. Atlassian documents two rules: - *"If you don't include the URL in the request we redirect to the callback URL in the consumer."* - *"If you do include the URL in a request it must be appended to the same URL configured in the consumer. So if your consumer callback URL is `example.com/add-on` the URL in your request must be something similar to `example.com/add-on/function`."* So the registered value is a **prefix**, and a per-request `redirect_uri` may only extend it with a deeper path. It cannot change the host. That settles the multi-region question: **different callback hosts cannot share one consumer.** For Unified.to the callbacks are one per data center; confirm the current list with the platform owner rather than assuming, because data centers get added: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Four hosts, four consumers, four Key/Secret pairs — and the credential store must be able to hold them separately. Say so explicitly in your handoff. (There is one alternative, and it is a platform change rather than a console setting: a single callback host that fans out internally. Do not assume it exists.) Other notes: - Send `redirect_uri` explicitly on the authorize request anyway, and send the identical value on the token exchange. Relying on the consumer's default is what makes a region mix-up silent instead of loud. - A mismatch surfaces at connect time, not at save time. - The callback is your platform's URL. Nothing about it varies per customer or per workspace. ## 6. Permissions are checkboxes — consent is all-or-nothing **This is the single biggest conceptual difference from every other portal in this collection.** Bitbucket states it twice: > *"Scopes are defined on the client/consumer instance. Bitbucket Cloud does not currently support the use of the > optional scope parameter on the individual grant requests."* > > *"When the scope parameter is provided, Bitbucket will validate that it contains no scopes that were not already > present on the client/consumer and fail if additional scopes are requested, but asking for fewer scopes will not > affect the resulting access token."* Read that second sentence carefully, because it has three separate consequences: 1. **Sending `scope` can only fail you, never help you.** A scope on the authorize URL that is not ticked on the consumer **fails the request**. A subset succeeds — and is ignored. 2. **Every token carries the consumer's entire permission set.** There is no per-customer, per-object or least-privilege authorization. A customer who only wants repository reads still grants everything ticked. 3. **Changing the checkboxes changes the deal for everyone.** Every subsequent authorization carries the new set, and a connector that adds a permission is asking all future customers for more. Treat a permission change as a customer-visible decision, not a config tweak — and re-run the consent dialog yourself to see what it now says. The consumer form groups permissions per resource, with read/write ticks inside each group — Atlassian's Pipelines walkthrough instructs *"Under Permissions, go to Repositories. Tick both Read and Write."* Its older support walkthrough describes the coarse shape as **Email** (*"Permission to read your account's primary email address"*), **Read**, **Write** and **Admin** across account, workspace membership and repositories. The live form is finer-grained than that older page and maps onto the scope strings below; **read the actual checkboxes in the UI and map them to the table** rather than trusting either description. Tick what the connector calls, and nothing more. ### The scope strings, and what implies what These are the OAuth 2.0 scope strings Bitbucket publishes. The implication rules are the non-obvious part: some scopes grant others for free, and — importantly — **the admin scopes do not**. | Scope | Grants | Implies | | --- | --- | --- | | `repository` | Read a repo: source, clone over HTTPS, file-browsing API, zip archives | — | | `repository:write` | Push over HTTPS, fork | **implies `repository`** | | `repository:admin` | Repo admin features only — deploy keys, permissions, branch permissions, delete the repo | **does *not* imply `repository` or `repository:write`** | | `repository:delete` | Delete repositories | — | | `pullrequest` | See, list and comment on PRs; create/resolve tasks | **implies `repository`** (read on the destination repo) | | `pullrequest:write` | Create, merge, decline, approve PRs | **implies `pullrequest` and `repository:write`** | | `project` | View projects | **implies `repository`** across the project's repos | | `project:write` | **Deprecated**, superseded by `project:admin` | — | | `project:admin` | Create, update, delete projects | **does *not* imply `project` or `repository:write`** | | `issue` | View, create, comment, watch, vote on issues | nothing, and **no implicit repo access** | | `issue:write` | Transition and delete issues | **implies `issue`**, still no repo access | | `wiki` | Wikis, read *and* write — *"no distinction is made"* | nothing | | `snippet` / `snippet:write` | Read / create, edit, delete snippets | `snippet:write` **implies `snippet`** | | `webhook` | Any webhook operation; **reads existing subscriptions on anything the token can reach, without the resource's own scope** | — | | `email` | The user's primary email address | — | | `account` | Read account info; on workspace APIs, view users, permissions and projects | — | | `account:write` | Change account properties — **including delete the authorizing user's account** | — | | `pipeline`, `pipeline:write`, `pipeline:variable` | Read pipelines / control runs / create variables | — | | `runner`, `runner:write` | Read / manage pipeline runners | — | Two traps in that table: - **The admin scopes are not supersets.** `repository:admin` gives you branch permissions and deletion but not the source; a connector that needs to read code *and* administer must tick both. Atlassian notes the sharp edge: `repository:admin` *"can be used or misused to grant read access to other users, who can then clone the repo"* — so it is a heavier ask than it looks on the dialog. - **`webhook` is quietly broad on reads.** It *"gives read access to existing webhook subscriptions on all resources the authorization mechanism can access, without needing further scopes."* Creating a webhook, though, needs the resource scope too — `issue:created` needs `webhook` **and** `issue`. ### Scopes never exceed the user's own permissions Atlassian puts it bluntly: *"Anything you could do when logged into Bitbucket, your application can do also. So for example, if they have read/write access to all of the workspace, your application does as well."* That cuts both ways, and both directions produce support tickets: - A token with `repository` sees **only the repositories the authorizing user can see** — so two customers on the same consumer get wildly different views, and "the connector is missing repos" is usually an account-permissions question, not a scope question. - Conversely there is **no repository picker**. Unlike an installation-style model, the customer cannot narrow the grant to three repositories. Whatever the user can reach, the consumer can reach. Customer security reviews notice this; have an answer ready, and note that **access tokens (§10) are the per-repository answer** when a customer insists. ## 7. Capture the credentials From **Workspace settings → OAuth consumers**, expand the consumer row: *"Toggle the consumer name to see the generated Key and Secret value for your consumer."* - **Key** — the OAuth `client_id`. - **Secret** — the OAuth `client_secret`. Re-readable by any workspace admin, forever (§3). - **Authorization endpoint** — `https://bitbucket.org/site/oauth2/authorize` - **Token endpoint** — `https://bitbucket.org/site/oauth2/access_token` - **API base** — `https://api.bitbucket.org/2.0`, Bearer token in the `Authorization` header (required since 4 May 2026). - Which data center this consumer's single callback serves (§5). The authorize request Atlassian documents is minimal: ``` https://bitbucket.org/site/oauth2/authorize?client_id={client_id}&response_type=code ``` No `audience`, no `prompt`, no `offline_access`. Add `state` — Bitbucket does not require it and does not document it, but you need it, and you need to cope if it does not come back (see the symptom table in §12). The exchange passes the client credentials as **HTTP Basic**, not in the body: ``` curl -X POST -u "client_id:secret" \ https://bitbucket.org/site/oauth2/access_token \ -d grant_type=authorization_code -d code={code} ``` **PKCE is not mentioned anywhere in Atlassian's Bitbucket Cloud OAuth documentation** — not in the developer reference, not in the support article. Do not assume `code_challenge` is honoured, and do not rely on it as a security control here; the confidential-client path (private consumer + Basic auth secret) is the documented one. Rotating the Secret invalidates the old one for new exchanges, so deploy the new value before you rely on it. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 8. Token lifetimes and rotating refresh tokens | Thing | Value | Source | | --- | --- | --- | | Access token lifetime | **One hour** per the developer reference, **two hours** per the support article — **read `expires_in`** | Both Atlassian pages, in conflict | | Expired access token | 401 responses | Both | | Refresh token issued | With `authorization_code` and with each `refresh_token` response | Developer reference | | Refresh token behavior | **Rotating** — *"a new refresh token will be issued… The existing refresh token will expire shortly after and should not be used again"* | Developer reference | | Unused refresh token | **Expires after 3 months**, then the user must redo the full authorization_code flow | Developer reference | | Absolute maximum lifetime | Not documented | — | The refresh call, with credentials again as Basic auth: ``` curl -X POST -u "client_id:secret" \ https://bitbucket.org/site/oauth2/access_token \ -d grant_type=refresh_token -d refresh_token={refresh_token} ``` **The failure mode that passes your smoke test.** A client that does not store the *new* refresh token refreshes successfully the first time — it still holds a valid token — and fails the second time, hours later. So ask two questions, and note a platform can answer yes to the first and no to the second: 1. Does the client read `refresh_token` out of the refresh response at all? 2. Does it **persist** it, atomically, before the next refresh, including under concurrent refreshes of the same connection? Note the phrase *"will expire shortly after"* — the old token is not killed instantly, which gives a racing client a small undocumented window of apparent success. Do not design around it. The quieter killer is **idleness**: a connection nobody refreshes for three months is dead, and the customer must re-authorize from scratch. With a one-to-two-hour access token, any live connector refreshes constantly — but *paused*, *disabled* and *trial* connections do not, and those are exactly the ones that break on resumption. ## 9. `client_credentials` — the two-legged flow, and what it actually reaches Bitbucket supports the Client Credentials grant (RFC 6749 §4.4): ``` curl -X POST -u "client_id:secret" \ https://bitbucket.org/site/oauth2/access_token \ -d grant_type=client_credentials ``` What it gives you is specific: a token that *"represents not an end user, but the owner of the client/consumer"* — that is, the workspace the consumer lives in, as the consumer's owner sees it. Practically: - **It is useless for multi-tenant customer access.** It cannot reach a customer's workspace; it reaches yours. It is the right tool for your own automation against your own workspace (CI pushing back to a repo is Atlassian's own worked example), and the wrong tool for a connector. - **The consumer must be marked private.** A consumer marked public is rejected for this grant; Atlassian's worked example ticks *"This is a private consumer"* as a prerequisite. - It inherits the same ceiling as everything else: the consumer's ticked permissions, no more. - Atlassian's example passes `-d scopes="repository"` alongside the grant. Given §6, treat that as cosmetic — the consumer's checkboxes decide the token. If someone proposes `client_credentials` as a way to avoid asking customers to authorize, that is a misunderstanding worth correcting on the spot. ## 10. The alternatives: access tokens, API tokens, and the app-password removal An OAuth consumer is not always the right credential. Bitbucket offers three non-OAuth options and one dead one, and the differences are about *what the credential is attached to*: - **Repository / Project / Workspace access tokens.** *"Access tokens are linked to a repository, project, or workspace, not a user account."* They survive the employee who created them leaving, they are the only way to hand a customer a genuinely repository-scoped credential, and they are the only credential that qualifies for scaled rate limits (§11). Key properties: created by an admin of that resource with a chosen scope set; **cannot be viewed or edited after creation** (replace, don't recover); can carry an expiry, and workspaces can be made to *require* one; can be **rotated** (refresh the secret and expiry without redefining scopes); appear in the UI as a pseudo-user named after the token; and are **deactivated when the resource is deleted** (repository tokens also die when the repo is transferred). Their scope lists are narrower than OAuth's — workspace tokens get `project`/`project:admin`, `repository*`, `pullrequest*`, `webhook`, `account`, `pipeline*`, `runner*`; repo tokens drop the project scopes. They cannot manage permissions, cannot log into the website, and a few deploy-key endpoints are closed to them. **Workspace access tokens are a Premium feature.** - **API tokens** (Atlassian-account level): *"personal access tokens that users can create to authenticate with Bitbucket's REST APIs or interact with Git… designed as a long term replacement for app passwords."* Basic auth with the Atlassian email as username, or Bearer since 18 August 2026. **Expiry is mandatory, maximum one year**, scopes are chosen at creation and cannot be edited afterwards. Tied to an individual, so they inherit the offboarding problem OAuth also has. - **App passwords: deprecated, and being removed.** No new ones since 9 September 2025; brownouts from 9 June 2026 (401 on the API, 410 on Git-over-HTTPS); fully removed 28 July 2026. If a platform still offers "username + app password" as a connection method, that is a migration item with a deadline, not a supported alternative. **Choose the OAuth consumer when** the platform must act *as each customer's user*, across whatever that user can see, with a browser consent step — the normal multi-tenant connector. **Point a customer at an access token when** they refuse a workspace-wide user-delegated grant and want the credential pinned to one repository or workspace, or when what you are connecting is really CI automation rather than a person. ## 11. Rate limits — counted per user, and OAuth cannot buy more Atlassian's request-limits page is explicit about the counter: *"Unauthenticated calls: measured against a specific IP address. Authenticated calls: measured against the user ID."* So a connector's budget is **per authorizing customer user**, which is friendlier than a per-app cap — until one customer runs a heavy sync. | Limit | Value | | --- | --- | | Anonymous | **60 requests/hour** across all API resources | | Repository data (`/2.0/repositories/*`) | **1,000–10,000 per hour** (1,000 default; scaled tier below) | | Git operations (HTTPS and SSH) | 60,000/hour | | Raw file downloads | 5,000/hour | | Archive (.zip/.gz) downloads | 1,500 files/hour | | Webhook list/add/remove | 1,000/hour | | Application properties | 2,000/hour | | Invitations | 100/minute | The window is *"a one-hour rolling window"*, not a fixed reset. **The scaled tier is closed to OAuth.** Qualifying for more than 1,000/hour on repository data requires a Standard or Premium plan, 100+ paid users in the workspace, **and** that you *"must use workspace, project, repository access tokens for authentication or make requests through programmatic access via a Forge app."* The formula is `1,000 + (paid users − 100) × 10`, capped at **10,000/hour**. A user-delegated OAuth token does not qualify, at any customer size. If a large customer is throttling, the answer is a change of credential type (§10), not a support ticket — say that plainly rather than promising headroom you cannot get. When the scaled limits apply, Bitbucket returns `X-RateLimit-Limit` (total permitted per hour — *not* remaining), `X-RateLimit-Resource`, and `X-RateLimit-NearLimit` (boolean, true under 20% remaining). Those headers are only documented for scaled-limit requests, so an OAuth client cannot count on seeing them and must handle throttling from the status code alone. ## Product state — Unified.to (verified 2026-09-20; confirm with the connector's owner) Dated observation of how the Bitbucket connector behaves today, not contract: - **It is an OAuth 2.0 consumer integration** against `https://bitbucket.org/site/oauth2/authorize` and `.../access_token`, with the API at `https://api.bitbucket.org/2.0` — the correct hosts, including the post-May-2026 API host requirement. It also offers a non-OAuth option taking an **email address plus an API token**, which is the right successor to app passwords (§10). - **It sends `scope` on the authorize URL**, space-delimited, built per object type from a map: `repository` (+ `repository:write`, `repository:delete`) for repository, branch, commit and file objects; `pullrequest` (+ `pullrequest:write`); `issue`/`issue:write` for task objects; `project`/`project:admin`; and `account`/`account:write`/`email` for the people and organization objects. `account` and `email` are the login scopes. Given §6, this only matters in one direction: **any scope in that map that is not ticked on the consumer will fail the authorize request**, so the consumer must be ticked for the union of everything the workspace might enable — and every customer's token then carries that union regardless. - **It exchanges the code with the client credentials as HTTP Basic**, which matches Atlassian's documented `-u "client_id:secret"` form, and uses the standard refresh grant. **No PKCE parameters are sent** — consistent with Atlassian documenting none (§7). - **It flags Bitbucket as not returning `state`** on the callback and compensates internally. Worth knowing when debugging a connect flow, and worth confirming against current Bitbucket behaviour rather than assuming. - **It picks the customer's workspace automatically at connect time by taking the first entry of a cross-workspace workspace listing.** Two problems, both real (§12): the cross-workspace collection endpoint is the deprecated one — the connector's own most recent read test recorded an error naming that deprecation — and "first workspace" is arbitrary for any customer who belongs to more than one. Flag both to the connector's owner. - **Its task objects target the native Bitbucket issue tracker**, whose API endpoints Atlassian recorded as fully removed on 20 August 2026. Those objects, and the `issue`/`issue:write` scopes backing them, should be expected to fail; confirm before ticking those permissions on a new consumer. - **Platform-shared OAuth credentials appear to be configured for this connector**, meaning workspaces may not each need their own Key and Secret — confirm which mode is live before registering anything, because it changes whether this run is needed at all. ## 12. Verify end-to-end Authorizing as the workspace admin who owns the consumer proves almost nothing — that account can see everything anyway. Test the path a customer takes. 1. Run a real **authorize → callback** round trip through the platform's connect flow, from a **different** Bitbucket account in a **different** workspace, sending `redirect_uri` explicitly. 2. Read the **consent dialog** as that customer would and check it against §6 — it shows the consumer's whole permission set, and that is what you are actually asking every customer for. 3. Confirm the token response's **`expires_in`** and that a **`refresh_token`** is present. 4. Make one real read against **`https://api.bitbucket.org/2.0/…`** with a Bearer header. 5. Check what the token can *see*: list the user's workspaces via `/user/workspaces` and confirm the connector picks the intended one rather than the first (§10, Product state). 6. Force a **refresh**, then force a **second refresh using the token the first returned**. This is the step that catches a client ignoring rotation, and it is the step most often skipped. 7. If the consumer will serve more than one region, repeat against each region's consumer — they are separate credentials with separate callbacks (§5). | Symptom | Cause | | --- | --- | | Authorize request rejected over scopes | A scope in the request is not ticked on the consumer — the request cannot widen the set (§6) | | Requested fewer scopes, token came back with more | Expected: *"asking for fewer scopes will not affect the resulting access token"* (§6) | | Redirect rejected / lands somewhere unexpected | `redirect_uri` is not the registered callback or a deeper path under it; a different host never matches (§5) | | EU/AU customers land on the US callback | Wrong consumer's Key in play, or `redirect_uri` omitted so the consumer default was used (§5) | | `unauthorized_client` / invalid client credentials | Wrong Key/Secret pair, or credentials sent in the body instead of HTTP Basic; check for stray whitespace or newlines in the stored values (§7) | | `client_credentials` rejected | The consumer is not marked private (§9) | | Everything 401s after about an hour | Normal expiry — read `expires_in`, do not hardcode (§8) | | First refresh works, later ones fail | Rotated refresh token not persisted (§8) | | Connection dead after a quiet few months | Three-month idle refresh-token expiry; the customer must re-authorize (§8) | | Customer sees fewer repos than expected | The token is bounded by the authorizing user's own access — a permissions question, not a scope one (§6) | | Customer wants only some repositories connected | Not possible with an OAuth consumer; access tokens are the per-resource credential (§10) | | Repo admin calls fail although `repository:admin` is ticked | `repository:admin` does not imply `repository`/`repository:write` — tick them too (§6) | | Webhook creation fails although `webhook` is ticked | Creating a subscription also needs the resource's own scope (§6) | | Issue-tracker calls 404 | The native issue tracker API was removed on 20 Aug 2026 (Platform state) | | Cross-workspace workspace listing errors | The cross-workspace collection is deprecated; use `/user/workspaces` (Platform state) | | Large customer constantly throttled | OAuth cannot reach the scaled tier — it needs an access token or Forge (§11) | | Auth suddenly broke for every customer at once | The consumer was deleted (revokes all its tokens) or the Secret was rotated without a deploy (§4, §7) | | Git/API auth broke for a customer using "username + app password" | App-password brownout/removal — 401 on API, 410 on Git (Platform state) | | Nothing resembles this document; you are on `auth.atlassian.com` | That is Atlassian 3LO, not Bitbucket Cloud (§1) | | The instance is not `bitbucket.org` | Bitbucket Data Center — different product, different OAuth provider (§1) | ## 13. Hand off — never commit the secret - **Do not** write the consumer Secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or the console. - Remember the Secret is **permanently re-readable by every admin of the owning workspace** (§3). If that admin list is wrong, fixing it is part of this job, not a follow-up. - If a code change is needed (a callback host, a permission, workspace selection), keep it credential-free and say what the human must set out of band. - Close with: consumer name and the **owning workspace**; the **Key**; where the Secret was delivered; the authorize/token endpoints and API base; the single callback URL and which data center it serves, plus how many further consumers the remaining regions need (§5); the exact permission set ticked and the fact that every customer grants all of it (§6); whether the consumer is marked private; the observed `expires_in`; the state of rotated-refresh-token persistence and who owns any remaining work; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the platform needs more callback hosts than one consumer allows and nobody has decided how many consumers to create (§5); the workspace that would own the consumer has an admin list broader than the people who should hold the secret (§3); a permission change would alter what every future customer grants (§6); the client does not persist rotated refresh tokens — report it, do not register and hope (§8); someone proposes `client_credentials` as a way to reach customers' workspaces (§9); a customer's security review requires per-repository scoping that an OAuth consumer cannot give (§6, §10); a customer is still on app passwords and the removal date is close (§10); a large customer needs rate-limit headroom OAuth cannot reach (§11); the target is Bitbucket Data Center rather than Cloud (§1); or the workspace settings pages do not match the **Platform state** section above. **No browser automation:** if the session cannot drive a browser, do not simulate clicks or claim a step was done. Produce the exact click path from §4 and the literal values for the user, and resume when they report back. ## References Atlassian-owned pages only. Every link verified to return HTTP 200 on 2026-09-20. - Authentication methods — OAuth 2.0, grants, token and refresh lifetimes, access tokens, API tokens, the full scope list — https://developer.atlassian.com/cloud/bitbucket/oauth-2/ - Bitbucket Cloud REST API scopes (per-scope detail and implication rules) — https://developer.atlassian.com/cloud/bitbucket/bitbucket-cloud-rest-api-scopes/ - The Bitbucket Cloud REST API (intro, base URL) — https://developer.atlassian.com/cloud/bitbucket/rest/intro/ - Bitbucket Cloud changelog (app-password brownouts, issue-tracker removal, Bearer for API tokens) — https://developer.atlassian.com/cloud/bitbucket/changelog/ - Workspaces REST reference (`/user/workspaces`, `/workspaces/{workspace}`) — https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/ - Use OAuth on Bitbucket Cloud (create a consumer, callback rules, delete a consumer) — https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/ - Integrate another application through OAuth (the click path, the Permissions checkboxes) — https://support.atlassian.com/bitbucket-cloud/docs/integrate-another-application-through-oauth/ - OAuth consumer examples — https://support.atlassian.com/bitbucket-cloud/docs/oauth-consumer-examples/ - Push back to your repository (private-consumer checkbox, `client_credentials` worked example) — https://support.atlassian.com/bitbucket-cloud/docs/push-back-to-your-repository/ - API request limits (per-user counting, scaled tier, rate-limit headers) — https://support.atlassian.com/bitbucket-cloud/docs/api-request-limits/ - Access tokens for a workspace (Premium; features and limitations) — https://support.atlassian.com/bitbucket-cloud/docs/workspace-access-tokens/ - Access tokens for a project — https://support.atlassian.com/bitbucket-cloud/docs/project-access-tokens/ - Access tokens for a repository — https://support.atlassian.com/bitbucket-cloud/docs/repository-access-tokens/ - Rotate an access token for a workspace — https://support.atlassian.com/bitbucket-cloud/docs/rotate-an-access-token-for-a-workspace/ - Require access token expiry — https://support.atlassian.com/bitbucket-cloud/docs/require-access-token-expiry/ - API tokens — https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/ - Bitbucket Cloud transitions to API tokens (app-password deprecation rationale and phases) — https://www.atlassian.com/blog/bitbucket/bitbucket-cloud-transitions-to-api-tokens-enhancing-security-with-app-password-deprecation - 'Invalid OAuth client credentials' troubleshooting — https://support.atlassian.com/bitbucket-cloud/kb/invalid-oauth-client-credentials-while-setting-up-autoscaler-for-runners-on-kubernetes/ - Grant access to a workspace (workspace membership, groups, and Read/Write/Admin permissions) — https://support.atlassian.com/bitbucket-cloud/docs/grant-access-to-a-workspace/ ## box-oauth-app URL: https://docs.unified.to/skills/box-oauth-app/SKILL.md # Box OAuth2 App Registration Get a working Box OAuth2 client — a developer account, a Platform App configured for **User Authentication (OAuth 2.0)**, redirect URIs, scopes, client ID and client secret — for a platform that connects many customers' Box accounts on their behalf. Three things are expensive to get wrong here, and only one of them is in the app form. First, **the app type is permanent**: a User app cannot be converted to a Server app, or the reverse, so picking the wrong authentication method means starting over with a new client ID. Second, **a customer's own Box admin may have to authorize your app inside their Admin Console before any user in that enterprise can connect** — this is the classic Box trap, it is customer-side, and no amount of re-registering fixes it (§4). Third, **Box refresh tokens are single-use**: every refresh returns a *new* refresh token and invalidates the old one, so a client that stores the original will work exactly once and then break (§8). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, description, contact email | Shown to users on the Box consent screen | | **Which Box account owns the app** | A free developer account, or an existing Box enterprise (§2) | | **Is the owning account an enterprise?** | Decides whether you can self-authorize or must ask an admin (§4) | | **Redirect URIs** | Every callback host your platform serves (§5) | | **Scope set** | What the connector actually calls (§6) | | **Does the client store the rotated refresh token on every refresh?** | Blocking — see §8 | | **New app, or an edit to an existing one?** | Decides §1 | | **Will this be listed in Box Integrations?** | Separate review path (§9) | ## Quick Start 1. Confirm a **new app** is needed — existing customer connections are bound to the current client ID (§1). 2. Sign in to, or create, the Box account that will own the app (§2). 3. Developer Console → **New App** → app type **User** → **Create** (§3). 4. Understand who must authorize the app in each customer enterprise — do this before you promise a launch date (§4). 5. Add **every** redirect URI, exactly (§5). 6. Select the console scopes the connector actually calls, and no more (§6). 7. Copy the client ID and client secret from the Configuration tab (§7). 8. Confirm the client stores rotated refresh tokens and refreshes inside 60 days (§8). 9. Verify with a real authorize → callback → refresh → refresh-again round trip on a *second* account (§10). 10. Hand the credentials over — never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - Apps are created in the **Box Developer Console** at `https://app.box.com/developers/console`. The flow is **New App → choose app type → Create**. The two app types are **User** (users sign in with their own Box accounts — this is OAuth 2.0) and **Server** (backend services — Client Credentials Grant or JWT). - **A customer-authorizes-us connector needs a User app with OAuth 2.0.** CCG and JWT authenticate the *application* against a single enterprise using credentials you hold; they do not produce a per-customer consent flow, and they require an explicit Box Admin authorization in every enterprise before they work at all. Do not reach for them because the OAuth flow looks like more work. - **The choice is permanent.** Box's own wording: "You can't convert a User app into a Server app, or a Server app into a User app. To move between them, create a new app." - **Authorization differs by account type.** On a **free developer account**, apps are authorized automatically at creation. In an **enterprise**, JWT/CCG apps must be authorized by a Box Admin or Co-Admin, and **unpublished OAuth 2.0 apps also require enablement by an admin if the enterprise has "Disable unpublished apps by default" turned on**. See §4 — this applies to *your customers' enterprises*, not just yours. - Redirect URIs have required strict exact matching for OAuth 2.0 apps created since **29 November 2021**, and multiple URIs per app are supported. - Token lifetimes: authorization code **30 seconds**, access token **60 minutes**, refresh token **60 days or one use**, whichever comes first. - An enterprise can authorize up to **10,000** custom apps before it has to ask Box Support for an increase. - Listing in **Box Integrations** (the directory at `https://app.box.com/services`, formerly called App Center) is a separate Box Partner review, submitted from the app's **Publishing** tab, and it only accepts OAuth 2.0 apps (§9). If the Developer Console does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer connection is bound to the old one** — every customer would have to re-authorize, and in enterprises that gate apps, every customer's admin would have to authorize the new client ID too (§4). That is a migration project, not a config change. Reuse the existing app for: adding a redirect URI, adding a scope, rotating a compromised secret, or diagnosing an authorization failure. Note that **adding a scope is not free either** — changing an app's scopes requires re-authorization in every enterprise that authorized it (§4, §6). Register a **new** app only when the user explicitly wants one: a first-time setup, a replacement for a compromised app, a separate app for a different product, or a deliberate move off a Server app. Say which path you are taking before you touch the console. ## 2. Account: sign in or sign up - **Free developer account** — sign up at `https://account.box.com/signup/n/developer`. This is the simplest home for an integration's OAuth app when you do not already have a Box enterprise. Apps created here are authorized automatically, which conveniently hides the §4 problem from you and not from your customers. - **An existing Box enterprise** — normal for an established platform. You need Developer Console access; if you are not an Admin or Co-Admin you will have to submit the app for approval rather than self-authorize (§4). - **Developer sandbox** — a separate Box enterprise with its own Enterprise ID, created by an enterprise **admin or co-admin** from the Admin Console; up to ten per customer, no expiry, and it starts empty rather than inheriting parent settings. This is the right place to reproduce the enterprise authorization gate before a customer hits it. Sandbox API calls count against the normal allocation. Hand control back to the user for anything only a human can do: signup email verification, 2FA enrollment, accepting terms, or an admin action inside a Box Admin Console you do not control. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click and do not describe a screen you have not seen. Hand the user an exact, ordered click path with the literal values to paste (§5 redirect URIs, §6 scope list), then continue once they report back with the client ID. ## 3. Create the app and pick the authentication method 1. Log in to Box and open the **Developer Console** (`https://app.box.com/developers/console`). 2. Select **New App**. 3. Select **User** as the app type — this is the User Authentication (OAuth 2.0) path. Do **not** select **Server**. 4. Select **Create**. Box asks server apps to choose between Client Credentials Grant and JWT; a User app has no such choice, because OAuth 2.0 is the method. Reference of what you are declining: | Method | App type | What it is | Fits a customer-authorizes-us connector? | | --- | --- | --- | --- | | **OAuth 2.0** | User | Customer signs in to Box and grants your app access; token represents that user | **Yes** | | **Client Credentials Grant** | Server | App authenticates itself with client ID + secret; unattended; Service Account and App Users | No — no per-customer consent, needs admin authorization in every enterprise | | **JWT** | Server | Same as CCG but the app signs assertions with a keypair | No — same reason, plus key management | After creation, the settings screen exposes the fields that matter: **App Name**, **Description**, **Contact Email**, a **Purpose** selector (pick the one that describes an integration and say whether it is built by a customer or a partner — an admin reading an authorization request sees this), **Authentication Method**, **Application Scopes** (§6), **CORS Domains**, and **Advanced Features**. CORS domains only matter if browser JavaScript calls the Box API directly; a server-side connector can skip that section entirely. A **Developer Token** is generated automatically. It is a short-lived token for your own poking around — it is not part of the customer flow, and it is a credential. Do not paste it anywhere durable. ## 4. Enterprise app authorization — the thing that blocks customers This is the section that decides whether customers can actually connect, and almost none of it happens in your account. **The shape of it.** Box lets each enterprise decide whether *published* and *unpublished* platform apps are enabled by default for its users. Your app, until it is listed in Box Integrations (§9), is **unpublished**. If a customer's enterprise has **"Disable unpublished apps by default"** turned on, then a Box Admin or Co-Admin in *that* enterprise must explicitly enable your app by **client ID** before any of their users can complete the OAuth flow. Box's own authorization guide puts it plainly: unpublished OAuth 2.0 apps "may require enablement by a Box Admin or Co-Admin if they are inactive by default." **Who does it, and where.** The customer's admin, in *their* Admin Console: 1. Admin Console → **Platform** in the left navigation (`https://app.box.com/master/settings/custom`). 2. **Platform Apps** tab → **User Authentication Apps**. 3. **Add +** in the top-right, and paste **your app's client ID**. 4. Review the scopes the app requests, then enable it. (Or hover the app's row → **...** → authorize and enable.) So the only thing you can hand a customer is **the client ID and a plain-language justification of each scope**. Box shows the admin the scope names and nothing about your use case — the developer is expected to justify them. Write that justification once, keep it with the credentials, and give it to every customer whose admin asks. **Your own account.** In the Developer Console, the **Configuration** tab prompts an enterprise developer to submit the app for admin approval, and prompts an enterprise Admin/Co-Admin to authorize it directly. User-authentication apps also have an **Enablement** tab, which sends an approval request by email to the enterprise's Primary Admin; both the developer and the admin get an email with the decision. On a free developer account this is automatic — which is exactly why testing only there proves nothing about customers (§10). **Re-authorization after changes.** Authorization is a snapshot in time. **When an app's scopes or access level change, the app must be re-authorized for the change to take effect** — per enterprise. Free developer accounts re-authorize automatically on save; enterprise developers must re-submit; admins can re-authorize from Admin Console → Platform → the app's menu → **Reauthorize App**. Practically: adding a scope to a live connector means going back to every gated customer. Plan the scope set once (§6) rather than growing it. **Caps and known errors.** A new enterprise can authorize up to **10,000** custom apps; beyond that the customer contacts Box Support. Three errors show up on the admin side and are worth naming when you help a customer: *"Unable to retrieve service of enterprise app authorization"* means the pasted client ID is wrong or carries trailing characters; *"Something went wrong with adding the app authorization"* means the co-admin has view-but-not-edit rights on enterprise settings; *"Disabled by Administrator"* means the authorization requirements are not all satisfied. ## 5. Redirect URIs Set these under **OAuth 2.0 Redirect URI** on the app's **Configuration** tab. Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` The rules, exactly as Box enforces them: - **Exact match.** No subdirectory matching, no trailing-slash forgiveness. A mismatch shows the user a `redirect_uri_mismatch` error page instead of the grant screen. - **HTTPS required**, except plain HTTP for `localhost` or a loopback address. Localhost and loopback may redirect to any port, but scheme, domain, path and query parameters must still match a configured URI. - **Multiple URIs are allowed**, and duplicates cannot be saved. - **With more than one URI configured, the authorize URL must carry a `redirect_uri` parameter** matching one of them. Omit it and the user sees `redirect_uri_missing` — *after* granting access, so the consent looks like it worked and the connection still fails. A single-URI app can omit the parameter; a multi-data-center platform never can. Send it always. - Use the current authorization host, `https://account.box.com/api/oauth2/authorize`. Apps built before March 2016 used a legacy host that does not redirect Box Verified Enterprise customers to their own subdomain. ## 6. Scopes Scopes are chosen in the Developer Console under **Application Scopes**, not requested freely at runtime. The authorize URL's `scope` parameter is space-separated and **defaults to every scope configured for the app**; sending it can only narrow the set, never widen it. Anything not ticked in the console is unavailable no matter what the authorize URL asks for. Scopes cap a token; they never exceed what the authorizing user may do. An app with write scope does not get access to content the user cannot reach, and a call outside the app's scopes returns **403** even when the user owns the object. The self-service console scopes most relevant to a content connector: | Scope | Grants | | --- | --- | | `root_readonly` | Read all files and folders the authenticated user can reach | | `root_readwrite` | Read *and* write files and folders — upload, download, create folders, update collaborations, comments, tasks | | `manage_webhook` | Create and manage webhooks (limit 1000 webhooks per application, per user) | | `manage_enterprise_properties` | Enterprise event streams, enterprise attributes, reports, device pins | | `manage_managed_users` | Manage managed users — logins, password resets, role assignment | | `manage_groups` | Create, update, delete groups and memberships | | `manage_app_users` | Manage App User accounts — Box documents this as **server-side JWT applications only** | Other scopes exist (`manage_data_retention`, `sign_requests.readwrite`, `ai.readwrite`, `manage_triggers`, and request-only ones such as `manage_legal_holds` and `enterprise_content`). Do not tick anything the connector does not call: every scope is something a customer's admin reads before enabling the app, and `root_readwrite` plus user management is a combination security teams push back on. **Product fact — confirm before relying on it.** As of 2026-09-20, Unified.to's Box connector requests, by capability: `root_readwrite` for both reading and writing storage files; `root_readonly`, `root_readwrite`, `manage_enterprise_properties` and `manage_webhook` for webhook support; and `manage_app_users` for reading HRIS employees (carrying an open question about whether `manage_managed_users` is also needed). It authorizes against `https://account.box.com/api/oauth2/authorize` with a space-delimited `scope` parameter plus `client_id`, `redirect_uri`, `response_type` and `state`; exchanges and refreshes at `https://api.box.com/oauth2/token` as a form-encoded POST with `client_id` and `client_secret` in the body; calls the API at `https://api.box.com/2.0` with a bearer token; **does not use PKCE**; treats access tokens as ~60-minute and refresh tokens as just under 60 days; and ships with Unified.to-owned shared OAuth credentials available as a default. Two of these deserve a question rather than a copy-paste: requesting `root_readwrite` for read-only use removes the customer's option to grant read-only, and `manage_app_users` is documented by Box as a JWT-app scope. **Confirm the current scope set and these two points with the connector's owner before ticking boxes in the console.** ## 7. Capture the credentials On the app's **Configuration** tab, in the OAuth 2.0 credentials section, copy: - **Client ID** (the OAuth `client_id`) — also what every customer's admin needs in §4, so it is not a secret, but it is worth recording precisely. The Developer Console has a copy button; a hand-typed client ID with a trailing character is a real and common failure. - **Client secret** (`client_secret`). - Endpoints in play: authorize `https://account.box.com/api/oauth2/authorize`, token and refresh `https://api.box.com/oauth2/token`, API base `https://api.box.com/2.0`. - Whether the owning account is a free developer account, a sandbox, or a production enterprise — a sandbox is a *different* enterprise, so its client ID is a different app. Rotating the client secret invalidates the old one. Existing access tokens live out their hour, but every code exchange and every refresh fails until the new secret is deployed — and because Box refresh tokens are single-use (§8), a failed refresh window can burn connections rather than just pausing them. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 8. Token lifetimes and single-use refresh rotation | Thing | Lifetime | | --- | --- | | Authorization code | **30 seconds** — exchange it immediately | | Access token | **60 minutes** | | Refresh token | **60 days or one use**, whichever comes first | | Download URL | 15 minutes | **The rotation rule, in Box's words:** "Every time an application uses the Refresh Token to get a new Access Token the Refresh Token is invalidated and a new Refresh Token is returned with the new Access Token. This new Refresh Token is then again only valid for 1 use within 60 days." What that means for a connector storing credentials for thousands of customers: - **The refresh response's `refresh_token` must be persisted every single time.** A client that keeps the original refresh token succeeds once and fails forever after. This is the single most common way a Box connector rots. - **A dropped or un-persisted refresh response kills the connection.** If the new token is issued but the write fails, the stored token is already invalid. Persist before you discard the old one, and treat a failed persist as an incident, not a retry. - **Concurrent refreshes race.** Two workers refreshing the same connection at once means one of them holds a token that the other just invalidated. Serialize refreshes per connection. Box's docs describe no grace period for reuse. - **Idle connections die.** No refresh within 60 days and the customer must re-authorize. A connector that only refreshes on demand will quietly lose seasonal or low-traffic customers. - Box's current official SDKs handle renewal automatically. A hand-rolled HTTP client does not. - Access tokens can be revoked early via the revoke endpoint if you need to invalidate a connection deliberately. **Ask up front whether the client stores the rotated refresh token.** If the answer is no or unknown, say so plainly — the app will register fine and then fail in production. That is a finding to report, not a step to work around. ## 9. Rate limits, and publishing to Box Integrations **Rate limits** (per user unless stated), worth knowing before a backfill design review: - 1000 API requests per minute, per user - 240 file upload requests per minute, per user - 6 searches per second and 60 searches per minute, per user; 12 searches per second, per enterprise - Box Sign: 100 create/resend per minute, 1000 get per minute, per user Exceeding a limit returns **429 `rate_limit_exceeded`** with a `retry-after` header in seconds. Honour the header, then back off exponentially. Current Box SDKs retry 429s for you; direct HTTP clients and community SDKs do not. **Publishing is a separate path from authorization.** Listing your app in **Box Integrations** (`https://app.box.com/services`) is a Box Partner review, not a technical gate, and it is optional. It matters here for one reason: an enterprise that blocks *unpublished* apps by default (§4) may still allow *published* ones, so a listing can remove the per-customer admin step for some customers. It does not remove it for enterprises that gate published apps too. To submit: Developer Console → **My Platform Apps** → the app → **Publishing** tab → read the submission checklist and tick the confirmation → fill in the listing form → **Preview** → **Submit for Approval**. The Box Partner team is notified and reviews the request; questions go to `integrate@box.com`. An app can be unpublished from the same tab. Box's stated prerequisites: the app is finished and production-ready, **it uses OAuth 2.0** (Integrations support no other authentication method), and you have Developer Console access to it. Everything the listing form asks about positioning, support commitments, partner status or security posture is a business decision. Collect the answers from the user; do not compose them. ## 10. Verify end-to-end Testing in the account that owns the app proves very little, and testing on a free developer account proves less — apps there are authorized automatically. Test the path a customer takes. 1. Authorize from a **second** Box account, ideally a **developer sandbox** with **"Disable unpublished apps by default"** turned on, through your platform's real connect flow. That is the only way to see the §4 gate before a customer does. 2. Confirm the authorize URL carries `redirect_uri` explicitly (§5) and that the consent screen lists the scopes you expect — not more. 3. Exchange the code within its **30-second** life and confirm the response carries a refresh token. 4. Force a refresh. Then force a **second** refresh using the token returned by the first. That is what catches a client ignoring rotation — the failure never appears on the first one. 5. Make one real read call against `https://api.box.com/2.0` with the resulting access token. 6. If the connector creates webhooks, confirm those scopes are actually ticked; webhook failures look like permissions bugs. | Symptom | Cause | | --- | --- | | `redirect_uri_mismatch` error page instead of the grant screen | Redirect URI not registered character-for-character (§5) | | `redirect_uri_missing` *after* the user grants access | Multiple URIs configured, authorize URL omitted `redirect_uri` (§5) | | "Disabled by Administrator" | App not enabled/authorized in that enterprise, or another authorization requirement unmet (§4) | | Works for you, fails for one customer's whole enterprise | "Disable unpublished apps by default" is on there; their admin must enable it by client ID (§4) | | Worked, then stopped after adding a scope | Scope change requires re-authorization per enterprise (§4, §6) | | Second refresh fails, first succeeded | Client is not storing the rotated refresh token (§8) | | Connection dies after a quiet month or two | 60-day refresh-token expiry; nothing refreshed it (§8) | | Intermittent `invalid_grant` under load | Concurrent refreshes racing on one connection (§8) | | Code exchange fails with a valid-looking code | Authorization code older than 30 seconds (§8) | | 403 on an object the user owns | App scopes too narrow — scopes cap the token independently of user permission (§6) | | Admin: "Unable to retrieve service of enterprise app authorization" | Wrong client ID, or trailing characters pasted (§4) | | Admin: "Something went wrong with adding the app authorization" | Co-admin has view-only rights on enterprise settings (§4) | | 429 `rate_limit_exceeded` | Over a per-user or per-enterprise limit; honour `retry-after` (§9) | ## 11. Hand off — never commit the secret - **Do not** write the client secret or a developer token into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for their secret store or console. - If a code change is needed (a redirect host, a scope, refresh-token persistence), keep it secret-free and say what the human must set out of band. - Close with: app name and type (Platform App, User Authentication / OAuth 2.0); the owning account and whether it is a free developer account, sandbox, or production enterprise; the **client ID** (customers' admins will need it); where the secret was delivered; authorize, token and API base URLs; the exact scope strings and a one-line justification for each, ready to forward to a customer's admin; whether the client persists rotated refresh tokens; the customer authorization story (§4); and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the client does not persist rotated refresh tokens, or nobody can say whether it does (report it — do not register and hope); someone proposes a Server app (JWT or CCG) for a customer-authorizes-us connector, or proposes converting an existing app's type; the scope set would need `root_readwrite`, user management, or enterprise-properties access that nobody has justified; a customer's connection problem turns out to need *their* admin to enable or re-authorize the app; someone wants to rotate the client secret on a live app; the Box Integrations listing form asks for partner, support, security or volume claims; or the Developer Console does not match the **Platform state** section above. Do not use browser automation to click through the Box Developer Console or a customer's Admin Console on someone's behalf. Produce the exact click path and the literal values to paste, and let the human drive. ## References Verified 2026-09-20; every link resolved. - OAuth 2.0 authentication — https://developer.box.com/guides/authentication/oauth2/ - Setup with OAuth 2.0 — https://developer.box.com/guides/authentication/oauth2/oauth2-setup/ - OAuth 2.0 without SDKs — https://developer.box.com/guides/authentication/oauth2/without-sdk/ - Select an authentication method — https://developer.box.com/guides/authentication/select/ - Create a Platform App — https://developer.box.com/guides/applications/platform-apps/create/ - Authorization overview — https://developer.box.com/guides/authorization/ - Platform App Approval — https://developer.box.com/guides/authorization/platform-app-approval/ - Authorization common errors — https://developer.box.com/guides/authorization/common-errors/ - Security: scopes, application access, enterprise settings — https://developer.box.com/guides/security/ - Scopes — https://developer.box.com/guides/api-calls/permissions-and-errors/scopes/ - Token and code expiration — https://developer.box.com/guides/api-calls/permissions-and-errors/expiration/ - Refresh a token — https://developer.box.com/guides/authentication/tokens/refresh/ - Tokens overview — https://developer.box.com/guides/authentication/tokens/ - Rate limits — https://developer.box.com/guides/api-calls/permissions-and-errors/rate-limits/ - Authorize user (endpoint reference) — https://developer.box.com/reference/get-authorize/ - Request access token (endpoint reference) — https://developer.box.com/reference/post-oauth2-token/ - Revoke token (endpoint reference) — https://developer.box.com/reference/post-oauth2-revoke/ - Integrations: publishing a platform app — https://developer.box.com/guides/applications/integrations/ - Developer Console — https://app.box.com/developers/console - Developer account signup — https://account.box.com/signup/n/developer - Box Integrations directory — https://app.box.com/services - Managing platform apps (admin) — https://support.box.com/hc/en-us/articles/360044196653-Managing-platform-apps - Authorizing platform applications in sandbox and production — https://support.box.com/hc/en-us/articles/360043697014-Authorizing-Apps-in-the-Box-App-Approval-Process - Understanding requests to authorize or allow applications — https://support.box.com/hc/en-us/articles/360044193933-Understanding-Requests-to-Authorize-or-Allow-Applications - Developer sandbox management FAQ — https://support.box.com/hc/en-us/articles/360044196313-Developer-sandbox-management-FAQ - Box Verified Enterprise & supported apps — https://support.box.com/hc/en-us/articles/360043693554-Box-Verified-Enterprise-Supported-Apps - Box Partner Programs — https://support.box.com/hc/en-us/sections/21356597387539-Box-Partner-Programs ## brex-oauth-app URL: https://docs.unified.to/skills/brex-oauth-app/SKILL.md # Brex OAuth2 App Registration Get working Brex credentials for a platform that reads and writes data in *other companies'* Brex accounts — and decide, before anything else, whether that is even the credential you can have. **Brex has no self-serve OAuth app registration.** There is no developer portal page where you create an app, paste redirect URIs and receive a client ID. Brex's own partner authentication guide says to "contact our developer support team to be issued a client ID and client secret", and the redirect URIs are ones you supply to Brex "when the credentials were set up". OAuth on Brex is a **partner programme with a signed agreement, a staging build, a demo review and human-delivered credentials** — measured in weeks, not minutes. That shapes the whole task. If the partnership is not in place, the only credential a customer can give you today is a **user API token** that they generate themselves in their own Brex dashboard. In that case the deliverable is not an app registration at all: it is a **per-customer admin runbook** (§9), and you should say so plainly rather than looking for a registration page that does not exist. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Is there already a Brex partnership / issued client ID?** | Decides the whole run — see §1 | | **Company legal name, counterparty address** | Brex asks for these to draft the API access agreement (§2) | | **Name, email and title of the person who will sign** | Brex's agreement is signed by a human at your company (§2) | | **A staging account email** | Brex provisions a staging Brex account and invites this address (§2, §7) | | **Redirect URIs** | Every callback host; supplied to Brex at credential setup, not self-edited later (§3) | | **Which Brex data the connector reads and writes** | Drives the scope list Brex issues for your client (§4) | | **Read-only or read/write?** | Write scopes reach cards, transfers and users — a different conversation (§4) | | **A demo you can record** | Brex generates production credentials after you show a working staging demo (§2) | | **If no partnership: which customer, and who is their Brex account admin?** | The user-token runbook needs a named admin (§9) | ## Quick Start 1. Decide the route: partner OAuth, or per-customer user tokens (§1). **Do this first.** 2. If partner OAuth: start the partner conversation and work the onboarding sequence (§2). 3. Hand Brex every redirect URI at credential-setup time (§3). 4. Agree the exact scope strings your client will be issued (§4). 5. Capture client ID, client secret and the auth-server URLs, per environment (§5). 6. Build for 1-hour access tokens and **rotating** 90-day refresh tokens (§6). 7. Test in staging, and understand what staging is *not* (§7). 8. Design around rate limits, idempotency and the single-webhook-endpoint rule (§8). 9. If there is no partnership: deliver the per-customer admin runbook instead (§9). 10. Verify a real authorize → callback → token → refresh → read round trip (§10). 11. Hand the credentials over — never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) Five facts decide this task. Check the linked pages before following any older runbook: - **OAuth credentials are issued by humans, not by a portal.** Brex's partner authentication guide: "Please contact our developer support team to be issued a client ID and client secret for partner authentication." Developer support is `developer-support@brex.com`. There is no app-creation screen anywhere in Brex's developer documentation, and the developer area of the Brex dashboard creates **user tokens**, not OAuth apps. - **Partner onboarding is a sequence with an agreement and a demo in it.** Brex's partner onboarding page: once your application is approved you receive **staging** credentials over SendSafely, which "expire in 7 days"; you provide company name, counterparty address and the signer's name, email and title; you then receive a **Unilateral API Access Agreement** from Brex; and "once you've built your demo, share it with us in a video and we'll generate production credentials for you." - **Redirect URIs are a field you give Brex, not one you edit.** "Partners must supply one or more redirect URIs that Brex will use to post the authorization code", and the `redirect_uri` on the authorize call "must match exactly one of the addresses that was provided to Brex when the credentials were set up." Plan the full list up front; adding one later is a support request. - **Base URLs moved on 2026-01-01, and the old ones still work.** Brex: "As of January 1, 2026, we are transitioning base URLs to `api.brex.com` and `api-staging.brex.com` respectively, but the former URLs (`platform.brexapis.com` and `platform.staging.brexapps.com`) will continue to be available for the forseeable future." Auth servers are separate hosts and did **not** move: `accounts-api.brex.com/oauth2/default` and `accounts-api.staging.brexapps.com/oauth2/default`. - **There is no public sandbox.** Staging is partner-only, it is handed out as part of onboarding, it carries no availability guarantee — "data stored in staging environments may be purged at any time" — and Brex's own API references label the staging server "Note: This is not a sandbox. It will not work with customer tokens." Brex also publishes live OpenID Connect discovery documents for both environments, and tells you to treat them as authoritative: "This may change in the future without notice. Always use the well-known OpenID configuration document as the source of truth for the base URL." Read them before you trust this page (§References). If the documentation does not look like this, stop and report what you actually see rather than clicking on. ## 1. The route decision — read this first Brex's own FAQ draws the line in one sentence: "The user token is used to authenticate your own Brex account. The OAuth token is used when building partner applications to authenticate other Brex accounts." So for a multi-tenant platform, **OAuth is the correct model and the user token is the fallback.** But the correct model is gated and the fallback is not, and that asymmetry is the whole decision: | | Partner OAuth | Per-customer user API token | | --- | --- | --- | | Who issues it | Brex developer support, after partner onboarding (§2) | The customer's own Brex admin, self-serve in their dashboard | | Lead time | Weeks: application, agreement, staging build, demo review | Minutes | | Onboarding UX | Redirect-based consent from your product | Customer copies a secret and pastes it into your product | | Credential held | One client ID/secret, plus a token pair per customer | One long-lived bearer token per customer | | Token shape | `access_token` (1 hour) + rotating `refresh_token` (90 days) | `bxt_…` bearer token, no refresh | | Expiry | Access hourly; refresh must be exchanged inside 90 days | Expires after **90 days of no API calls**; dies if the user is deactivated | | Scope control | The scope list Brex issues for your client; customer consents per scope | Whatever the customer happens to tick when creating the token | | Scales to self-serve signup | Yes | No — every customer is a manual handoff | **Pick partner OAuth if the platform will connect Brex accounts as a product feature.** It is the only route that gives redirect-based onboarding, revocable per-customer grants and a credential your customer never has to handle. Start §2 today, because the lead time is the long pole. **Pick user tokens if you need to connect a handful of named customers now**, or while the partnership is in flight. It works, it is documented, and it is the customer's own account admin authorizing their own account — which is exactly what Brex intends it for. What you owe the customer in that case is §9's runbook, and what you owe your own team is the knowledge that this does not scale and that each token is a long-lived secret with no rotation story built in. **Do not mix the two silently.** A connection made with a user token and a connection made with OAuth have different expiry behaviour, different revocation paths and different scope granularity. Whatever stores the credential must know which kind it holds. ## 2. Getting in: the partner programme There are two doors and they lead to the same room. - **Developer support** — `developer-support@brex.com` — is the technical door, and the one Brex's partner authentication guide points at for client ID and secret. Brex asks that you include the request trace ID from the `X-Brex-Trace-Id` response header on any API-error report, and warns you not to send tokens or authorization headers in support mail. - **The partner team** — via the "Become a partner" form on Brex's partners page — is the business door. Brex groups partners as embedded finance, **technology partners** ("Integrate your software natively with Brex"), accounting firms, PE/VC and service partners. A multi-tenant data connector is a technology partner. Brex also runs a developer Slack community, linked from its contact page; useful for questions, not for credentials. **The documented onboarding sequence**, in order: 1. **Application and approval.** Brex approves the application before anything is issued. 2. **Staging credentials arrive over SendSafely, and expire in 7 days.** Treat the SendSafely link as perishable: whoever receives it must be ready to move the values straight into the secret store. If it lapses, you ask again. 3. **Agreement paperwork.** You supply company name, counterparty address, and the signer's name, email and title; Brex sends a **Unilateral API Access Agreement**. 4. **A staging Brex account, on request.** "If you need access to an account on staging, provide us with an email address and we'll provision a staging account for you and invite you to it." Ask for this explicitly — without it you have credentials but no data to authorize against. 5. **Build the demo.** 6. **Share the demo as a video.** "Once you've built your demo, share it with us in a video and we'll generate production credentials for you" — also delivered via SendSafely. **What this means for planning.** Production access is gated on a working integration, so the sequencing is staging-first: you cannot get production credentials, then build. Budget for the agreement's legal review on your side, and for the fact that the demo is a real deliverable. **What is *not* documented**, and therefore what you must not promise: Brex publishes no security questionnaire, no penetration-test requirement, no connection cap, no listing or marketplace review, and no stated turnaround time for any step. If the user needs those answers — because procurement, security review or a launch date depends on them — that is a question for the Brex partner team, not something to infer (§Stop and ask). ## 3. Redirect URIs Give Brex **every** callback host your platform serves, in the same message as the rest of the setup details. There is no self-service editor: the addresses are "provided to Brex when the credentials were set up", and the `redirect_uri` you send on the authorize call must match one of them **exactly**. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes: - **Register all four on day one**, including `api-dev`. Adding a data center later is an email and a wait, not a form submission — and a region that launches without its callback registered is a region that cannot connect. - **Exact match means exact**: scheme, host, path, case and trailing slash. A URI that is "obviously the same" is not the same. - Ask Brex to confirm, in writing, the exact list they stored, and keep that reply. It is the only copy of that configuration you control. - **Staging and production credentials are separate**, so confirm the redirect list is attached to both sets. ## 4. Scopes Scopes are **space-delimited** on the authorize URL, and Brex states that "the scopes for your client will be provided to you" once you are a registered partner — that is, the catalogue below is what exists, and what your client may request is what Brex enables for it. Agree the list explicitly during onboarding. Two scopes are structural rather than data-bearing, and a connector needs both: | Scope | Why | | --- | --- | | `openid` | Required to make an OpenID Connect request | | `offline_access` | **Required to obtain a refresh token** — omit it and every connection dies in an hour | The OIDC discovery document also advertises the standard identity scopes `profile`, `email`, `address` and `phone`. They return claims about the signed-in user; they return no business data. ### The catalogue, exactly as Brex spells it | API | Read | Write | | --- | --- | --- | | **Transactions** | `accounts.card.readonly`, `accounts.cash.readonly`, `transactions.card.readonly`, `transactions.cash.readonly`, `statements.card.readonly`, `statements.cash.readonly` | *(none — this API is read-only)* | | **Expenses** | `expenses.card.readonly` | `expenses.card` | | **Budgets** | `budgets.readonly` | `budgets` | | **Team** | `cards.readonly`, `companies.readonly`, `departments.readonly`, `legal_entities.readonly`, `locations.readonly`, `titles.readonly`, `users.readonly` | `cards`, `departments`, `legal_entities`, `locations`, `titles`, `users`; plus `cards.pan` for card numbers | | **Payments** | `transfers.readonly`, `vendors.readonly`, `linked_accounts.readonly` | `transfers`, `vendors`, `incoming_transfers` | | **Accounting** | `accounting.integration.read`, `accounting.record.read` | `accounting.integration.write`, `accounting.record.write` | | **Fields** | `fields.read`, `field_values.read` | `fields.write`, `field_values.write` | | **Travel** | `travel.trips.readonly` | `travel.trips` | | **Onboarding** | — | `https://onboarding.brexapis.com/referrals` | Traps worth more than the table: 1. **The naming convention is not uniform.** Most pairs are `x.readonly` / `x` (cards, users, budgets, transfers, vendors, travel). But **Accounting and Fields use `.read` / `.write`** instead. Guessing `accounting.record.readonly` or `fields.readonly` produces a scope that does not exist. 2. **Expenses is spelled `expenses.card`, not `expenses`** — even though the current list endpoint is the un-suffixed `/v1/expenses` path, its published OpenAPI requires `expenses.card.readonly` or `expenses.card`. This is the single easiest scope in the catalogue to get wrong, because the endpoint name and the scope name disagree. 3. **The Onboarding scope is a URL.** `https://onboarding.brexapis.com/referrals` is the literal scope string. That API is also the one exception to the grant type: onboarding uses the **client credentials grant**, with no Brex user involved; every other API uses the authorization code grant. 4. **`cards.pan` is the one that reads actual card numbers.** Brex separates it from `cards` deliberately. Request it only if the platform genuinely needs the PAN, and expect it to change the compliance conversation with both Brex and your customers. 5. **Scopes are additive, per user.** Brex: adding functionality later means requesting the new scopes, "which will send the user through the authentication flow again and add those scopes to their previously consented scopes." Adding a scope does **not** upgrade tokens already issued. 6. **Consent is partial by design — this is the multi-tenant gotcha.** Brex: the client "may accept 0 to all of the requested scopes which your application will then have access to", and the callback returns `scope=` listing what was actually granted, which "can be a subset of what was requested." A connector that assumes it got what it asked for will 403 on individual endpoints for individual customers, with nothing wrong at the app level. **Read the granted scope list off the callback, store it with the connection, and let it drive which objects you expose for that customer.** ### Who can consent "Most Brex APIs require a Brex admin to grant access. In these cases, only users with the account admin role can be authenticated." **Bookkeepers** may access the **accounting API** only. So the person who clicks Connect in your product must be an account admin, or the flow fails for reasons your UI cannot explain — say so in the connect screen. If your users are accountants or bookkeepers who hold several Brex logins, Brex supports forcing a fresh login by appending **`prompt=login`** to the authorize request. Without it, a servicer silently reconnects the same Brex account they last signed into. ### Extra approval Brex's published documentation does **not** describe a per-scope approval step: the approval gate is the partnership itself, and the scope list is settled with Brex when your client is created. Treat "which of these will Brex actually enable for us, and under what conditions" as a question for the partner conversation — not as something to assume from the catalogue (§Stop and ask). ## 5. Capture the credentials What you receive from Brex: a **client ID** and **client secret**, per environment, delivered over SendSafely (staging first, production after the demo). Nothing self-serve regenerates them, so store them carefully the first time — and remember the staging link expires in 7 days. Endpoints, from Brex's partner guide and confirmed against the live OIDC discovery documents on 2026-09-20: | | Production | Staging | | --- | --- | --- | | Auth server base | `https://accounts-api.brex.com/oauth2/default` | `https://accounts-api.staging.brexapps.com/oauth2/default` | | Authorize | `…/v1/authorize` | `…/v1/authorize` | | Token | `…/v1/token` | `…/v1/token` | | Revoke | `…/v1/revoke` | `…/v1/revoke` | | Userinfo | `…/v1/userinfo` | `…/v1/userinfo` | | Discovery | `…/.well-known/openid-configuration` | `…/.well-known/openid-configuration` | | API base | `https://api.brex.com` (formerly `https://platform.brexapis.com`) | `https://api-staging.brex.com` (formerly `https://platform.staging.brexapps.com`) | The authorize request takes `client_id`, `response_type=code`, `redirect_uri`, `scope` (space-delimited) and `state`. **`state` is required and "must be longer than 8 characters"** — Brex enforces a length, not just a presence. Verify it on the way back. **PKCE is supported but not required.** Discovery advertises `code_challenge_methods_supported: ["S256"]`. If the client can send a code challenge, send one; it costs nothing and closes the code-interception hole. The token exchange is `application/x-www-form-urlencoded` with `grant_type=authorization_code`, `code`, `redirect_uri`, `client_id` and `client_secret` **in the body** — `client_secret_post`. Discovery also advertises `client_secret_basic`, so HTTP Basic works if your HTTP client prefers it; pick one and be consistent. The response carries `access_token`, `refresh_token` and `token_type: "bearer"`. Calls then send `Authorization: Bearer `. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and that replacing it means going back to Brex, then move on. ## 6. Token lifetimes and rotation Four facts, and a connector that misses any one of them breaks later rather than now. | Fact | Consequence | | --- | --- | | **Access token: 3600 seconds (1 hour)** | Refresh proactively; a stale token is a 401 on every call | | **Refresh returns a new refresh token as well as a new access token** | Persist the new refresh token from *every* exchange, or the connection dies at the next refresh | | **Refresh tokens "expire every 90 days"** | A connection nobody syncs for three months requires the customer to re-authorize | | **Revocation is `POST {auth base}/v1/revoke`** with `token`, `client_id`, `client_secret` | This is how you cleanly disconnect a customer — use it on disconnect rather than dropping the row | More on each: - **Rotation is the silent killer.** Brex is explicit that the refresh response "will contain a new `` as well as a new `REFRESH_TOKEN`". A client that stores only the value it got at connect works until the first refresh and then fails permanently, with no user-visible event. Treat "we store the token we got at connect" as a defect, not a design. - **The 90-day clock is about exchange, not idle time per se.** Brex: refresh tokens "should be exchanged for a new one before then. Failure to do so will require the user to re-authenticate." An actively-synced connection stays alive indefinitely; a dormant one does not. If the platform has a pause/disable feature, make sure it does not also pause refreshes. - **Client credentials tokens do not refresh.** The onboarding API's client-credentials flow returns a 1-hour token and no refresh token — Brex's advice there is simply to request a new one when it expires. - **User tokens are a different clock entirely** (§9): they expire after **90 days without an API call**, and they stop working the moment the underlying Brex user is deactivated. ## 7. Staging is not a sandbox This matters more here than for most vendors, because the alternative to a test environment is experimenting against real company money. - **Staging is partner-only.** It arrives with your onboarding credentials; there is no public signup. - **Brex labels it explicitly**: "Note: This is not a sandbox. It will not work with customer tokens." Staging is a separate Brex world with separate credentials and a separately provisioned Brex account — not a mode of the production one. - **No availability guarantee, and data can vanish**: "We can remove data from the staging environment at any time, so please do not use this in anything that's critical or a production use-case… Use staging for point-in-time testing only." Do not build regression fixtures that assume a staging record still exists. - **Ask for a staging Brex account explicitly** (§2, step 4). Credentials without an account to authorize against prove only that the token endpoint answers. - **Production is the only place your customers live**, and every read there is real financial data. Scope minimally (§4), and treat any write path — transfers especially — as needing a deliberate, separate sign-off. ## 8. Limits, idempotency and webhooks None of this is fixed by re-registering; design for it. - **Rate limits, per client ID *and* Brex account**: up to **1,000 requests in 60 seconds**, **1,000 transfers in 24 hours**, **100 international wires in 24 hours**, **5,000 cards created in 24 hours**. Exceeding one returns **HTTP 429**. Brex's guidance: prefer webhooks over polling, back off exponentially "with some randomness", and throttle client-side with a token bucket per client ID. Contact support for higher limits. Note the scoping — one noisy customer cannot exhaust another customer's budget, but your client ID is one half of the key. - **Page sizes are not uniform, and they change.** The Expenses list endpoints had their **maximum `limit` reduced from 1000 to 100 in February 2026**. A connector with one global page-size constant will start getting 400s from exactly one API. Cap per endpoint, from each API's own reference. - **Idempotency keys are required on the dangerous endpoints.** `Idempotency-Key` is optional on most POST/PUT calls and **required** on Create transfer and Create card. Brex's rules: keys must be "random strings with sufficient entropy to avoid collisions like V4 UUIDs"; your application "must not provide identical idempotency keys for different requests"; and it "should store these keys so that semantically identical requests are sent with the same idempotency keys." **A key regenerated on each retry defeats the entire mechanism** — the retry of a timed-out transfer becomes a second transfer. GET and DELETE ignore the header. - **Webhooks exist but cover very little.** The published catalogue is six events: `REFERRAL_CREATED`, `REFERRAL_ACTIVATED`, `REFERRAL_APPLICATION_STATUS_CHANGED` (Onboarding), `TRANSFER_PROCESSED`, `TRANSFER_FAILED` (Payments), `USER_UPDATED` (Team); an `ACCOUNTING_RECORD_READY_FOR_EXPORT` event was added in March 2026. There is **no** webhook for expenses or for card/cash transactions — those must be polled. - **One webhook endpoint per customer per client ID.** Brex: "Currently only one webhook endpoint can be registered per customer / `client_id`" — though one endpoint may subscribe to many event types. Registration is by API (`POST /v1/webhooks`), and you must verify the signature on receipt. - **Transactions lag, so poll with an overlap.** Brex documented in July and August 2026 that card and cash transaction lists "may reflect a small lag" and recommended "a lookback window of at least one day when syncing transactions so delayed transactions are not missed", and an overlapping `posted_at_start` when polling. A poller whose high-water mark advances to "now" with no overlap **will silently drop late-posting transactions** — and because the API returns only settled transactions, late posting is normal, not exceptional. - **Pending transactions are not available at all.** Brex's FAQ: "Only settled transactions are returned from the API." Do not promise real-time spend visibility. ## 9. No partnership yet? Ship the admin runbook When the answer to §1 is user tokens, the deliverable per customer is this. Send it to the customer's **Brex account admin or card admin** — nobody else can do it. > **Connecting your Brex account** > > 1. Sign in to `https://dashboard.brex.com/` as an **account admin** or **card admin**. > 2. Go to **Developer → Settings** (`https://dashboard.brex.com/settings/developer`). > *If your company has not yet accepted the developer API agreement, you will be asked to review and accept the > terms first. This is a one-time, company-level step and it requires someone empowered to accept it.* > 3. Click **Create Token**. > 4. Name the token something you will recognise later — include our platform's name, so it is obvious what it is > for when someone audits your tokens. > 5. **Select exactly these scopes** — no more: *(list the exact strings from §4 that the connector needs, and > nothing else)*. > 6. Confirm the summary screen, then select **Allow Access**. > 7. **Copy the token immediately.** It begins `bxt_` and **you will not be able to see it again**; the dashboard > shows only a partially obfuscated version afterwards. If you lose it, create a new one and replace it. > 8. Send it to us through *(your secure channel — never email, chat or a ticket)*. Tell them the three things that will otherwise surprise them later: - **The token expires after 90 days without an API call.** Regular syncing keeps it alive; a paused connection quietly dies. - **The token is tied to a Brex user.** If that person is deactivated, the token stops working — so create it under a durable admin, not someone's personal account that offboarding will close. - **They can revoke it any time** from the same developer page, and calls "will immediately begin to fail". And tell your own side: this is a long-lived bearer secret with no refresh and no rotation protocol. It needs the same handling as any other customer credential, plus a plan for what happens when it expires (§11). ## 10. Verify end-to-end 1. **Read the discovery document** for the environment you are targeting and confirm the endpoints match what the client is configured with. Brex says to treat it as the source of truth. 2. Run a full **authorize → consent → callback** against a staging Brex account. Confirm the consent screen lists exactly the scopes you expect, and that `state` comes back matching what you sent. 3. **Read `scope` off the callback** and compare it to what you requested. Deliberately deselect one scope at consent time and confirm the connector degrades gracefully instead of 403-ing at random later (§4). 4. Exchange the code and confirm you received both an `access_token` and a `refresh_token`. If there is no refresh token, `offline_access` was missing. 5. Make one real read call with `Authorization: Bearer …`. 6. **Force a refresh, then force a second refresh using the token the first one returned.** Nothing else catches a client that ignores rotation before production does. 7. Call `/v1/revoke` with the refresh token and confirm the connection is cleanly torn down. 8. Repeat 2–6 in production, authorized by a real account admin who is not you. | Symptom | Cause | | --- | --- | | No app-registration page anywhere in the Brex dashboard | Correct — OAuth apps are issued by Brex, not self-registered (§1, §2) | | Authorize request rejected on `redirect_uri` | Not byte-identical to an address supplied to Brex at credential setup, or registered against the other environment (§3) | | Authorize rejected with a `state` complaint | `state` is required and must be **longer than 8 characters** (§5) | | Token exchange succeeds, no `refresh_token` in the response | `offline_access` was not requested or not granted (§4, §6) | | Consent completes, then some endpoints 403 for some customers | Partial consent — the customer granted a subset; read the granted `scope` off the callback (§4) | | Every endpoint 403 for one customer | Consent given by a non-admin, or a bookkeeper outside the accounting API (§4) | | All calls 401 after an hour | Access token expired; refresh not wired up (§6) | | Works once, then every refresh fails | Rotated refresh token not persisted (§6) | | Connection dies after a quiet few months | Refresh token passed its 90-day window; customer must re-authorize (§6) | | A `bxt_` token stops working with no change | 90 days without a call, or the Brex user behind it was deactivated (§9) | | `403`/`401` in staging with a customer-supplied token | Staging "will not work with customer tokens" — it is not a sandbox (§7) | | Staging data disappeared | Expected — staging data may be purged at any time (§7) | | HTTP 429 | Rate limit: 1,000 req/60s per client ID and account; back off with jitter, poll less (§8) | | Expenses list 400s on page size | Expenses `limit` max was reduced from 1000 to 100 in Feb 2026 (§8) | | A retried transfer went out twice | Idempotency key regenerated on retry instead of reused (§8) | | Transactions missing from a sync | No lookback overlap; late-posting settled transactions fell behind the high-water mark (§8) | | A servicer keeps reconnecting the same Brex account | Add `prompt=login` to the authorize request (§4) | When reporting any API error to Brex, include the **`X-Brex-Trace-Id`** response header — and strip tokens and authorization headers from whatever you paste. ## 11. Hand off — never commit the secret - **Do not** write the client secret, an access token, a refresh token, or a customer's `bxt_` user token into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Brex says the same about user tokens: "Never check it into version control or save it somewhere publicly accessible." - **Brex secrets are self-identifying.** A value beginning `bxt_` is a live Brex user token with whatever scopes its creator ticked, and on a production account that reaches real card, transaction and transfer data. If you ever see one in a diff, a log or a paste, treat it as compromised, say so, and tell the customer to revoke it from their developer dashboard immediately — revocation is instant and self-serve, so there is no reason to wait. - **SendSafely links are perishable and single-purpose.** Move the values into the secret store on receipt; do not forward the link around. - **Replacing a client secret means going back to Brex.** There is no self-serve rotation, so plan a rotation the way you would a migration, not a click. - If a code change is needed (a redirect host, a scope, refresh-token persistence, per-endpoint page-size caps), keep it secret-free and say what the human must set out of band. - Close with: which route was taken (partner OAuth or user tokens) and why; the partner onboarding stage reached and what is outstanding; client ID per environment and where each secret was delivered; the authorize, token and revoke URLs; the API base URL per environment; the exact scope strings agreed; the redirect URIs supplied to Brex and Brex's written confirmation of them; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: nobody has decided between partner OAuth and per-customer user tokens (§1 — it changes the product, not just the config); the Unilateral API Access Agreement needs signing, or its terms need review; Brex asks for company details, a signer, or anything contractual (§2); someone needs a committed answer on partner review timelines, security requirements, connection caps or listing — Brex publishes none of these (§2); the scope list would include `cards.pan`, `transfers`, `incoming_transfers` or any write scope that moves money or changes users (§4); the platform cannot store rotated refresh tokens (§6 — report it, do not register and hope); a customer's production Brex account is about to be used as a test environment because staging was not provisioned (§7); replacing a live client secret is on the table (§11); or the documentation does not match the **Platform state** section above. Never attempt to create Brex credentials through a logged-in dashboard session on the user's behalf, and never ask a customer to send a `bxt_` token over an insecure channel. ## References Verified 2026-09-20. `developer.brex.com` returns real HTTP 404s for paths that do not exist (checked), so the status of these links is meaningful; each was also read on that date. Appending `.md` to a `/guides/…` path returns the same page as Markdown, which is the fastest way to re-verify a quote. - Brex API documentation home — https://developer.brex.com/ - Quickstart — https://developer.brex.com/guides/quickstart - Authentication (user API tokens) — https://developer.brex.com/guides/authentication - **Partner authentication (OAuth: endpoints, grants, PKCE, state, token lifetimes, revoke, redirect URIs)** — https://developer.brex.com/guides/partner_authentication - **Partner onboarding (SendSafely, 7-day staging credentials, API access agreement, demo → production)** — https://developer.brex.com/docs/partner_onboarding/ - User roles, permissions and the full scope catalogue — https://developer.brex.com/guides/roles_permissions_scopes - Rate limits — https://developer.brex.com/guides/rate_limits - Idempotency — https://developer.brex.com/guides/idempotency - Webhooks (event catalogue, one endpoint per customer/client ID) — https://developer.brex.com/guides/webhooks - Changelog — https://developer.brex.com/changelog - Contact us (developer-support@brex.com, `X-Brex-Trace-Id`, Slack community) — https://developer.brex.com/docs/support - Brex API FAQ (user token vs OAuth token, settled-only transactions) — https://developer.brex.com/docs/faq - OIDC discovery, production — https://accounts-api.brex.com/oauth2/default/.well-known/openid-configuration - OIDC discovery, staging — https://accounts-api.staging.brexapps.com/oauth2/default/.well-known/openid-configuration - Expenses API reference — https://developer.brex.com/openapi/expenses_api/ - Expenses API OpenAPI bundle (per-endpoint scopes) — https://developer.brex.com/_bundle/openapi/expenses_api.yaml - Team API reference — https://developer.brex.com/openapi/team_api/ - Transactions API reference — https://developer.brex.com/openapi/transactions_api/ - Payments API reference — https://developer.brex.com/openapi/payments_api/ - Budgets API reference — https://developer.brex.com/openapi/budgets_api/ - Accounting API reference — https://developer.brex.com/openapi/accounting_api/ - Fields API reference — https://developer.brex.com/openapi/fields_api/ - Travel API reference — https://developer.brex.com/openapi/travel_api/ - Onboarding API reference — https://developer.brex.com/openapi/onboarding_api/ - Webhooks API reference — https://developer.brex.com/openapi/webhooks_api/ - Brex MCP server (OAuth and API-key auth; beta since April 2026) — https://developer.brex.com/docs/mcp - Partner with Brex (technology partners, "Become a partner" form) — https://www.brex.com/partners - Brex API help-centre article — https://www.brex.com/support/brex-api - Brex dashboard — developer settings (where customers create user tokens) — https://dashboard.brex.com/settings/developer - Brex status — https://status.brex.com/ ## Product facts: the connector, as of 2026-09-20 A dated snapshot of how this platform's Brex connector behaves. Treat it as a starting point for a conversation with the connector's owner, not as a specification. **Authorization.** It authorizes against `https://accounts-api.brex.com/oauth2/default/v1/authorize` and exchanges at `…/v1/token`, sending `client_id`, `response_type`, `redirect_uri`, `scope` and `state` on the authorize URL. The exchange is a form POST carrying `client_id`, `client_secret`, `redirect_uri`, `code` and `grant_type` in the body (`client_secret_post`); refresh sends `client_id`, `client_secret`, `redirect_uri`, `refresh_token` and `grant_type`. Access tokens are presented as `Bearer`. **PKCE is not sent**, and no `prompt` parameter is sent — both are optional on Brex's side (§5, §4), but `prompt=login` would matter for accountant/bookkeeper users. **Environments.** It carries two host profiles: production on the legacy `https://platform.brexapis.com` API hostname, and a Staging profile on `https://platform.staging.brexapps.com` with the staging auth server. Both legacy hostnames remain served (§Platform state), but the connector has not moved to `api.brex.com` / `api-staging.brex.com`. **Second auth mode.** Besides OAuth, it accepts a customer-supplied API token presented as a bearer token, and its customer-facing instructions point at the Brex dashboard's developer settings page — i.e. it already implements §9's user-token route. **Scopes.** Every capability's scope set begins with `offline_access openid profile email`, and a login-only set of `openid profile email` is used for the identity flow. Per capability it then adds: `users.readonly`/`users`, `locations.readonly`/`locations`, `departments.readonly`/`departments`, `transfers.readonly`, `cards.readonly`, `transactions.card.readonly`, `companies.readonly`, `vendors.readonly`, `accounts.cash.readonly` and `transactions.cash.readonly`. All of those match Brex's published per-endpoint requirements. **Three things worth checking with the owner:** 1. **The expense capability requests no expense scope.** Its scope set is the `offline_access openid profile email` baseline and nothing else, while Brex's published OpenAPI for the expenses list and get endpoints requires `expenses.card.readonly` or `expenses.card`. Any customer authorized through this path would be granted no expense access, and expense calls should 403 — the read path appears to work today only for connections made with a user API token whose creator happened to tick the expense scope. 2. **The page-size ceiling is 1000 for all objects.** Brex reduced the Expenses list maximum from 1000 to 100 in February 2026 (§8), so a large page request against expenses is expected to fail. 3. **Two write-path details on Brex's dangerous endpoints.** The `Idempotency-Key` it sends on create and update is generated from the current millisecond-of-second plus a random number, freshly computed per attempt. That is both low-entropy against Brex's "V4 UUID"-grade guidance and, more importantly, **not stable across retries** — Brex's stated purpose for the header (§8). Separately, the cash-transaction poller advances its per-account high-water mark to the current timestamp with **no lookback overlap**, against Brex's July/August 2026 guidance to use at least a one-day overlapping window because settled transactions can post late. **Credentials in source:** none found. No client ID, client secret or API token value appears in the connector's Brex sources; the only credential-shaped strings are parameter names. ## bullhorn-oauth-app URL: https://docs.unified.to/skills/bullhorn-oauth-app/SKILL.md # Bullhorn API Credentials **Read this first, because it may end the run: Bullhorn has no developer portal, and Bullhorn's published policy forbids the exact shape of platform this skill is usually invoked for.** There is no console, no "create app" button, no self-service registration of any kind. Every Bullhorn OAuth credential is created by a human at Bullhorn — Support for a customer's own key, the Alliances team for a contracted integrator — and delivered by email through a support case. And Bullhorn's own API Access FAQ answers the middleware question directly and in the negative: > "Q: Can I leverage a third party to build my integration? A: **We do not permit third-party 'connector' or > 'middleware' companies to build integrations with Bullhorn.** Integrators are required to leverage in-house > developers to build a direct, platform-to-platform integration to the Bullhorn API." and, on the obvious workaround of just collecting each customer's own key: > "Q: Can I just get access to my customer's instance and build directly to them? A: We do not offer free access to > anybody, and **customers will not be provided with their API Key if they intend to provide it to a third party > vendor who is not contracted with Bullhorn.**" The Fair Use Policy says the same thing from the legal side: a third-party service provider accessing the API on a customer's behalf "is subject to all terms of this policy, including … registering with Bullhorn," access happens only "at such customer's specific written instruction identifying the third party," and there must be "No Access on Behalf of Others … without Bullhorn's explicit written permission." So there are exactly two honest answers to "get me Bullhorn OAuth credentials for our multi-tenant platform": 1. **The platform contracts with Bullhorn** under the API Access Program — annual platform fee, no redlines on the agreement, a security assessment, and only then a sandbox (§3). Note that even a contracted integrator is told to build "a direct, platform-to-platform integration," so whether a unified-API layer qualifies is a **commercial conversation with Bullhorn, not a technical one**. Get it in writing before building. 2. **Each customer holds their own credential**, requested from Bullhorn Support under their own contract, with Bullhorn's knowledge that a named third party will use it (§4). This is the path most platforms are actually on, and the one the FAQ above constrains. It is workable only where the customer has told Bullhorn who you are. Do not start a registration that cannot complete. Establish which of the two applies (§1) before anything technical. Once you do hold credentials, four things cost real time, and three of them have nothing to do with registration: the **per-customer data-centre swimlane** that makes the auth host itself different for every tenant and must be discovered at runtime (§5) — the single most common Bullhorn integration failure; the **two-step token dance**, where the OAuth access token is not an API credential at all but a ticket you trade for a short-lived REST session (§6); **refresh tokens that are single-use and are not enabled on every key** (§6); and **the absence of scopes entirely**, because Bullhorn's access control is the authorizing user's entitlements, which your consent screen cannot request and your token response cannot report (§7). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Which credential model** | Platform-level API Access contract, or per-customer keys? (§1) | | **Contract status** | Is there a signed Bullhorn API Access Agreement? A sandbox? A named Alliances contact? (§3) | | **For per-customer keys: who is the customer's authorized support contact** | Only they can open the ticket (§4) | | **Owner's email for the key request** | Bullhorn sends the credentials to this address, and only this address (§4) | | **Redirect URI(s)** | Every callback host, final, up front — changing them is another support ticket (§4, §5) | | **Environment** | Sandbox and production are separate keys on separate swimlanes; request each separately (§4) | | **Whether refresh tokens are required** | Must be explicitly requested — a key can be issued without them (§6) | | **The customer's API username** | Needed at *authorize* time to find their data centre, not just at call time (§5) | | **The Bullhorn edition / cluster** | Some editions have no API access at all; the cluster ID fixes the swimlane (§5, §8) | | **Expected monthly call volume** | Limits are per client ID and shared across all your customers (§8) | ## Quick Start 1. Establish which **credential model** is legitimately available here, and whether the middleware prohibition ends the run (§1). Most wrong turns — and every wasted month — start here. 2. Confirm whether credentials already exist worth reusing; re-issuing orphans every existing authorization (§2). 3. If platform-level: contract through Bullhorn Alliances, pass the security assessment, get the sandbox (§3). 4. Request the key by support ticket, with the redirect URI and refresh-token requirement stated explicitly (§4). 5. Build **data-centre discovery** before you build anything else — the auth host is per customer (§5). 6. Implement the **two-step token dance** and the single-use refresh rotation (§6). 7. Stop looking for scopes. Pin down the authorizing user's **entitlements** instead (§7). 8. Check rate limits, call caps and edition gates before promising a launch date (§8). 9. Verify with a real authorize → callback → login → refresh → refresh-again round trip on a *second* swimlane (§9). 10. Hand the secret to a human, never to source control (§10). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **There is no Bullhorn developer portal.** Bullhorn's developer FAQ and Getting Started page both say the same thing: "Bullhorn customers can obtain OAuth keys for developing applications with the Bullhorn REST API by creating a support ticket via the Bullhorn Resource Center." Bullhorn's knowledge base restates it flatly for the one credential-request workflow it documents step by step: "These credentials are not self-service." - **Partner keys come from a different door.** Bullhorn's FAQ: "Partner keys are only available directly from Bullhorn. If you are interested in becoming a partner, send an email message to `partners@bullhorn.com` or fill out the form available on the Bullhorn Marketplace." - **Middleware/connector companies are prohibited** from building Bullhorn integrations per the API Access FAQ, and customers are told they will not receive an API key intended for an uncontracted third party. This is current as of this verification and is the single most important fact on this page. - **The API Access Program is paid and non-negotiable.** Annual platform fee (amount **not published** — do not quote a figure), including "a sandbox, technical resources, and 200,000 API calls per month," overage charges beyond that, fee "must be paid in full at the beginning of each annual term," and "we do not accept redlines to the API Access Agreement." Onboarding order: Alliances sends the contract → Finance invoices → **security assessment** → sandbox access once the assessment passes and the invoice clears. - **Listing is a separate paid engagement.** "Promotion of your integration is not allowed unless you decide to go through our validation process. This is a paid engagement with our Technical Services team." The platform-partner page adds that "All integrations must undergo a Technical Services-assisted validation engagement to ensure they meet our security, data privacy, and technical performance standards." - **The published data-centre list is out of date.** `Data-Center-URLs` is bylined **April 9, 2018** and does not list an Australian lane at all, yet `auth-aus.bullhornstaffing.com` and `rest-aus.bullhornstaffing.com` both resolve in DNS today, and a live `loginInfo` response for an AU cluster returns `auth-aus`/`rest-aus` with `superClusterId: 66`. The page also lists APAC as CLS61/CLS62 on `auth-syd`, which does not cover every APAC cluster seen in the wild. **Treat the published list as documentation of the idea, never as a lookup table.** - **`loginInfo` fails open, which is dangerous.** On 2026-09-20 a GET of `https://rest.bullhornstaffing.com/rest-services/loginInfo?username=` returned **HTTP 200** with a complete, plausible response body pointing at a default US-West lane (`cls31`, `auth-west`, `rest-west`). It does **not** 404 on an unknown user. A typo in the API username therefore produces a confidently wrong data centre rather than an error. - **Rate limits are scoped to the client ID, not the tenant.** Bullhorn's knowledge base: "Rate limits are scoped to your OAuth Client ID, not to individual tenants or sessions. This means all API calls made under the same OAuth Client ID share a single rate limit." For a platform with one shared client ID across many customers this is the defining capacity constraint (§8). - **The Fair Use Policy was last bylined 17 December 2025** and now carries explicit AI/LLM clauses: no connecting the API "to any 3rd party AI or LLM tools for viewing or updating data without Bullhorn's explicit written permission," and no export/import/update/delete "by any unauthorized solutions or models, like Model Context Protocols (i.e. MCPs), without Bullhorn's explicit written permission." If the platform exposes Bullhorn data to an AI feature, that is a **contract question to raise before shipping**, not a footnote. - **No PKCE anywhere in Bullhorn's documented flow**, and no scope parameter. Neither appears in the OAuth overview or the Getting Started walkthrough. Absence of documentation is not proof of absence; it is proof you should not build on it. - **Two API generations coexist.** Bullhorn documents both a REST API and a legacy SOAP ("web services") API, and recommends REST. SOAP keys are self-serve in the customer's own account (Tools → BH Connect → Web Services API); **REST OAuth keys are not**. A customer who says "I generated an API key myself" has almost certainly generated the wrong one. - **Support-forum answers are not documentation.** `supportforums.bullhorn.com` was unreachable from this run; where it is reachable, treat it as community folklore and confirm anything load-bearing against a Bullhorn-owned page. If the developer pages or the knowledge base do not look like this, stop and report what you actually see. ## 1. Which credential, and who is allowed to hold it | Surface | Credential | Hosts | Who gets it | | --- | --- | --- | --- | | **REST API** (core ATS/CRM read/write) via **OAuth 2.0** | Client ID + secret, authorization code grant, plus an API username and a password set on that API user | authorize/token on `auth-{lane}.bullhornstaffing.com/oauth`; REST on `rest-{lane}.bullhornstaffing.com/rest-services` | Bullhorn Support, by ticket, for a contracted customer; Bullhorn Alliances for a contracted integrator | | **REST API** as a contracted **integrator** | Same shape, issued under the API Access Program with a sandbox | Same | Companies with a signed API Access Agreement (§3) | | **SOAP / "web services" API** (legacy) | API key generated by the customer's own admin in Tools → BH Connect → Web Services API | `api-{lane}.bullhornstaffing.com` | Any customer admin, self-serve — **not** a REST credential | Consequences worth saying out loud before anyone starts work: - **"Bullhorn API key" is ambiguous and the ambiguity costs a week.** A customer admin can self-serve a SOAP key in about a minute, and will cheerfully send it to you. It will not work against REST. Ask for the **REST OAuth Client ID, Client Secret, API Username** by those exact names, separately. - **There is no client-credentials or service-account grant.** Bullhorn's documented OAuth is authorization-code only. A human user's login is required at least once per credential, even for an unattended integration — Bullhorn's own guidance for "OAuth 2 clients without user agents" is to perform the authorization step **outside** normal operation and then live on refresh tokens. - **The credential is bound to a Bullhorn *user*, and therefore to that user's permissions.** There is no app identity with its own permission set. See §7. - **Sandbox and production are different clusters with different hosts and different keys.** Bullhorn's knowledge base is explicit for the one documented request workflow: "Each environment requires its own Client ID and Client Secret. Submit a separate support ticket for each environment you plan to deploy to." > **Product fact — dated.** As of **2026-09-20**, this platform's Bullhorn connector uses **OAuth 2.0 only**, and > ships **no shared credentials**: every workspace must supply its own, which in practice means its own relationship > with Bullhorn. It asks each workspace for **four** values, in this order — **OAuth2 Client ID, OAuth2 Client > Secret, API Username, API Password** — which matches exactly the four values Bullhorn's knowledge base says Support > issues. Its on-file default hosts are `https://auth.bullhornstaffing.com/oauth/authorize`, > `https://auth.bullhornstaffing.com/oauth/token` and `https://rest.bullhornstaffing.com`, with a long list of > per-data-centre alternatives recorded but **disabled**, because the hosts are resolved at runtime instead (below). > **Data-centre discovery happens at authorize time:** before building the consent URL it takes the **API Username** > the workspace supplied, calls `https://rest.bullhornstaffing.com/rest-services/loginInfo?username=`, > and rewrites the authorize URL, the token URL and the API base from the `oauthUrl` and `restUrl` the response > returns, persisting them for that connection. On the authorize request it sends `client_id`, `state`, > `response_type` and the literal `action=login`; it sends **no `scope`**, **no PKCE challenge**, and — deliberately — > **no `redirect_uri`**, relying on the callback registered on the key. At the token endpoint it POSTs `grant_type`, > `code`, `client_id` and `client_secret` as **parameters**, not as an HTTP Basic header; refreshes send `client_id`, > `client_secret`, `refresh_token` and `grant_type`. **Immediately after a successful token exchange, and again after > every refresh, it performs the second step**: a call to `/rest-services/login` with the OAuth > access token and `version=*`, takes the `BhRestToken` and the `restUrl` from the response, **replaces the stored > access token with the `BhRestToken`**, and normalizes the REST base to a trailing slash. REST calls then carry the > session key in a **`BHRestToken` header**, not an `Authorization: Bearer` header. Its recorded guidance to > customers is the Bullhorn support-ticket route plus "Make sure that the redirect URL is …/oauth/code", and its > recorded rate-limit handling is Bullhorn's own advice: wait one second and retry until successful. **Confirm all of > this with the connector's owner before acting on it** — connector configuration changes independently of this > skill. ## 2. Reuse the existing credentials, or request new ones New credentials mean a **new client ID, and every existing customer authorization is bound to the old one** — every customer re-authorizes, and on Bullhorn that means every customer files a ticket or clicks through consent again. Reuse what exists for: adding or changing a redirect URI, enabling refresh tokens on a key that lacks them, rotating a compromised secret, or diagnosing a failure. All four are handled on the existing key, by support ticket (§4). Request a **new** key only when the user explicitly wants one: a separate product, a replacement for a compromised client, or a new environment — which *is* a new key by Bullhorn's design, not a setting. One Bullhorn-specific wrinkle that pushes the other way: because rate limits are **per client ID and shared across every tenant using it** (§8), a platform at scale may legitimately want more than one client ID purely for capacity. That is a conversation with Bullhorn Alliances, not something to engineer around quietly. ## 3. The contract gate: the API Access Program This section applies when the **platform** is getting credentials, rather than each customer. Entry point: `partners@bullhorn.com`, or the form on the Bullhorn Marketplace / "Integrate with Bullhorn" page. Bullhorn's published onboarding sequence: 1. **Bullhorn Alliances sends the contract for signature.** The FAQ states plainly that redlines are not accepted: "we do not require that same level of access to the integrator solution … The terms set out in the contract, in particular in the Limitations of Liability and Indemnification sections, are meant to mitigate our risk and may not be made mutual." 2. **Finance issues an invoice for the annual platform fee**, payable in full at the start of each annual term, with no discounts and no monthly or quarterly plan. 3. **A Security Assessment** must be completed and passed by Bullhorn's compliance team **before** sandbox access. 4. **Sandbox access** arrives once the assessment passes and the invoice clears. 5. **Validation** — needed before you may promote or list the integration — is a *separate* paid engagement with Bullhorn's Technical Services team. What the fee buys, per Bullhorn: a sandbox, technical resources, and **200,000 API calls per month**, with overage charges beyond that and the option to pre-commit to more volume at a higher annual cost. Things this section deliberately does not tell you, because Bullhorn does not publish them: the fee amount, the turnaround time for each step, the contents of the security assessment, and whether a unified-API platform is eligible at all given the middleware prohibition in §1. **All four are questions for Bullhorn, and all four are business decisions.** Do not fill in security, compliance or commercial claims on the user's behalf — collect the questions and hand them back. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered path with the literal values to paste — the callbacks from §4 — or a ready-to-send message to `partners@bullhorn.com`, then continue once they report back. ## 4. Requesting the credential from Bullhorn Support This is the per-customer path, and it is also the mechanics of how any Bullhorn REST key gets made. Bullhorn's knowledge base documents the request workflow step by step for one specific developer scenario; the shape generalizes, but **note the scoping honestly** when you quote it — Bullhorn has not published an equivalent article for the general partner case. **Who files it:** the customer's authorized support contact, through the Bullhorn Resource Center. A third party may be named: Bullhorn's FAQ on that article says "Can a third-party developer or partner request credentials on behalf of a client? Yes. Include your developer contact information in the ticket. The credentials will be sent to the contact listed as the Owner's Email on the key request." **What the ticket must contain** — put all of it in the first message, because each round trip is business days: - That you are requesting **REST API OAuth credentials** (say REST explicitly; see the SOAP trap in §1). - The **developer contact name and email** the credentials should be sent to (the Owner's Email). - The **purpose** of the credentials and the name of the integration. - The **target environment** (sandbox/staging or production) — one ticket per environment. - The **redirect URI(s)**, exactly, character for character (§5). Bullhorn's FAQ calls the redirect URI "a required part of the OAuth key configuration." - **That the key must be configured to issue refresh tokens.** Bullhorn's OAuth overview says "An OAuth API key *can be configured* to return long-lived refresh tokens in addition to access tokens," and the Getting Started page hedges the same way: "*If* Bullhorn has provided you with the ability to generate refresh tokens…". A key without them forces a full re-authorization every ten minutes. **Ask for them by name.** **What comes back, and what does not.** Bullhorn Support delivers the **Client ID, Client Secret and API Username** through the support case. The **API user's password is not issued** — per Bullhorn, "Before passing the credentials to your developer, you will need to generate a password for the API user." Somebody on the customer side must set it in the Bullhorn front end. Allow "a few business days" for processing, and expect an internal configuration step on Bullhorn's side that can silently be incomplete — Bullhorn's own troubleshooting advice for a key that will not work is to "follow up on your original support ticket to confirm that all processing steps have been completed on your key." **There is no console to re-read the secret in.** Treat it as shown once and store it immediately. Rotation is another ticket: "Contact Bullhorn Support to request a new Client Secret." **The flow itself**, once you hold credentials. Hosts are per customer — `{lane}` comes from §5, and there is no correct value to hardcode: - Authorize: `GET https://auth-{lane}.bullhornstaffing.com/oauth/authorize` with `client_id`, `response_type=code`, `action=Login`, `state` (recommended by Bullhorn, and your CSRF defence) and an optional `redirect_uri`. Bullhorn documents an optional `username`/`password` pair on this URL that skips the login page — **do not use it for a multi-tenant flow**; let the customer type their own credentials on Bullhorn's page. Without them the user gets a Bullhorn login page, then a Terms of Service page which, per Bullhorn, "is only displayed the first time a user's credentials are provided for the specified clientId." - Exchange: `POST https://auth-{lane}.bullhornstaffing.com/oauth/token` with `grant_type=authorization_code`, `code`, `client_id`, `client_secret` and — only if you sent one on authorize — the **same** `redirect_uri`. - Then, and this is the part people miss, **log in** (§6). The access token is not an API credential. ## 5. Redirect URIs, and the swimlane that is not yours to choose ### The callbacks Register **every** callback host in the same ticket. Bullhorn's OAuth overview refers to "the redirect URI(s) that you provide to Bullhorn for your OAuth API key," so more than one is possible, but there is no self-service editor: each change is another ticket and another wait. For this platform these are one per data centre; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` **A trap specific to this connector.** It deliberately does **not** send `redirect_uri` on the authorize request, which is legal — Bullhorn documents the parameter as optional and falls back to what is registered on the key. But Bullhorn does **not** document how it chooses when a key carries *several* registered callbacks and the request names none. Do not guess. Either register **one callback per client ID** — a separate Bullhorn key per data centre, which is the safe reading — or get Bullhorn to state the selection rule in writing and test it. A key registered with all four callbacks and a flow that names none is the kind of thing that works in the US and silently misroutes the EU. Note also that Bullhorn's `redirect_uri` rule is symmetric: if you send one on authorize you **must** send the identical value on the token exchange. Sending on one and not the other is a classic `invalid_grant`. ### The swimlane **This is the fact that sinks most Bullhorn integrations, and it has no analogue at most ATS vendors.** Every Bullhorn customer lives on a numbered cluster, and **the authorization host, the token host and the REST host are all different per cluster**. `auth.bullhornstaffing.com` is not a universal front door you can point every tenant at. Bullhorn's instruction is to discover it, per user, at runtime: > "Run the following GET rest-services/loginInfo request with your API_Username to return the list of correct URLs > for that user: `https://rest.bullhornstaffing.com/rest-services/loginInfo?username={API_Username}`. **If you do not > use the correct URLs for a user, you will receive a 307 redirect to the correct data center. Your code must be > written to handle that 307 redirect.**" A live response (US West, abridged) looks like this — the two fields that matter are `oauthUrl` and `restUrl`: ```json { "oauthUrl": "https://auth-west.bullhornstaffing.com/oauth", "restUrl": "https://rest-west.bullhornstaffing.com/rest-services", "atsUrl": "https://cls31.bullhornstaffing.com", "dataCenterId": 3, "superClusterId": 31 } ``` Five things follow, and each of them is a bug somebody has shipped: 1. **Discovery must happen before the authorize redirect, not after the callback.** You need `oauthUrl` to build the consent URL at all. That in turn means you need the customer's **API username before** they click Connect — which is why it is an input on the connect form rather than something you learn from the token. 2. **`loginInfo` fails open.** An unknown username returned HTTP 200 and a full US-West payload on 2026-09-20 — not a 404. A typo does not fail; it silently authorizes against the wrong lane, and the failure surfaces much later as 307s or an unexplained 401. **Validate the username with the customer, and treat a lane that does not match their browser URL as a red flag.** 3. **Never hardcode the lane list.** Bullhorn publishes one, bylined 2018, which today omits at least the Australian lane (`auth-aus` / `rest-aus`, `superClusterId: 66`) while listing APAC only as `auth-syd`. Both `auth-aus` and `rest-aus` resolve in DNS. The published page is a useful sanity check and a terrible source of truth. 4. **Handle the 307.** Bullhorn says your code must. Many HTTP clients do not follow 307 for POST bodies by default, and a silently-dropped POST body is a maddening failure mode. 5. **The cluster is stable per customer.** Bullhorn: "cluster IDs do not change for a customer, so identifying the correct cluster ID for all users for a given customer is a one-time exercise." Cache the discovered hosts per connection — but re-discover on a 307 rather than trusting the cache forever. If a customer is unsure of their cluster, it is in their own browser URL once logged in (`cls21.bullhornstaffing.com` → CLS21 → the UK data centre). Sandbox clusters are their own lanes again (`…-west9`, `…-east9`, `…-emea9`). ## 6. The two-step token dance, and the refresh token that is not what you think **A Bullhorn access token is not an API credential.** It is a ten-minute ticket whose only purpose is to be traded for a REST session. Getting this wrong produces the most confusing Bullhorn symptom there is: a textbook-clean OAuth exchange followed by a 401 on every single API call. The full sequence, per Bullhorn: | Step | Call | You get | Lifetime | | --- | --- | --- | --- | | 1 | `GET .../oauth/authorize` | authorization code | seconds | | 2 | `POST .../oauth/token` | `access_token`, and `refresh_token` **if the key is configured for it** | access token **10 minutes** | | 3 | `POST https://rest-{lane}.bullhornstaffing.com/rest-services/login?version=*&access_token=…` | **`BhRestToken`** (the session key) **and a `restUrl`** | until the session expires | | 4 | every API call | data | — | | 5 | on `401` | back to step 2 via refresh, then step 3 again | — | Details that matter: - **Use the `restUrl` the login response gives you.** It is not the same string as the `restUrl` from `loginInfo` — it carries the customer's `corpToken`: `https://rest{swimlane#}.bullhornstaffing.com/rest-services/{corpToken}/`. Every subsequent entity call hangs off it. Building paths from the discovery URL instead of the login URL produces 404s that look like missing entities. - **The session key travels in `BhRestToken`.** Bullhorn documents three carriers — "The session key can be provided in the BhRestToken query string, a cookie, or an HTTP header." A header is the sane choice; a query string puts a live session key into every access log you and Bullhorn keep. - **Do not log in per request.** Bullhorn is unusually emphatic: "For performance and load reasons you must **NOT** perform a fresh REST API login before every API request… Note that Bullhorn will apply strict limits to login rates, and may block login requests that occur too frequently." A platform that re-logs-in on every call will be throttled, and the failure will look like an auth bug. - **The REST session's lifetime is not a published number.** Bullhorn says only "until the session expires" and gives you `GET /ping`, which returns `{"sessionExpires": }` and is the documented way to test whether a session is still valid. **Do not hardcode a session TTL from a forum post.** Drive re-authentication off the 401, and use `/ping` when you need to check without spending a real call. - **Refresh tokens are single-use and rotate.** Bullhorn's OAuth overview: "A refresh token expires after it is used once to successfully obtain a new access token and refresh token." The Getting Started page says the same from the other side: "A new refresh token is returned with every new access token. The refresh token has no expiration date/time, but it does expire when a new access token and refresh token are generated." **Persist the new refresh token from every refresh response, transactionally.** A client that replays the original works exactly once. A crash between "refresh succeeded" and "new token saved" permanently bricks that connection. - **Refresh tokens are an option on the key, not a guarantee.** If a key was issued without them, the connection cannot survive ten minutes unattended and no amount of client code will fix it. That is a support ticket (§4). - **Plan for unrecoverable refresh failures.** Bullhorn explicitly puts this on the integrator: "the partner must provide a means to get a new refresh token to account for situations where a refresh token encounters an unrecoverable error. The resource owner may be notified of the failure by email, sms, or another means to inform them go through the full authorization process." A re-consent path is a product requirement here, not a nicety. - **Client credentials go in the request, not an `Authorization` header.** Bullhorn's documented token and refresh calls carry `client_id` and `client_secret` as parameters. HTTP Basic is not documented. - **PKCE and `scope` do not appear.** Do not send them on the strength of habit (§7, Platform state). ## 7. There are no scopes — entitlements are the access control Bullhorn's OAuth has no `scope` parameter, no consent checklist, and no per-app permission model. **What your integration can do is exactly what the authorizing Bullhorn user can do**, decided inside the customer's Bullhorn configuration, invisible to you at authorize time, and unreportable in the token response. This is the second-biggest source of "it works for us and 403s for them." The model, as Bullhorn documents it: - `GET /entitlements/{entityType}` returns the current user's entitlements for an entity, e.g. `["CREATE","READ","READ_DEPARTMENT","UPDATE","DELETE"]`. **This is your pre-flight check** — call it at connection time and tell the customer what is missing, rather than discovering it as a 403 during a sync. - Read, update and delete each come in **owned / department / corporate** flavours: `READ` (own records only), `READ_DEPARTMENT`, `READ_CORPORATE`, and the matching `UPDATE*` / `DELETE*`. `CREATE` is flat. A user with only `READ` sees their own records and an *empty list* for everyone else's — **not an error**. An integration that reports "this customer has 4 candidates" when they have 40,000 is almost always looking at an owned-only entitlement. - `UPDATE_OWNER` is separate, and applies to Candidate, ClientContact, JobOrder, JobSubmission, Lead and Opportunity. Update rights do not imply the right to change an owner. - **Private records need `READ_PRIVATE`.** Without it a user "cannot read private entities owned by other users." - **Private file attachments** need the "View All Private Attachments" entitlement, or ownership, or an explicit share, or department membership. Attachment rights follow the entitlements of the **parent entity**. - **Confidential fields have their own gates**: editing them "as part of a POST /entity call" requires the "Edit Confidential Data" user action entitlement, and the set of confidential fields is a per-customer private-label attribute. - **The silent one.** Bullhorn: "For nested to-many associations for which the user does not have read entitlements, only data for predefined fields is returned. The other fields are returned with a value of 'null'." Missing permissions can arrive as `null`s in a `200`, with no error anywhere. Any mapping that treats `null` as "the customer has no data" will quietly lie. Practical consequences: - **Ask the customer which Bullhorn user will authorize, and get corporate-level entitlements on the entities you need.** The right answer is usually a dedicated API user, which is why Bullhorn Support issues an API Username alongside the key. - **User types are not readable over REST.** If you need to know which user type a mutual customer's API user sits in, the customer has to ask Bullhorn. Do not build a feature that depends on reading it. - Because there are no scopes, **adding a feature never requires re-consent** — it requires the customer's admin to widen a user's entitlements. That is a support conversation you own, and it should be in your setup docs. ## 8. Limits, editions, and what does not count From Bullhorn's knowledge base, for all ATS editions that have API access: | Limit | Value | | --- | --- | | Calls per minute | **1,500**, scoped to the **OAuth Client ID** — shared by every tenant on that client ID | | Total calls per month | **100,000** for a customer key, "unless otherwise agreed upon with Bullhorn" | | Concurrent API sessions | **50** active | | API subscriptions (event subscriptions) | **50** active; unused subscriptions and un-retrieved events have a **30-day TTL** | | Contracted integrator allowance | **200,000 calls/month** included in the API Access Program, overage charged | And the parts that change the arithmetic: - **"API usage associated with validated Bullhorn partners does not count toward your API call limits. API users created by Bullhorn Support for partner integrations also do not count against your API user limit."** Validation is therefore not only a marketing badge — it materially changes whether your traffic eats the customer's budget. - **The per-client-ID rate limit is the multi-tenant killer.** One shared client ID means one shared 1,500/min across every customer you connect. Model this before promising throughput, and raise it with Alliances early. - **Two different 429s.** "Too Many Requests, API rate limit exceeded" means you exceeded 1,500/min for your client ID; "Server API Capacity Exceeded" means the server is loaded and is "unrelated to your call volume." Both are handled the same way: wait one second, retry, repeat — Bullhorn says most clear within ten retries, and warns "Ensure that there are no retry limits for 429 responses in your integration, or your application may not recover correctly." Usefully, **calls that return 429 do not count against your usage limits**. - **Some editions have no API at all.** Bullhorn's limits page: the limits apply to all ATS editions "except ATS Growth (formerly Team Edition). ATS Growth edition does not include access to the Bullhorn API for custom integrations." A separate knowledge-base page lists the requirement as "Front Office Growth or Enterprise edition of Bullhorn." **These two pages use different product names and do not obviously agree** — if edition eligibility is load-bearing for a deal, confirm it with Bullhorn rather than quoting either page. - **Versioning:** older API versions are supported for at least three years after a successor ships, with at least six months' end-of-life notice on the Bullhorn Resource Center. Build against the latest version. - **Listing** happens on the Bullhorn Marketplace, after the paid Technical Services validation engagement (§3). - **Branding:** the Fair Use Policy forbids using Bullhorn's name, trademarks or logos without a written agreement — which includes the connect button and the marketing page, not just the logo file. ## 9. Verify end-to-end Do not stop at "the token came back." On Bullhorn, the token coming back proves almost nothing. 1. Run `loginInfo` for the customer's API username and **read the result out loud to the customer**: does the `atsUrl` cluster match the `cls##` in their own browser URL? If not, the username is wrong (the endpoint will not tell you). 2. Authorize through the platform's real connect flow against a **second** Bullhorn tenant — ideally on a **different swimlane** from the first. A single-lane test proves nothing about discovery, which is the thing most likely to be broken. 3. Confirm the token response actually contains a **`refresh_token`**. If it does not, the key was issued without them (§4) and everything after this is wasted effort. 4. Perform the **login step** and confirm you get both a `BhRestToken` and a `restUrl`, and that your first entity call is built from **that** `restUrl` (with its `corpToken`), not from the discovery URL. 5. **Refresh, then refresh again using the token returned by the first refresh.** Two minutes of work for a failure that otherwise appears ten minutes after the customer walks away. 6. Kill the session deliberately (or wait one out), confirm you get a `401`, and confirm the client recovers by refreshing and re-logging-in rather than by hammering `/login`. 7. Call `GET /entitlements/{entityType}` for every entity the integration touches, as the user who will actually authorize, and compare it against what you need. Then repeat step 4 as a **deliberately under-privileged** user and confirm your code reports a permission problem instead of reporting "no data." 8. Point the client at a deliberately wrong lane and confirm it survives the **307** with its POST body intact. 9. Exercise pagination past the first page, and confirm your 429 handling has **no retry cap**. | Symptom | Cause | | --- | --- | | OAuth exchange succeeds, every API call 401s | The access token was used as an API credential; the `/rest-services/login` step was skipped (§6) | | 307 redirects, or POST bodies vanishing | Wrong data-centre host; client not handling 307 on POST (§5) | | Works for US customers, fails for EU/AU | Hardcoded auth/REST hosts instead of per-user `loginInfo` discovery (§5) | | `loginInfo` returns a plausible US-West lane for a customer who is plainly in the UK | The API username is wrong — the endpoint fails open with a default (§5) | | Entity calls 404 on paths that should exist | REST base built from the discovery `restUrl` rather than the login `restUrl` with its `corpToken` (§6) | | Second refresh fails, first succeeded (`invalid_grant`) | Refresh tokens are single-use; the rotated token was not persisted (§6) | | Connection dies exactly ten minutes after connecting | Key issued without refresh-token capability (§4, §6) | | Connection bricked after a crash mid-refresh | Rotated refresh token lost between "issued" and "saved" — needs a re-consent path (§6) | | `invalid_grant` on the initial exchange | `redirect_uri` sent on authorize but not on token, or vice versa, or not matching the registered value (§4, §5) | | Callback lands on the wrong data centre's host | Several callbacks registered on one key while the flow sends no `redirect_uri` (§5) | | Throttled on `/login` specifically | Logging in before every request; Bullhorn applies strict login-rate limits (§6) | | 403 on one customer only | Authorizing user's entitlements, not a scope or a key problem (§7) | | List returns far fewer records than the customer expects, no error | Owned-only entitlement (`READ` without `READ_DEPARTMENT`/`READ_CORPORATE`) (§7) | | Nested association fields arrive as `null` in a `200` | No read entitlement on the association — Bullhorn returns nulls, not errors (§7) | | Private notes or attachments missing with no error | `READ_PRIVATE` / "View All Private Attachments" not granted (§7) | | `429 Too Many Requests, API rate limit exceeded` under modest per-customer load | 1,500/min is shared across every tenant on that client ID (§8) | | `429 Server API Capacity Exceeded` | Bullhorn-side load, unrelated to your volume; same retry (§8) | | Integration stops recovering from 429s | A retry cap in the client — Bullhorn says there must not be one (§8) | | Customer "already generated an API key" and it does not work | They generated a SOAP/web-services key, not a REST OAuth key (§1) | | Event subscriptions silently stop delivering | 50-subscription cap, or the 30-day TTL on unused subscriptions/un-retrieved events (§8) | | Customer cannot get a key at all | Their edition may not include API access, or Bullhorn declined because an uncontracted third party was named (§1, §8) | ## 10. Hand off — never commit the secret - **Do not** write a client secret, API-user password or session key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Bullhorn says so itself on its own credential-request page: "Do not commit credentials to source control or share them outside your team." The Fair Use Policy goes further and makes API keys **contractually confidential**, and forbids resharing or transferring them. - A `BhRestToken` is a **live session key**, not a derived value. Treat it as a secret with the same care as the client secret, keep it out of URLs and logs, and do not persist it anywhere a support transcript could reach. - If a code change is needed (a callback host, the discovery step, retry behaviour), keep it secret-free and say plainly what the human must set out of band. - Report a secret once so it can be pasted into the secret store, say clearly that it is now in the transcript and can be rotated by ticket, then move on. - Close with: which credential model you ended on and whether Bullhorn has agreed to it in writing; the client ID (or which customer holds which key); where the secret was delivered; the environment and its swimlane; how the hosts are discovered at runtime; whether the key was confirmed to issue refresh tokens; the entitlements the authorizing user needs, in words the customer's Bullhorn admin can act on; the shared per-client-ID rate-limit budget; and whatever is still waiting on Bullhorn. ## Stop and ask Hand back to a human rather than guessing when: **the platform is a connector, middleware or unified-API layer and has no signed Bullhorn agreement** — say so in the first reply, quote the FAQ, and do not start a request that Bullhorn's published policy says will be refused; the API Access Agreement, security assessment or validation engagement needs business, security or compliance claims; someone proposes re-issuing credentials on a live integration (that re-authorizes every customer); a customer's support ticket comes back refused or asks who the third party is; the **data centre cannot be determined** for a customer, or `loginInfo` disagrees with the cluster in their browser URL; a key turns out not to issue refresh tokens and the customer will not re-ticket; the integration will expose Bullhorn data to an AI or LLM feature, or to an MCP server (the Fair Use Policy requires explicit written permission); expected volume approaches the per-client-ID rate limit or the monthly call cap; or the developer pages and knowledge base no longer match the Platform state section above. Note also that this skill was written without browser automation: every fact here came from fetching Bullhorn's public documentation and probing its hosts and its `loginInfo` endpoint directly. Nothing here was read from a logged-in portal, and Bullhorn's documentation is thinner and older than most vendors' — where a fact could not be found on a Bullhorn-owned page, this skill says so rather than filling the gap. A run that also has no browser should hand click paths and ticket text to the user rather than simulating a session. ## References Verified to return HTTP 200 on 2026-09-20. - Get Started with the Bullhorn REST API (data-centre discovery, authorize/token/login calls, 10-minute access token, refresh rotation, login-rate warning) — https://bullhorn.github.io/Getting-Started-with-REST/ - Bullhorn OAuth Authorization (client registration, refresh-token configuration, single-use refresh, terms-of-service screen, clients with and without user agents) — https://bullhorn.github.io/docs/oauth/ - Data-center-specific API URLs (the published lane list — bylined 2018; see Platform state) — https://bullhorn.github.io/Data-Center-URLs/ - API Fair Use Policy (registration requirement, third-party access, credential sharing, AI/LLM and MCP clauses, branding) — https://bullhorn.github.io/api-fair-use-policy/ - Developer FAQ (customer keys by support ticket; partner keys only from Bullhorn; `partners@bullhorn.com`; SOAP key self-service) — https://bullhorn.github.io/FAQ/ - REST API Reference (error codes, `/entitlements`, `/ping` and `sessionExpires`, `BhRestToken` carriers, null-on-no-entitlement behaviour) — https://bullhorn.github.io/rest-api-docs/index.html - REST API Reference (index) — https://bullhorn.github.io/rest-api-docs/ - Entity Reference — https://bullhorn.github.io/rest-api-docs/entityref.html - Bullhorn developer documentation home — https://bullhorn.github.io/ - Bullhorn developer docs index — https://bullhorn.github.io/docs/ - Bullhorn developer guides — https://bullhorn.github.io/guides/ - API Usage Limits, Versioning, and Backward Compatibility (1,500/min per client ID, 100k/month, 50 sessions, 50 subscriptions, the two 429s, edition exclusion, validated-partner exemption) — https://kb.bullhorn.com/ats/Content/BHATS/Topics/understandingBHAPIUsageLimitsVersioningBackwardCompatibility.htm - Request REST API Credentials (the four credential values, "not self-service", what the ticket must contain, API-user password, per-environment keys, secret rotation) — https://kb.bullhorn.com/ats/Content/BHATS/Topics/BHExtensionConfigStarter.htm - Customizing Bullhorn with Third Party Solutions (Marketplace vs custom API build, edition requirement) — https://kb.bullhorn.com/ats/Content/BHATS/Topics/customizingBHWithThirdPartySolutions.htm - Bullhorn knowledge base home — https://kb.bullhorn.com/ - Bullhorn API Access FAQs (the middleware prohibition, annual platform fee, 200k calls, no redlines, onboarding order, validation as a paid engagement) — https://www.bullhorn.com/bullhorn-partner-program-faqs/ - Integrate with Bullhorn (the API Access application form) — https://www.bullhorn.com/become-a-partner/ - Bullhorn Partner Program Guide — https://www.bullhorn.com/partner-program-guide/ - Bullhorn Platform Partner Program (Technical Services-assisted validation engagement; `partners@bullhorn.com`) — https://www.bullhorn.com/partner-program-guide/bullhorn-platform-partner-program/ - Bullhorn Marketplace Partner Engagement Hub — https://www.bullhorn.com/partner-engagement-hub/ - Bullhorn Marketplace (marketing) — https://www.bullhorn.com/marketplace/ - Bullhorn Marketplace: Understanding the Ecosystem — https://www.bullhorn.com/marketplace/understanding-the-partner-ecosystem/ - Bullhorn Marketplace listings — https://marketplace.bullhorn.com/ - Bullhorn support (route to the Resource Center for credential tickets) — https://www.bullhorn.com/support/ - Bullhorn developer articles — https://developer.bullhorn.com/articles - Bullhorn developer documentation — https://developer.bullhorn.com/documentation ## calendly-oauth-app URL: https://docs.unified.to/skills/calendly-oauth-app/SKILL.md # Calendly OAuth2 App Registration Get a working Calendly OAuth client — a developer account, an OAuth application, redirect URIs, a scope set, a client ID, a client secret and a webhook signing key — for a platform that connects many customers' Calendly accounts. The form itself takes two minutes. Four things around it cost a release: 1. **Scopes are new, and new apps start with nothing.** Calendly shipped authorization scopes on **3 March 2026**. Calendly's rule is blunt: *"For newly created OAuth apps and new Personal Access Tokens, no API access is granted until scopes are explicitly requested and approved"* — while *"Legacy OAuth apps and Personal Access Tokens issued before the introduction of scoped permissions retain full access."* So an app registered today does **not** behave like the app registered in 2024 that your connector may still be running on, and a client that sends no `scope` parameter gets a token that authenticates perfectly and is authorised for nothing (§5). 2. **Three secrets, shown once, never again.** Client secret *and* webhook signing key are displayed only on the creation screen. Calendly states plainly you cannot see them when editing the app afterwards. Losing the signing key means emailing developer support (§6). 3. **Refresh tokens are now single-use and rotate.** Calendly moved to OAuth 2.1 rotation with an enforcement deadline of **31 August 2026** — already past as of this writing. A client that reuses a refresh token gets `invalid_grant` and drops the customer (§6). 4. **The customer's plan and role, not your app, decide what works.** The API runs on every plan including Free, but **webhooks require a paid plan**, three endpoints are Enterprise-only, Notetaker/meeting-recap data needs **Standard Plus or Teams Plus**, and organization-wide reads need the authorizing user to be an **owner or admin**. None of this is registerable — it is a property of whoever connects (§7). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name** | Shown to users at consent; editable later (§3) | | **GitHub or Google account** to own the developer account | Calendly's developer account is **not** a Calendly user account (§2) | | **Web or native** app kind | Web = confidential client, Basic auth at the token endpoint (§3) | | **Sandbox or Production**, or both | Two separate apps, two separate credential sets (§3) | | **Redirect URIs** | Every callback host the platform serves — and confirm how many this app may hold (§4) | | **Which objects the connector reads and writes** | Decides the scope list, which may not be editable later (§5) | | **Whether webhooks are in scope** | Adds `webhooks:*`, a signing key to store, and a paid-plan requirement on the customer (§7) | | **Existing app or new one?** | A new client ID orphans every existing customer connection (§1) | ## Quick Start 1. Confirm a **new** app is really needed — new credentials re-authorize every customer (§1). 2. Sign up for a **developer account** with GitHub or Google; it is separate from any Calendly account (§2). 3. Create the app: name, web/native, **Sandbox or Production**, redirect URI, **scopes** (§3). 4. Enter **every** redirect URI, and find out whether this app accepts more than one (§4). 5. Pick scopes from the catalogue deliberately — a new app has zero access until they are granted (§5). 6. On the creation screen, copy **client ID, client secret and webhook signing key**. They are not shown again (§6). 7. Confirm the client sends `scope`, handles **2-hour** access tokens and **rotating single-use** refresh tokens (§6). 8. Check plan/role gating before promising a customer anything (§7). 9. Authorize from a **second, unrelated Calendly account** on a realistic plan, then hand the credentials over (§8, §9). ## Platform state (verified 2026-09-20 — re-verify before trusting) Calendly rewrote most of this surface between March and August 2026. Anything written before 2026 describes an unscoped, non-rotating OAuth 2.0 and will be wrong in the two places that matter. - **OAuth 2.1 with scopes.** Calendly's own docs now say "OAuth 2.1", and a scope catalogue shipped on **3 March 2026** ("Authorization scopes for API access"). `:write` **implicitly includes** the matching `:read` in the same domain. Users **can decline** individual scopes at install. - **Backward compatibility is explicit and asymmetric.** Pre-scopes apps and PATs keep full access; new ones get none until scopes are granted. Calendly also states that *"When a legacy token is refreshed, it is automatically migrated to the scoped token format,"* transparently and without re-authorization — the docs do not say which scopes such a migrated token ends up with. Treat that as an open question, not a guarantee. - **Refresh token rotation.** Single-use rotating refresh tokens, per OAuth 2.1. Enforcement deadline **31 August 2026**; reusing a spent refresh token returns HTTP 400/401 with `invalid_grant`. The older reference page still carries the pre-rotation sentence "Refresh Tokens don't expire until they are used" — that line is consistent with rotation, not an exemption from it. - **Access tokens last 2 hours** (`expires_in: 7200`), and **only 8 OAuth tokens per user may be requested per minute**. - **PKCE is required for native apps and recommended, not enforced, for web apps.** Calendly: *"While we will not enforce PKCE for web applications, we recommend using PKCE conforming to the RFC 7636 specification for both web and native applications."* `S256` is the documented method. - **Sandbox and Production are app-level environment settings**, chosen at creation and editable afterwards. The only difference Calendly documents is the redirect-URI rule: Sandbox permits `http://localhost:PORT`, Production requires HTTPS. Calendly does **not** document a separate sandbox API host, a separate auth host, or a pool of disposable test accounts — both environments use `auth.calendly.com` and `api.calendly.com`. Do not assume Sandbox is isolated from real data; confirm before pointing it at anything live. - **No documented app review or approval gate.** Creating a Production OAuth app is self-serve; Calendly's docs describe no submission, security review or listing requirement before other organizations can authorize it. A *partner* relationship (`calendly.com/partners`) is a separate, sales-led conversation, not a prerequisite. - **Recent API surface, in case someone assumes it is missing:** Contacts API and webhooks (27 May 2026), Notetaker / meeting recaps API and webhooks (22 July 2026), Contact custom fields (25 August 2026), Scheduling API "Create Event Invitee" (Oct 2025, rate limits revised 15 April 2026). - **Two different OAuth surfaces exist, and they are not interchangeable.** Classic apps use `auth.calendly.com`. Separately, `https://calendly.com/.well-known/oauth-authorization-server` advertises `calendly.com/oauth/authorize`, `calendly.com/oauth/token`, a dynamic-registration endpoint, `token_endpoint_auth_methods_supported: ["none"]` and S256 — that is the authorization server for Calendly's **MCP** server (`mcp.calendly.com`, scopes `mcp:scheduling:read` / `mcp:scheduling:write`). Do not wire a connector to those hosts by following the well-known document. If the developer portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID and secret**, and every existing customer token is bound to the old one — every customer re-authorizes. Reuse the existing app for: adding a redirect URI, renaming, switching environment, or diagnosing an authorization failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, a Sandbox counterpart to a Production app, or — the Calendly-specific case — because the existing app is a **legacy pre-scopes app** and someone wants a scoped one. That last migration is not a config tweak: the legacy app has full access by grandfathering, and a scoped replacement starts with nothing until every scope is requested and every customer re-consents. Say which path you are taking before you touch the portal. There is one more Calendly-specific reason to think twice: if the app's scope set turns out **not to be editable after creation** (§5), registering the wrong scopes is a new-app problem, not an edit. ## 2. The developer account is not a Calendly account - Sign up at the developer portal (`https://developer.calendly.com`) using the **Sign Up** control, **with a GitHub or Google account**. Calendly's instruction is explicit: *"Note: This is not your Calendly user account and is not associated with your Calendly user account."* The FAQ repeats it: *"For the v2 API, the developer's account is not connected to their Calendly account even if the same email address is utilized."* - Practical consequence: the person who owns the OAuth app need not be, and is not automatically, a Calendly user. Use a team-controlled GitHub/Google identity, not a person who might leave. Record whose it is. - You still need at least one **real Calendly account** to test against (§8) — and, for anything organization-scoped, one whose user is an **owner or admin** (§7). - Personal access tokens are generated from the *Calendly* side instead (`calendly.com/integrations/api_webhooks`), are not retrievable after creation, and now take a scope list of their own. They are the right answer for one company's internal use and the wrong answer for a multi-tenant connector — every action would be attributed to one person, and the token dies when their password or login email changes (§7). Hand control back for anything only a human can do: OAuth sign-up with GitHub/Google, email verification, 2FA, accepting terms. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the redirect URIs from §4 and the scope list from §5 — plus the warning that the secret and signing key appear exactly once, and continue once they report back. ## 3. Create the app Developer portal → create a new OAuth application. The form, in Calendly's own order: | Field | What it decides | | --- | --- | | **Name of your application** | What users see. Editable later | | **Kind of application** — web or native | **Web** is a confidential client: client ID + secret, Basic auth at the token endpoint. **Native** is a public client: `client_id` in the body, PKCE with a `code_verifier`. A server-side multi-tenant connector is **web**. Editable later | | **Environment** — Sandbox or Production | Sandbox allows `http://localhost:…` redirect URIs; Production requires HTTPS. Editable later | | **Redirect URI** | §4 | | **Scopes** | §5 — **not** listed among the attributes Calendly documents as editable later | Calendly's guidance: *"We recommend starting with Sandbox for development and creating a second application for Production when ready to go live with customer data."* Two apps means two client IDs, two secrets and two signing keys — keep them clearly labelled, because the hosts are identical and nothing in a token tells you which app minted it except the client ID. The documented **editable** attributes after creation are: name of app, kind of app, environment type, redirect URI. Scopes are absent from that list. Before you submit the form, check the portal for a scope editor on an existing app and **report what you actually see** — if scopes really are fixed at creation, the scope decision in §5 is irreversible without a new app and a customer-wide re-authorization. ## 4. Redirect URIs Register **every** callback host the platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` What to watch: - **Production requires HTTPS.** Only the Sandbox environment permits `http://` with a `localhost` domain (`http://localhost:1234` is Calendly's own example). A production app cannot hold a localhost dev callback. - **Calendly does not document how many redirect URIs one app may hold.** The creation form and the edit list both name "Redirect URI" in the singular, and no doc states a maximum. For a four-region platform that is the single most consequential unknown on this page: if the field takes one value only, the choices are one app per data center (four client IDs, four secrets, four signing keys) or a single shared callback host that forwards. **Look at the field, report what it is, and do not invent a fan-out.** - **`redirect_uri` is required in the token-exchange body** for the authorization-code grant, and Calendly says it *"must be the same as the `redirect_uri` passed in the authorization request."* A mismatch fails at the exchange, not at save time. Treat matching as exact — no trailing-slash or subpath forgiveness is documented. - Native apps use a custom scheme (Calendly's example is `com.site.app://auth/calendly`). Irrelevant to a server-side connector; do not copy it in. ## 5. Scopes Space-delimited on the authorize URL, exactly as Calendly's sample shows: ``` https://auth.calendly.com/oauth/authorize ?client_id=CLIENT_ID &redirect_uri=REDIRECT_URI &response_type=code &scope=scheduled_events:read webhooks:write ``` Rules that actually bite: 1. **`:write` implicitly includes `:read`** within the same domain. Requesting both is harmless but redundant. 2. **Users can decline scopes at install.** A granted token can therefore carry less than you asked for. The token response includes a `scope` field, and the introspection endpoint returns `scope` for a live token — check what you were actually given rather than what you requested. 3. **Requests are not additive.** Calendly: *"For existing installs, you may need to reauthorize the app to request additional scopes."* Adding an object to the connector later is a customer-visible re-consent event. 4. **Webhooks need two things**: `webhooks:write` to create the subscription, *and* the read scope for the event family you subscribe to. Missing the second is the classic "subscription created, nothing ever arrives". ### The catalogue (as published 2026-09-20) | Category | Scope | Covers | | --- | --- | --- | | Scheduling | `availability:read` | `/user_busy_times`, user and event-type availability schedules | | | `availability:write` | `PATCH /event_type_availability_schedules/{uuid}` | | | `event_types:read` | `/event_types`, available times, event type memberships | | | `event_types:write` | create/update event types, one-off event types | | | `locations:read` | `/locations` | | | `routing_forms:read` | routing forms and submissions | | | `shares:write` / `scheduling_links:write` | single-use scheduling links (with / without customization) | | | `scheduled_events:read` | scheduled events, invitees, no-shows | | | `scheduled_events:write` | create invitees, cancel events, mark no-shows | | User management | `organizations:read` | organization, memberships, invitations | | | `organizations:write` | invite/remove organization users | | | `groups:read` | groups and group relationships | | | `users:read` | `/users/{uuid}`, `/users/me` | | Webhooks | `webhooks:read` / `webhooks:write` | list/inspect, create/delete subscriptions | | Contacts | `contacts:read` / `contacts:write` | contacts and contact custom field values | | Notetaker | `meeting_recaps:read` | recaps, highlights, transcripts | | | `meeting_recaps:write` | update/delete recaps (includes read) | | Security & compliance | `activity_log:read`, `data_compliance:write`, `outgoing_communications:read` | Enterprise-gated surfaces (§7) | Every endpoint page in Calendly's reference carries a **"Required scopes"** callout. Derive the list from the endpoints the connector actually calls; do not guess a scope name. > **As of 2026-09-20, Unified.to's Calendly connector** points at `https://auth.calendly.com/oauth/authorize` and > `https://auth.calendly.com/oauth/token` with API base `https://api.calendly.com`, and its object coverage implies > this scope set: `availability:read`, `event_types:read`, `scheduled_events:read` + `scheduled_events:write`, > `organizations:read`, `groups:read`, `users:read`, `contacts:read` + `contacts:write`, `meeting_recaps:read`, and > `webhooks:read` + `webhooks:write`, with `users:read` and `organizations:read` as the minimum login pair. **But the > authorize URL it builds carries only `client_id`, `response_type` and `redirect_uri` — it sends no `scope` > parameter at all.** That is survivable against a legacy pre-scopes app, which is grandfathered to full access, and is > a silent failure against any app registered after 3 March 2026, which starts with no access. If you are registering > a *new* app for this connector, raise that with the connector's owner before anyone ships it: a new client ID > without a `scope` parameter produces tokens that authenticate and then 403. Confirm all of this with the connector's > owner rather than treating this paragraph as current. ## 6. Capture the credentials The creation screen shows three values. Copy all three, now: - **Client ID** - **Client secret** — *"Be sure to copy these values as you will not be able to access the Client Secret or Webhook signing key again."* - **Webhook signing key** — for an OAuth app, Calendly generates **one signing key covering all webhooks created by that application**. It produces the `Calendly-Webhook-Signature: t=…,v1=…` header, which carries a timestamp for replay protection. If it is lost, the only documented recovery is emailing `support+developer@calendly.com`. Endpoints, fixed for everyone: | Purpose | URL | | --- | --- | | Authorize | `GET https://auth.calendly.com/oauth/authorize` | | Token exchange and refresh | `POST https://auth.calendly.com/oauth/token` (form-encoded) | | Introspect | `POST https://auth.calendly.com/oauth/introspect` | | Revoke | `POST https://auth.calendly.com/oauth/revoke` | | API base | `https://api.calendly.com` | **How credentials travel.** Calendly splits this by app kind: *"Web clients: pass client_id and client_secret via Basic HTTP Authorization header. Native clients: include client_id as a body param within the API call."* Introspect and revoke are different again — both take `client_id` **and** `client_secret` in the form body. **Token behaviour:** - `token_type: Bearer`, **`expires_in: 7200`** — two hours. A connector must refresh, not re-authorize. - The token response also carries **`owner`** (a user URI) and **`organization`** (that user's current organization URI), plus `created_at` and `scope`. That is enough to key a connection without a separate `/users/me` call. - **Refresh tokens are single-use and rotate.** Every successful `POST /oauth/token` returns a new access token *and* a new refresh token; the one you just used is revoked immediately. Overwrite storage on every response and never keep the old value. On 400/401 `invalid_grant`, clear the tokens and send the user back through authorization — do not retry. - **Rate limit on token issuance: 8 OAuth tokens per user per minute.** A retry storm or a fan-out that refreshes per request will hit it. - **Tokens are revoked by ordinary account maintenance.** Calendly lists three conditions that revoke both OAuth and personal access tokens: the account's **login email**, **password**, or **login method** changes. A customer changing their password silently kills your connection; that is not a bug in your client. Rotating the client secret breaks every exchange and refresh until the new value is deployed; live access tokens run out their two hours. Never rotate without explicit go-ahead and a cutover plan. Report the secret and signing key once so the user can paste them into their secret store, say plainly that they are now in the transcript, and move on. > **As of 2026-09-20, Unified.to's Calendly connector** exchanges and refreshes with a form-encoded POST using **HTTP > Basic** client authentication (client ID as username, secret as password), keeping the credentials out of the body — > the web-client pattern Calendly documents — and sends `redirect_uri` on both the exchange and the refresh. It uses > the result as a `Bearer` token. It **does not send PKCE** (no `code_challenge`/`code_challenge_method`), which is > permitted for web apps but means it forgoes the protection Calendly recommends. It is also configured on the > assumption that **Calendly does not round-trip the `state` parameter**, and carries the flow's state out of band > instead — consistent with Calendly's docs, which never list `state` among the authorize parameters, but worth > re-testing at the consent screen rather than assuming. It additionally supports a **personal access token** as a > non-OAuth alternative for single-account customers. Confirm all of this with the connector's owner. ## 7. Plans, roles, scoping and limits — what your app cannot fix This is the section that decides whether customers think the connector works. **Plan gating (customer-side, from Calendly's own help centre and FAQ, 2026-09-20):** | Capability | Requirement | | --- | --- | | REST API reads and writes generally | **Any plan, including Free** | | Webhook subscriptions | **Paid**: Professional, Standard, Standard Plus, Teams, Teams Plus or Enterprise | | Scheduling API endpoints | Same paid tiers as webhooks | | List activity log entries; delete invitee data; delete scheduled-event data | **Enterprise only** | | Notetaker / meeting recaps (the data behind `meeting_recaps:*`) | **Standard Plus and Teams Plus** | The trap: a Free-plan customer authorizes cleanly, reads event types and scheduled events fine, and then webhook registration fails — so the connector looks broken only for real-time sync. A customer without Standard Plus/Teams Plus gets no recaps at all, no matter what scopes were granted. **Your OAuth app cannot grant any of this**; it is the connecting user's subscription. Calendly is also explicit that for a public OAuth integration the webhook capability follows *the connecting user's* tier, not yours — so your own test account's plan tells you nothing about a customer's. **Role gating.** A Calendly token sees what its user sees: - **User role** — data linked to that user only, including user-scoped webhooks. - **Owner/Admin** — organization-wide API calls and organization-scoped webhooks. So "connect our Calendly" from a non-admin produces a connection that sees one person's calendar. Ask which role the customer will connect as, and say it in the summary. **User vs organization scoping, concretely.** Everything hangs off two URIs, both returned in the token response (`owner`, `organization`) and by `GET /users/me`. Organization-scoped webhooks survive their creator being removed from the organization or demoted from admin to user; **user-scoped webhooks stop working when that user is removed**. A user removed from an organization becomes a solo account, and their historical events stay with the organization. **Webhook subscription scopes are per event family, and not all of them allow `organization`.** From Calendly's create-subscription reference: | Event | Allowed subscription scopes | Required auth scope | | --- | --- | --- | | `invitee.created`, `invitee.canceled`, `invitee_no_show.*` | `organization`, `user`, `group` | `scheduled_events:read` | | `event_type.created/updated/deleted` | `organization`, `user`, `group` | `event_types:read` | | `contact.created/updated/deleted` | `organization`, `user`, `group` | `contacts:read` | | `routing_form_submission.created` | `organization` **only** | `routing_forms:read` | | `meeting_recap.created/updated/deleted` | **`user` only** | `meeting_recaps:read` | `webhooks:write` is required on top, for all of them. The meeting-recap row is the one people get wrong: recap webhooks cannot be registered at organization scope, so a connector that registers everything organization-wide will fail on recaps — and at organization scale it needs one subscription per user, not one per tenant. **Rate limits are per user, not per app:** | Limit | Value | | --- | --- | | Paid plans | **500 requests per user per minute** | | Free plan | **50 requests per user per minute** | | OAuth token issuance | 8 tokens per user per minute | | `Create Event Invitee` — Trial | 5 per user per day | | `Create Event Invitee` — Paid, non-Enterprise | 10/min, 50/hour, 100/day | | `Create Event Invitee` — Enterprise | 500 per user per minute | Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (seconds, typically 60); 429s should be handled with backoff. Per-user limits mean one noisy customer cannot throttle another — but a connector that fans out across one admin's whole organization on that admin's token can throttle itself. **Review and listing.** Calendly documents **no review or approval step** for a Production OAuth app: create it, and other organizations can authorize it. That also means no security-review checklist exists to hide behind — the scope set and the plan gating are the whole story. A commercial partnership or marketplace presence is a separate, sales-led route via `calendly.com/partners`, not a technical prerequisite. If someone asserts an approval queue exists, ask them for the Calendly page that says so. ## 8. Verify end-to-end Testing with the developer account's own Calendly account, on whatever plan it happens to have, proves almost nothing. Test the path a customer takes: 1. Authorize from a **second, unrelated Calendly account**, through the platform's real connect flow, on a plan that matches your customers — and once as a **non-admin user**, to see how much smaller the world gets. 2. At the consent screen, read the permissions listed. Confirm they match the scopes you registered, and try **declining** one to see what your client does with a partial grant. 3. Confirm the token response carries `access_token`, `refresh_token`, `expires_in: 7200`, `owner`, `organization` and `scope`. Introspect the token and compare `scope` against what you asked for. 4. **Refresh twice in a row**, using the token returned by the first refresh for the second, and confirm the *first* refresh token is now rejected. That is the only test that catches a client which ignores rotation. 5. Call one endpoint per object the connector maps, including `GET /users/me` and one organization-scoped list, so a missing scope or a non-admin role surfaces now. 6. If webhooks are in play: create a subscription at the scope the event family actually allows, and verify the `Calendly-Webhook-Signature` header against the signing key. Do this on a **paid** account — it cannot work on Free. | Symptom | Cause | | --- | --- | | Token works, every call 403s "insufficient permissions" | New (post-March-2026) app with no scopes granted, or the client sends no `scope` parameter (§5) | | One object 403s, the rest work | That endpoint's required scope was not requested, or the user declined it (§5) | | `invalid_grant` on refresh, customer dropped | Refresh token reused — rotation is enforced; store the new one from every response (§6) | | Everything 401s after two hours | Client is not refreshing; access tokens live 7200s (§6) | | A working connection dies with no deploy | Customer changed login email, password or login method — all three revoke tokens (§6) | | `invalid_client` / 401 at token exchange | Credentials not sent as HTTP Basic for a web app, or sent in the body instead (§6) | | Exchange fails right after a clean consent | `redirect_uri` missing from the token body, or not byte-identical to the authorize value (§4) | | Webhook creation fails for a customer | Free plan — webhooks need a paid tier; or `webhooks:write` not granted (§5, §7) | | Subscription created, no events ever arrive | Missing the event family's read scope, or wrong subscription scope (§5, §7) | | Meeting-recap webhook rejected at organization scope | Recap events allow `user` scope only (§7) | | Recaps always empty | Customer is not on Standard Plus / Teams Plus (§7) | | Connection sees only one person's data | Authorizing user has the User role, not owner/admin (§7) | | 429 during onboarding backfill | 50/min on Free, 500/min on paid, per user; obey `X-RateLimit-Reset` (§7) | | 429 at the token endpoint | 8 token requests per user per minute (§6) | | Activity log or data-deletion endpoints 403 | Enterprise-only endpoints (§7) | ## 9. Hand off — never commit the secret - **Do not** write the client secret, the webhook signing key or a personal access token into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - Remember there are **two** one-shot secrets here, not one. A handoff that forgets the signing key means webhook signature verification can never be added without emailing Calendly support. - If a code change is needed (a redirect host, adding the `scope` parameter, refresh-token rotation, per-user recap subscriptions), keep it secret-free and say what the human must set out of band. - Close with: app name, the GitHub/Google identity that owns the developer account, **Sandbox or Production**, web or native, the client ID, where the secret and signing key were delivered, the redirect URIs registered and how many the app accepts, **the exact scope strings requested**, whether scopes proved editable after creation, the plan and role a customer needs for the objects in play, and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the app's scope set cannot be edited after creation and the right list is unclear (it becomes a new-app, re-authorize-everyone decision); the redirect-URI field turns out to accept only one value, so a multi-region platform needs several apps; an existing **legacy pre-scopes** app is in play and someone proposes replacing it with a scoped one; the connector would need its authorize call changed to send `scope` before a new app can work; Sandbox is about to be pointed at real customer data; a customer's plan or role blocks the objects they were sold (Free-plan webhooks, non-Standard-Plus recaps, non-admin organization reads); someone proposes personal access tokens in place of OAuth for a multi-tenant connector; the webhook signing key is already lost and support must be emailed; or the developer portal does not match the **Platform state** section above. ## References Calendly-owned pages only; every URL below returned HTTP 200 on 2026-09-20. The developer portal and the OAuth app form require sign-in. - Developer portal — https://developer.calendly.com/ - Build with Calendly (getting started) — https://developer.calendly.com/docs/getting-started/overview - Introduction to the API (auth choice, permissions) — https://developer.calendly.com/docs/getting-started/introduction - Frequently asked questions (plan gating, token revocation, webhook scoping) — https://developer.calendly.com/docs/getting-started/frequently-asked-questions - Authentication overview (OAuth 2.1 vs personal access tokens) — https://developer.calendly.com/docs/authentication/overview - Creating an OAuth app — https://developer.calendly.com/docs/authentication/creating-an-oauth-app - Authorization scopes (the catalogue, backward compatibility, troubleshooting) — https://developer.calendly.com/docs/authentication/scopes - How to handle single-use refresh tokens — https://developer.calendly.com/docs/authentication/refresh-token-rotation-guide - Personal access tokens — https://developer.calendly.com/docs/authentication/how-to-authenticate-with-personal-access-tokens - Get Authorization Code — https://developer.calendly.com/api-docs/calendly-o-auth/o-auth/get-oauth-authorize - Get Access Token (exchange and refresh, Basic auth, 2-hour tokens) — https://developer.calendly.com/api-docs/calendly-o-auth/o-auth/post-oauth-refresh-token - Introspect access/refresh token — https://developer.calendly.com/api-docs/calendly-o-auth/o-auth/post-oauth-introspect - Revoke access/refresh token — https://developer.calendly.com/api-docs/calendly-o-auth/o-auth/post-oauth-revoke - Create webhook subscription (allowed scopes per event) — https://developer.calendly.com/api-docs/calendly-api/webhooks/create-webhook-subscription - Webhook signatures — https://developer.calendly.com/api-docs/overview/webhooks/webhook-signatures - Create a webhook subscription (guide) — https://developer.calendly.com/docs/api-guides/receive-data-from-scheduled-events-in-real-time-with-webhook-subscriptions - Rate limits — https://developer.calendly.com/api-docs/overview/rate-limits - API conventions — https://developer.calendly.com/api-docs/overview/api/api-conventions - Changelog — https://developer.calendly.com/release-notes - Authorization scopes for API access (3 March 2026) — https://developer.calendly.com/release-notes/2026/3/3 - Contacts API and webhooks (27 May 2026) — https://developer.calendly.com/release-notes/2026/5/27 - Notetaker API and webhooks (22 July 2026) — https://developer.calendly.com/release-notes/2026/7/22 - Contact custom fields API and webhooks (25 August 2026) — https://developer.calendly.com/release-notes/2026/8/25 - OAuth authorization server metadata (MCP surface — not the app OAuth host) — https://calendly.com/.well-known/oauth-authorization-server - Help: Calendly API overview (plans, user vs admin scopes) — https://calendly.com/help/calendly-api-overview - Help: Notetaker overview (Standard Plus / Teams Plus) — https://calendly.com/help/notetaker-overview - Help: Contacts overview — https://calendly.com/help/contacts-overview - Pricing and plan names — https://calendly.com/pricing - API & webhooks page (personal access tokens) — https://calendly.com/integrations/api_webhooks - Partner with Calendly — https://calendly.com/partners - Developer community — https://community.calendly.com/developer-community-60 ## clickup-oauth-app URL: https://docs.unified.to/skills/clickup-oauth-app/SKILL.md # ClickUp OAuth2 App Registration Get a working ClickUp OAuth2 client — an app, redirect URLs, a client ID and a secret — for a platform that connects many customers' ClickUp Workspaces. ClickUp's registration form is the smallest in this whole skill collection: a name and a redirect URL. Everything expensive here is downstream of that form, and four things are not obvious: 1. **ClickUp has no OAuth scopes.** None. There is no `scope` parameter, no consent checkboxes, no scope catalogue. The token inherits the authorizing user's permissions **wholesale**. Least privilege is therefore a *which human authorizes* problem, not a scope problem (§5). 2. **The user picks Workspaces at consent, and can pick fewer than you expect.** A token that authenticates perfectly can be blind to the exact Workspace your customer cares about, and the error says "Team not authorized" — a phrase that appears nowhere in your code (§6). 3. **Tokens do not expire and there is no refresh token.** The token response is a single field. That removes an entire class of bugs and creates a different one: a long-lived bearer credential with no documented server-side revocation endpoint (§7). 4. **The app lives inside one customer-facing Workspace's settings**, created by an owner or admin of that Workspace. There is no separate developer portal, no app review and no listing gate — and no obvious owner if that employee leaves (§2, §3). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name** | Shown to users at the consent screen; this is the whole branding surface | | **ClickUp account** that will own the app, and **which Workspace** it is created in | Must be that Workspace's owner or admin (§2) | | **Redirect URLs** | Every callback host your platform serves (§4) | | **Which ClickUp plan** the app's home Workspace and your customers are on | Decides rate limits and which endpoints exist at all (§8) | | **Whether customers will authorize as an admin or as a limited member** | The only lever you have on access breadth (§5) | | **Whether webhooks are in scope** | Changes the verification you must do (§8, §9) | | **New app, or an edit to an existing one?** | A new client ID re-authorizes every customer (§1) | ## Quick Start 1. Confirm a **new** app is needed — existing customer connections are bound to the current client ID (§1). 2. Sign in as an **owner or admin** of the Workspace that will host the app (§2). 3. **Settings → Apps → Create new app**; name it and add a redirect URL (§3). 4. Add **every** redirect URL, exactly, https only (§4). 5. Skip the scope step — there isn't one. Read §5 anyway; it is the security answer you will be asked for. 6. Understand Workspace selection before you write a connect flow (§6). 7. Capture the client ID and secret; note that the token never expires and there is no refresh token (§7). 8. Check the rate-limit tier and the Enterprise-only endpoint list against what the connector calls (§8). 9. Verify from a **second account in an unrelated Workspace**, on a non-Enterprise plan (§9). 10. Hand the credentials over — never commit them (§10). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The docs live at `developer.clickup.com`.** The old `clickup.com/api` documentation host now redirects there. Anything that still describes a "team settings → Integrations → ClickUp API" click path is stale; the current path is **avatar → Settings → Apps**, and ClickUp's own deep link is `https://app.clickup.com/settings/apps`. - **Only Workspace owners or admins can create OAuth apps.** ClickUp states this plainly on the authentication page. - **There are no OAuth scopes.** The published v2 OpenAPI specification contains no `scope`, no `code_challenge` and no `refresh_token` anywhere. The only occurrences of the word "scope" in it are about Custom Fields. - **Authorization URL is `https://app.clickup.com/api`** — a consent page, despite looking like an API route. The token endpoint is the differently-hosted `https://api.clickup.com/api/v2/oauth/token`. Mixing the two hosts up is the single most common first-day mistake. - **`state` is documented as an optional extra parameter** on the authorize URL. - **The token response is documented as `{ "access_token": "..." }`** — `access_token` is the only required property. No `refresh_token`, no `expires_in`, no `token_type`, no `scope`. - **"The access token currently does not expire. This is subject to change."** ClickUp says this twice, on the authentication page and in the FAQ. The hedge is theirs; treat expiry as something that could appear. - **Rate limits are per token, per minute, and changed shape from the older published numbers.** Current table: Free Forever / Unlimited / Business **100**, Business Plus **1,000**, Enterprise and Enterprise Plus **10,000**. `429` responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. - **Both v2 and v3 are live and authenticate identically** — same host, same `Authorization` header, one credential. v3 is not a migration you must do; it is where the newer surfaces (Docs, Chat, audit logs, attachments, ACLs) are. - **There is no app review, no directory submission gate and no install cap** documented for using the API. The App Center and the partner program are marketing surfaces, not a prerequisite for serving customers. - **No OAuth revocation endpoint is documented.** There is no `/revoke`, no `/introspect`, no refresh route — the only OAuth route in the v2 specification is the token exchange. If Settings → Apps does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer token is bound to the old one** — every customer re-authorizes and re-picks their Workspaces. Reuse the existing app for: adding a redirect URL, replacing a leaked secret, or diagnosing an authorization failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, a deliberate environment split, or — the ClickUp-specific case — because the existing app was created inside a Workspace the company no longer controls (§2). Say which path you are taking before you touch anything. One consolation specific to ClickUp: because there are no scopes and no consent-time capability list, **you never have to re-authorize customers just to widen what the connector reads**. Adding a new object type to the integration changes nothing about the grant. That is unusual, and it is worth saying out loud when someone assumes otherwise. ## 2. Account, and the ownership problem nobody plans for - Sign in at `https://app.clickup.com`. Settings live under your avatar in the upper-right corner → **Settings** → **Apps** in the sidebar, or go straight to `https://app.clickup.com/settings/apps`. - **You must be an owner or admin of the Workspace** you create the app in. A plain Member cannot. - **There is no separate developer account and no developer portal.** The app is an object inside an ordinary customer-facing ClickUp Workspace's settings, alongside that Workspace's personal API token generator. That last point is the trap. The credential your whole multi-tenant integration depends on is administered from one company Workspace, by whoever has owner or admin there. Before you create anything, settle: - **Which Workspace hosts the app.** Prefer a company-controlled Workspace whose admin list is managed, not a personal or trial Workspace someone spun up. - **Who can see and reset the secret.** Anyone who is an owner/admin of that Workspace and can reach Settings → Apps. - **What happens when the creating employee leaves.** ClickUp does not document a transfer-ownership flow for an OAuth app. Make sure at least two people who will still be there hold owner/admin on the host Workspace, and record the app name, client ID and host Workspace somewhere outside ClickUp. Hand control back to the user for anything only they can do: signup, email verification, 2FA, accepting terms, being promoted to admin. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user the exact ordered click path and the literal redirect URLs from §4 to paste, and continue once they report back with the client ID. ## 3. Create the app **Settings → Apps → Create new app.** ClickUp asks for two things: 1. **App name.** This is what the user sees when they land on the consent page. There is no logo, no description, no privacy-policy field and no support URL in the OAuth app form — so the name is your entire trust surface. Use the product name a customer will recognise, not an internal codename. 2. **Redirect URL** — see §4. On save you are shown a **`client_id`** and a **`secret`**. That is the whole registration. There is no app type to pick (public vs confidential), no distribution setting, no environment toggle, no capability list and no submission step. Anything a runbook tells you to configure beyond these two fields does not exist in this portal. ClickUp does not publish how many apps a Workspace may hold, whether the secret can be re-read after creation, or what editing and deleting an app does to issued tokens. **Treat the secret as shown once**: capture it into the secret store at creation rather than assuming you can come back for it, and if the portal does let you re-read it, note that as a finding rather than relying on it. ## 4. Redirect URLs Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` What ClickUp documents about them: - **Multiple are supported.** The create-app step says "a redirect URL" (singular), but the error for a mismatch is *"Redirect URI does not match the redirect **uris** of this application"* (`OAUTH_007`), and the app holds a set. If the form only ever accepts one value, stop and report that rather than registering four separate apps. - **The `redirect_uri` you send must match a registered value.** A mismatch is `OAUTH_007`; omitting it entirely is `OAUTH_017` *"Redirect URI not passed"*. Treat matching as exact — do not rely on trailing-slash or subpath forgiveness. - **Use https.** ClickUp's note is *"Non-SSL redirect URIs may not be supported in the future."* Do not register a plaintext callback for a production platform. - **`redirect_uri` is not part of the token exchange.** ClickUp's token request schema requires exactly `client_id`, `client_secret` and `code` — no `redirect_uri`, no `grant_type`. If your HTTP client insists on sending `grant_type=authorization_code`, that is your client's habit, not ClickUp's requirement. ## 5. Scopes — there are none, and that is the thing to explain **ClickUp's OAuth flow has no `scope` parameter and no scope catalogue.** The documented authorize URL is: ``` https://app.clickup.com/api?client_id={client_id}&redirect_uri={redirect_uri}&state={state} ``` and that is the complete parameter list. No `scope`, no `response_type`, no PKCE (`code_challenge` appears nowhere in ClickUp's published specification). The consent screen offers the user a Workspace picker, not a permission list. **What the token can therefore do:** everything the authorizing user can do, in the Workspaces they granted. Read and write. Tasks, Lists, Folders, Spaces, Docs, comments, attachments, time tracking, users, groups — the connector's own code is the only thing narrowing it. Two consequences worth stating in a security review before someone else does: - **Least privilege is a choice about *who* authorizes, not *what* you request.** If a customer wants the integration confined to one Space, the answer is a ClickUp user account whose own access is confined to that Space, doing the authorizing — commonly a dedicated service user invited as a Member or a Guest with access to just that hierarchy. There is no API-side configuration that achieves it. Say this plainly; "we request minimal scopes" is not available as an answer here. - **Role is enforced server-side, per endpoint, at call time.** ClickUp's role model (1 owner, 2 admin, 3 member, 4 guest) is what the token carries. The clearest illustration lives in the v3 audit-log endpoint, which ClickUp documents as *"Only the Workspace owner can create audit logs"* **and** Enterprise-only. A token from an admin, through a correctly registered app, simply cannot call it. When a customer reports "permission denied" on one endpoint while everything else works, check the authorizing user's role before you check anything else. > **As of 2026-09-20, this platform's ClickUp connector sends no scopes** (correct — ClickUp has none) and its > authorize call carries exactly `client_id`, `redirect_uri` and a signed `state`. It does **not** use PKCE, which > matches ClickUp's documented flow. Because there is no scope to widen, adding object types to the connector never > re-authorizes existing customers. Confirm with the connector's owner before reporting this as settled. ## 6. Workspace selection — where "it authenticated but sees nothing" comes from This is ClickUp's equivalent of a permission model, and it is invisible from your side. When the user lands on `https://app.clickup.com/api?...`, they are asked **which of their Workspaces to grant your app**. They may select one, several, or — in a hurry, on a shared screen, on mobile — not the one your customer actually meant. The resulting token is silently narrower than everyone assumed. What to build around it: - **`GET /api/v2/team` (Get Authorized Workspaces) is the source of truth.** It returns the Workspaces *this token* may reach. Call it immediately after the exchange and store the result; never infer Workspace coverage from anything the customer told you. (In v2, `team` means Workspace — see §8.) - **Calling into a Workspace that was not granted returns "Team not authorized"** with error codes `OAUTH_023`, `OAUTH_026`, `OAUTH_027`, and `OAUTH_029` through `OAUTH_045`. That wide, unmemorable code range is the single highest-value thing to map to a human-readable message in your connector: *"This ClickUp user did not grant access to that Workspace — send them back through the connect flow and ask them to tick it."* - **Re-authorization is the fix, and it is non-destructive.** ClickUp's own instruction is to redirect the user to the same authorization URL again to modify Workspace permissions. There is no separate management endpoint. - **Authorization is per user, not per company.** Each colleague who connects gets their own token, their own Workspace selection and their own effective permissions. Two users in the same Workspace can legitimately produce two connections that see different data. - **A user can revoke from their side at any time**, with no notification to you. It surfaces as *"Token not found"* — `OAUTH_019`, `OAUTH_021`, `OAUTH_025`, `OAUTH_077`. > **As of 2026-09-20, this platform's ClickUp connector resolves a single Workspace at connect time and pins the > connection to it** — it reads the authorizing user's identity, looks through the Workspaces the token can reach, > picks the one that user is a member of, and stores that one id. Every downstream call that needs a Workspace, > including webhook registration and the people/group objects, uses that stored id. **A customer who authorizes > several Workspaces therefore gets one connection covering one of them**, and which one is decided by ordering > rather than by the customer. If a customer needs two Workspaces, they need two connections. Confirm the current > behaviour with the connector's owner; it is worth an explicit product decision rather than an implicit one. ## 7. Capture the credentials, and the token model From **Settings → Apps → your app**: the **`client_id`** and the **`secret`**. Record: - **Client ID** and **client secret**, plus the **Workspace that hosts the app** and who administers it (§2) - Authorize (a consent page, not an API): `https://app.clickup.com/api` - Token exchange: `POST https://api.clickup.com/api/v2/oauth/token` - API base: `https://api.clickup.com/api` — one host for everyone, no per-customer domain to discover - The registered redirect URLs, verbatim **The exchange.** Body carries exactly `client_id`, `client_secret` and `code`. ClickUp's specification lists both `application/json` and `application/x-www-form-urlencoded` as accepted content types. The response is documented as a single required field: ```json { "access_token": "..." } ``` **Token behaviour, and why it is unusual:** - **No expiry.** ClickUp: *"The access token currently does not expire. This is subject to change."* There is nothing to refresh and no clock to watch. - **No refresh token.** `refresh_token` does not appear anywhere in ClickUp's published v2 specification. A connector that assumes OAuth always returns one will store `undefined` and be fine — until someone writes a refresh path that can never succeed. Do not build one. - **No documented revoke or introspect endpoint.** The only OAuth route ClickUp publishes is the token exchange. You cannot programmatically kill a token you issued; the user revokes from their side, and you find out through `OAUTH_019` / `OAUTH_021` / `OAUTH_025` / `OAUTH_077`. - **So a leaked ClickUp access token is a permanent, unscoped, unrevocable-by-you bearer credential** for everything the authorizing user can reach. That is the sentence to put in the security section. Encrypt at rest, never log it, and treat a suspected leak as a customer-side revocation request, not something you can clean up yourself. - **Header format.** ClickUp documents `Authorization: Bearer {access_token}` for OAuth tokens and `Authorization: {personal_token}` (bare, `pk_`-prefixed) for personal tokens. Two different shapes on the same header; if you support both credential types, do not share one formatter between them without checking. **The personal-token alternative, and why it is not the answer here.** The same Settings → Apps page generates a personal API token: `pk_`-prefixed, never expires, no OAuth flow. ClickUp's own guidance is that it is for "individual or testing purposes," and that for apps others use you implement OAuth. It is genuinely useful for getting a connector's read path working on day one, and for the docs' own Try-It feature (which explicitly does not accept OAuth tokens). It is not a multi-tenant answer: it is one person's credential, carries their full access, every action is attributed to them, it dies when they leave, and regenerating it breaks every app using the old one. If someone proposes personal tokens to skip registration, say this. Resetting the client secret: ClickUp does not document what happens to already-issued access tokens. Assume the worst, never reset without explicit go-ahead and a cutover plan, and verify the effect on one test connection before telling customers anything. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. > **As of 2026-09-20, this platform's ClickUp connector** exchanges the code by POSTing `client_id`, `client_secret` > and `code` as **query parameters** rather than in the request body — ClickUp's specification documents a JSON or > form-encoded body, and this has historically worked anyway, but it is a divergence to re-check whenever the > exchange starts failing. It stores no refresh token and has no refresh path (correct for ClickUp). It sends the > token in the `Authorization` header **bare, with no `Bearer` prefix**, for both OAuth and personal tokens — > ClickUp's current documentation specifies `Bearer` for OAuth tokens and bare only for personal ones, so this is a > second divergence worth confirming against a live call rather than assuming. It also accepts a **personal API > token** as an alternative credential for single-user setups. Confirm all of this with the connector's owner. ## 8. Plans, versions and rate limits — what actually bites at scale **Rate limits are per token, per minute, set by the plan of the Workspace hosting the token:** | Plan | Requests/minute/token | | --- | --- | | Free Forever | **100** | | Unlimited | **100** | | Business | **100** | | Business Plus | **1,000** | | Enterprise / Enterprise Plus | **10,000** | This is the number that decides whether your product works, and it is brutal at the bottom: **three of the five tiers share the same 100/minute budget**, and that budget covers everything the connector does for that customer — initial sync, incremental polls, per-task attachment lookups, webhook-triggered re-fetches. A full-hierarchy crawl of a mid-sized Workspace (Spaces → Folders → Lists → tasks → subtasks → attachments) exceeds 100 requests in seconds. Design for it deliberately: - **Read the plan, don't guess it.** `GET /api/v2/team/{team_id}/plan` (Get Workspace Plan) tells you which tier this connection is on. Tier your sync cadence and concurrency off that, not off a global constant. - **The budget is per token, so one noisy customer cannot throttle another** — but a single connector fanning out across one customer's whole Workspace throttles *itself*. - **Obey the headers.** `429` responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and a Unix-timestamp `X-RateLimit-Reset`. Back off to the reset rather than retrying blind. - **Webhooks are the cheap path.** ClickUp's webhooks exist precisely so you do not poll a 100/minute budget. **Endpoints that only exist on Enterprise.** The API itself is on every plan, but a documented set of endpoints is not: Invite User To Workspace, **Get User**, Edit User On Workspace, Remove User From Workspace, and the whole guest management family. Plus `admin_can_manage` on Update Space, some time-tracking parameters on Business Plus and above, and the v3 audit-log endpoint (Enterprise **and** Workspace-owner-only). This matters at connect time. **Get User** is the natural way to enrich the authorizing identity — and it is Enterprise-only, so on the plan most customers are on it simply fails. **Get Authorized User** (`GET /api/v2/user`) is not restricted and returns the authorizing user's `id`, `username` and `email` directly. Prefer it for identity. > **As of 2026-09-20, this platform's ClickUp connector enriches the authorizing user's identity through the > Enterprise-only per-Workspace user endpoint, inside a swallowed try/catch** — so on Free, Unlimited, Business and > Business Plus the enrichment silently returns nothing and the connection is created without a captured name or > email, with no error surfaced. It already calls the unrestricted authorized-user endpoint moments earlier in the > same flow, and that response carries the email. Worth raising with the connector's owner as a concrete fix rather > than a documentation note. **v2 vs v3.** Both are current, and **there is no authentication difference** — same host, same `Authorization` header, one credential, one rate-limit budget. The difference is vocabulary and coverage: | | API v2 | API v3 | | --- | --- | --- | | Path | `https://api.clickup.com/api/v2/...` | `https://api.clickup.com/api/v3/...` | | Top-level container | **`team`** — legacy word for Workspace | **`workspaces`** | | `group` | A user group inside a Workspace | Same meaning | | Covers | Tasks, Lists, Folders, Spaces, comments, custom fields, views, time tracking, users, **webhooks**, the OAuth token exchange | Docs and pages, Chat, audit logs, attachments, ACLs, a few task operations | The `team` = Workspace collision is the documentation trap: in v2, `team_id` is a **Workspace** id, while `group_id` is a user group — and ClickUp's product UI calls user groups "Teams". Three meanings, one word. Name your variables after the concept, not after the path segment. > **As of 2026-09-20, this platform's ClickUp connector mixes both versions against a single credential** — v2 for > tasks, projects, people, groups, comments, attachments and webhooks, and v3 for the Docs/pages objects. That is > supported and correct; there is no second app, second secret or second header to configure. ## 9. Webhooks — and the two ways they die quietly Webhooks are registered through the API, not the app settings, with `POST /api/v2/team/{team_id}/webhook` carrying an `endpoint`, an `events` array (or `"*"` for everything) and optionally one location filter. Read these before shipping: - **A webhook belongs to the user whose token created it**, not to your app. ClickUp: *"If the user who created a webhook is disabled, the webhook remains but stops triggering"* — it checks the creating user is still in the relevant hierarchy before each delivery. A customer offboarding one employee can therefore stop your sync for their whole company, with no error anywhere on your side. **Get Webhooks** (`GET /api/v2/team/{team_id}/webhook`) likewise returns only the webhooks created by the authenticated user. - **Signing.** Creating a webhook returns a `secret` unique to that webhook, **in the create response and nowhere else**. Every delivery carries `X-Signature`: a hex HMAC-SHA256 of the raw request body under that secret. If you discard the secret at creation you can never verify a delivery again without deleting and recreating the webhook. Verify against the **raw** body — re-serializing a parsed JSON object changes the bytes and the digest. - **Health, and automatic disabling.** A non-2xx response, or any response slower than **7 seconds**, marks the webhook *failing*. ClickUp retries an event up to five times, then increments `fail_count` and drops that event permanently — **failed events are never resent**. Recovery resets `fail_count` automatically. But at `fail_count` **100** the webhook is **suspended** and deliveries stop. Two responses suspend it *immediately*: **`410`** and — the one that catches platforms — **`401`**. An endpoint that answers `401` to an unrecognised or expired webhook id kills its own subscription on the first bad delivery. - **No notification is sent when the health status changes.** Poll the webhook object's `health.status` and `fail_count` yourself; reactivate with `PUT /api/v2/webhook/{webhook_id}` setting status back to active. - **One location per hierarchy level per webhook**, and the most specific wins. Registering the same configuration and location twice returns `OAUTH_171` *"Webhook configuration already exists"*. - **Use `{{webhook_id}}:{{history_item_id}}` as the idempotency key.** ClickUp says so explicitly; deliveries repeat. - **No fixed source IPs.** ClickUp states it has no dedicated webhook IP range and uses dynamic addressing, so an IP allow-list is not an available control. Signature verification is the control. - **Deleting is `DELETE /api/v2/webhook/{webhook_id}`.** Singular `webhook`, with the `/v2` segment. Worth double-checking in any client: a delete that quietly 404s leaves a live webhook hammering a dead endpoint until it accrues 100 failures. > **As of 2026-09-20, this platform's ClickUp connector registers native webhooks for task and project > create/update/delete, scoped to the Workspace it pinned at connect time (§6) and optionally to a Space, Folder or > List.** Two gaps to raise with its owner: it **keeps only the webhook id from the create response and discards the > per-webhook signing secret**, so incoming deliveries cannot be signature-verified and are not; and its > **unsubscribe call targets a path that does not match ClickUp's documented delete route** (it omits the `/v2` > segment and pluralises the resource), which would make webhook teardown a silent no-op and leave orphaned > subscriptions failing against a retired endpoint. Both are verifiable against a live connection in minutes. ## 10. Verify end-to-end Authorizing in the Workspace that hosts the app, as its owner, on whatever plan your company is on, proves almost nothing. Test the path a customer takes: 1. Authorize as a **different user in an unrelated Workspace**, on a **non-Enterprise plan**, through your platform's real connect flow. 2. At the consent screen, deliberately grant **one** Workspace while the account has more than one. Confirm your connector notices, and that a call into an ungranted Workspace produces a message a support agent can act on — not a raw `OAUTH_031`. 3. Immediately after the exchange, call `GET /api/v2/team` and store the authorized Workspace list. 4. Confirm the token response has **no** `refresh_token` and **no** `expires_in`, and that nothing in your code path waits for either. 5. Confirm the identity enrichment works on that non-Enterprise plan — this is where an Enterprise-only endpoint fails silently. 6. Authorize again as a **limited Member or Guest** and confirm the connector degrades sensibly rather than throwing: with no scopes, role is the only access control, and a guest's token is a legitimate customer configuration. 7. If webhooks are in play: create one, capture and store the `secret`, verify one real `X-Signature` against the raw body, then **delete it and confirm it is actually gone** via Get Webhooks. 8. Run a realistic initial sync against a Workspace on a 100/minute plan and watch for `429`. | Symptom | Cause | | --- | --- | | `OAUTH_010` "Client Not Found" | Wrong `client_id`, or the app was not created/saved properly (§3) | | `OAUTH_007` "Redirect URI does not match…" | The `redirect_uri` sent is not one of the app's registered values (§4) | | `OAUTH_017` "Redirect URI not passed" / "Authorization Header Required" | `redirect_uri` missing from the authorize call, or no `Authorization` header on an API call (§4, §7) | | `OAUTH_023` / `OAUTH_026` / `OAUTH_027` / `OAUTH_029`–`OAUTH_045` "Team not authorized" | The user did not grant that Workspace. Re-send them to the authorize URL (§6) | | `OAUTH_019` / `OAUTH_021` / `OAUTH_025` / `OAUTH_077` "Token not found" | The user revoked, or a personal token was regenerated (§7) | | Authorizes fine, then reaches the wrong Workspace | Multi-Workspace grant collapsed to one connection (§6) | | Auth works, one endpoint 403s at one customer | Authorizing user's role, or an Enterprise-only endpoint (§5, §8) | | Identity/email is blank on connect, no error logged | Enterprise-only user endpoint failing silently on a lower plan (§8) | | `429` at modest volume | 100/minute tier. Read the plan and the `X-RateLimit-*` headers (§8) | | Webhook stopped firing, nothing in your logs | Creating user disabled or removed from the hierarchy (§9) | | Webhook suspended after one bad deploy | Your endpoint answered `401` or `410` — both suspend immediately (§9) | | Webhooks degrade over days, then stop | `fail_count` climbing to 100 from slow (>7s) or non-2xx responses (§9) | | `OAUTH_171` | A webhook with that configuration and location already exists (§9) | | Signature never matches | Digest taken over a re-serialized body instead of the raw bytes, or the wrong secret (§9) | | `401` on every call with a token you know is good | Header shape: OAuth wants `Bearer `, personal tokens are sent bare (§7) | ## 11. Hand off — never commit the secret - **Do not** write the client secret or any access/personal token into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. ClickUp tokens do not expire, so a leaked one stays valid until a human revokes it (§7) — the blast radius is larger than for vendors with short-lived tokens. - If a code change is needed (a redirect host, a rate-limit tier, a webhook path), keep it secret-free and say what the human must set out of band. - Close with: app name; **the Workspace that hosts the app and who administers it**; the client ID; where the secret was delivered; the authorize page and token endpoint; the registered redirect URLs; **explicitly, that ClickUp has no scopes and the token carries the authorizing user's full permissions**; the Workspace-selection behaviour you observed at consent; the rate-limit tier of the Workspaces you tested; whether webhooks were registered and whether their secrets are stored; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the Workspace that would host the app is personal, a trial, or administered by one person (§2); a customer's security review asks for scope restriction and the honest answer is "use a restricted ClickUp user" (§5); a customer needs more than one Workspace from a single connection (§6); regenerating the client secret is proposed and nobody knows what it does to live tokens (§7); a customer reports a permanent `429` and the fix is a plan upgrade they must pay for (§8); a suspected token leak needs customer-side revocation because you have no revoke endpoint (§7); or Settings → Apps does not match the **Platform state** section above. ## References Official ClickUp pages only; every URL below returned HTTP 200 on 2026-09-20. The app settings themselves live at `https://app.clickup.com/settings/apps` and require sign-in. ClickUp help-centre articles are served behind a challenge that refuses non-browser clients, so none are cited here — read them in a browser if you need the product-side view. - Authentication: personal token, OAuth flow, app creation, Workspace selection — https://developer.clickup.com/docs/authentication - Get Access Token (the token endpoint's full schema) — https://developer.clickup.com/reference/getaccesstoken - Get Authorized Workspaces (`GET /v2/team`) — https://developer.clickup.com/reference/getauthorizedteams - Get Authorized User (`GET /v2/user`) — https://developer.clickup.com/reference/getauthorizeduser - Get User (Enterprise-only) — https://developer.clickup.com/reference/getuser - Get Workspace Plan — https://developer.clickup.com/reference/getworkspaceplan - Rate limits by plan, and the `X-RateLimit-*` headers — https://developer.clickup.com/docs/rate-limits - API availability by plan (the Enterprise-only endpoint list) — https://developer.clickup.com/docs/apis-available-by-plan - ClickUp API v2 and v3 terminology (team vs Workspace vs group) — https://developer.clickup.com/docs/general-v2-v3-api - Common errors and the `OAUTH_*` codes — https://developer.clickup.com/docs/common_errors - FAQ (token expiry, `Content-Type`, subtasks, user roles) — https://developer.clickup.com/docs/faq - Webhooks: scope, locations, events, delivery contract — https://developer.clickup.com/docs/webhooks - Webhook signature (`X-Signature`, HMAC-SHA256) — https://developer.clickup.com/docs/webhooksignature - Webhook health status (failing, suspended, `fail_count`) — https://developer.clickup.com/docs/webhookhealth - Task webhook payload examples — https://developer.clickup.com/docs/webhooktaskpayloads - Create Webhook — https://developer.clickup.com/reference/createwebhook - Get Webhooks — https://developer.clickup.com/reference/getwebhooks - Update Webhook (reactivate a suspended one) — https://developer.clickup.com/reference/updatewebhook - Delete Webhook — https://developer.clickup.com/reference/deletewebhook - OpenAPI specifications page — https://developer.clickup.com/docs/open-api-spec - v2 OpenAPI specification (raw) — https://developer.clickup.com/openapi/clickup-api-v2-reference.json - v3 OpenAPI specification (raw) — https://developer.clickup.com/openapi/ClickUp_PUBLIC_API_V3.yaml - Try the API in your web browser (personal tokens only) — https://developer.clickup.com/docs/trytheapi - Documentation index for machine readers — https://developer.clickup.com/llms.txt - Developer docs home (the old `clickup.com/api` redirects here) — https://developer.clickup.com/ - ClickUp plans and pricing — https://clickup.com/pricing - ClickUp integrations / App Center — https://clickup.com/integrations ## discord-oauth-app URL: https://docs.unified.to/skills/discord-oauth-app/SKILL.md # Discord OAuth2 App Registration Get a working Discord application — client ID, client secret, bot token, redirect URIs, scopes, a permissions bitfield and whatever intents you actually need — for a platform that connects *other organizations'* Discord servers on behalf of many customers. Discord's portal is free and instant, and that is the trap: the expensive decisions are invisible in it. Three of them. First, **a Discord app hands you two unrelated credentials** — a long-lived **bot token** that acts as the app inside every server the bot was installed into, and a short-lived **OAuth2 access token** that acts as one signed-in user (§5). They reach different data, and most connectors need both. Second, the **`bot` scope plus a `permissions` bitfield** is a different install than a plain user authorization, and that bitfield is a *permissions* bitfield, not an *intents* bitfield — two different numbering schemes that look identical and are constantly confused (§7). Third, **privileged gateway intents** are the wall: `MESSAGE_CONTENT` and `GUILD_MEMBERS` are portal toggles that stop being self-serve once the app can reach **10,000 users**, and a wrong intent bitfield does not degrade, it closes the gateway connection with code `4014` (§8). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, icon, description | Shown on the install/consent screen and in every server's member list | | **Discord account, and ideally a Team** | Teams need 2FA; ownership transfer to a team is one-way (§2) | | **A test server you own** | You need `MANAGE_GUILD` in it to install the bot (§2) | | **Redirect URIs** | Every callback host your platform serves (§4) | | **Does the connector need a bot token, a user token, or both?** | The decisive question (§5) | | **Exact scope strings** | Must match what the authorize URL sends (§6) | | **Bot permissions** | The `permissions` bitfield — pick the minimum, not `ADMINISTRATOR` (§7) | | **Which gateway intents, if any** | Only if you run a gateway/WebSocket bot; privileged ones gate growth (§8) | | **Privacy policy URL, terms of service URL** | Needed for App Directory discovery (§11) | ## Quick Start 1. Confirm a **new app** is needed — existing installs and tokens are bound to the current app (§1). 2. Sign in, enable 2FA, create or join a **Team**, and pick a **test server** you control (§2). 3. Create the app at `https://discord.com/developers/applications`; add a **bot user** (§3). 4. Add **every** redirect URI under **OAuth2 → Redirects** (§4). 5. Decide **bot token, user token, or both** — this drives everything below (§5). 6. Set the **scope strings**; remember there is no `openid` scope on Discord (§6). 7. Compute the **`permissions` bitfield** for the `bot` scope — permissions, not intents (§7). 8. Toggle only the **privileged gateway intents** you truly need, and read the 10,000-user rule (§8). 9. Copy **Application ID**, **client secret** (OAuth2 page) and **bot token** (Bot page — shown once) (§9). 10. Install into the test server through the real flow and make one call with **each** credential (§12). 11. Hand the credentials over — never commit them (§13). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The docs moved.** Discord's **10 February 2026** "Next Generation Docs" change migrated the developer documentation to Mintlify. `discord.com/developers/docs/...` now **301s to `docs.discord.com/developers/...`**. The portal itself is still at `discord.com/developers/applications`. Older runbooks linking the old paths are not wrong, just redirected. - **Privileged intent access changed on 10 June 2026, and the old number everyone quotes is dead.** The threshold used to be **100 servers**. It is now **10,000 users** — the number of unique users your app can reach across every server it is installed in. Below that, you toggle privileged intents yourself in the portal. At or above it you get a system DM and/or email, have **90 days** to apply for review, and must **reapply annually** once granted. Apps are **no longer blocked from joining new servers** while a submission is pending — that block was part of the old regime. Privileged intent review and **App Verification are now separate processes** (§8). - **App Verification is the *other* threshold and it is still 100 servers.** Discord's developer help center states that verification is required for an app to scale past 100 servers, that the checklist lives under an **App Verification** tab in the portal, and that the **development team's owner must verify their identity through Stripe**. Do not merge these two gates in your head: 100 servers = verification to keep growing; 10,000 users = review to keep privileged intents (§11). - **There is no `openid` scope.** Discord's scope table has `identify` and `email`; it does not document an `openid` scope and Discord is not documented as an OpenID Connect provider. Anything asking for `openid` on Discord is carrying over a habit from another vendor. - **PKCE is not documented anywhere in Discord's OAuth2 docs.** This is a confidential-client flow with a client secret. Do not add `code_challenge` expecting it to be honoured. - **Access tokens are short; refresh tokens are the mechanism.** The documented example response carries `expires_in: 604800` — seven days. Token exchange and refresh are `application/x-www-form-urlencoded` POSTs; **JSON bodies are rejected**, and the docs pass client credentials via **HTTP Basic** (client ID as username, client secret as password). - **`shard` became required on `GET /users/@me/guilds` for large-bot-sharding apps on 15 September 2026.** The endpoint also caps at **200 guilds** (`limit` 1–200). Non-sharding apps are unaffected, but a connector that enumerates a bot's servers through this route needs to know both facts. - **`PIN_MESSAGES` split out of `MANAGE_MESSAGES`** — announced 24 November 2025, effective February 2026. If you copied a permissions bitfield from an older runbook, re-derive it. If the portal or the docs do not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new Application ID, a new client secret and a new bot user**. Every existing install is bound to the old app: customers would have to remove the old bot from their server and re-install the new one, and every stored access token and refresh token becomes worthless. That is a customer-visible migration, not a config change. Reuse the existing app for: adding a redirect URI, adding a scope, changing the permissions bitfield, toggling an intent, rotating a secret, or diagnosing an install failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product surface, or a deliberate split of environments. Say which path you are taking before you touch anything. Note that even on the same app, **changing scopes or the permissions bitfield does not upgrade existing installs.** A token carries the scopes granted when it was issued, and a bot's role in a server carries the permissions granted when it was added. Broadening either is a re-authorization / re-install event per customer. ## 2. Account, 2FA, Team, and a test server There is no separate developer account and no approval gate to create an app. You need: - **A Discord account** with **2FA enabled**. 2FA is not optional in practice: Discord requires it to create or join a Team, and several powerful permissions — including `ADMINISTRATOR`, `MANAGE_GUILD`, `MANAGE_ROLES`, `MANAGE_WEBHOOKS`, `MANAGE_CHANNELS`, `MANAGE_MESSAGES`, `KICK_MEMBERS`, `BAN_MEMBERS`, `MODERATE_MEMBERS` — "require the owner account to use two-factor authentication when used on a guild that has server-wide 2FA enabled." A connector that requests those permissions will hit this at customers who enforce server-wide 2FA. - **A Team, not a person.** Teams are "groups of developers (or other Discord users) who want to collaborate and share access to an app's configuration, management, and payout settings," with roles **Owner** (one per team, can delete apps and the team), **Admin**, **Developer** and **Read-only**. For a platform integration the app should be owned by a team the company controls, not by whoever happened to click *New Application*. **Transfer is one-way**: "Once an app has been transferred to a team, it *cannot* be transferred back." Do it deliberately, and record who the team Owner is — that person is also who will have to complete identity verification at 100 servers (§11). - **A test server you own,** so you can install the bot. Installing to a server requires `MANAGE_GUILD` in it. Create a fresh free server rather than installing a half-configured bot into anything real. Hand control back to the user for anything only they can do: sign-in, email/phone verification, enabling 2FA, accepting terms, Stripe identity verification, or approving an install in a server where they are not an admin. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste — the redirect URIs from §4, the scope strings from §6, the computed bitfield from §7, the intent toggles from §8 — then continue once they report back with the Application ID. ## 3. Create the app At **`https://discord.com/developers/applications`** → **New Application**. Then, on the app's pages: | Page | Holds | | --- | --- | | **General Information** | **Application ID**, **Public Key**, **Interaction Endpoint URL**, app name/icon/description, transfer-to-team | | **Bot** | The **bot token** (via **Reset Token**), **Public Bot**, **Requires OAuth2 Code Grant**, **Privileged Gateway Intents** | | **OAuth2** | **Client secret** (**Reset Secret**), **Redirects**, the URL generator | | **Installation** | Install Link and **Default Install Settings** — the scopes and bot permissions a Discord-provided link uses | | **App Verification** | The checklist that gates growth past 100 servers (§11) | | **Discovery** | **Discovery Status** checklist and **Enable Discovery** for the App Directory (§11) | Two settings on the **Bot** page decide whether anyone but you can install it: - **Public Bot** — "If unchecked, only you can add the bot to guilds. If marked as public, anyone with your bot's URL can add it to guilds in which they have proper permissions." A multi-tenant connector **must** have this on. Leaving it off is the cause of *"Only the application owner can add this bot"* at every customer. - **Requires OAuth2 Code Grant** — when enabled it "requires anyone adding your bot to a server to go through a full OAuth2 authorization code grant flow." Turn this on only if your platform genuinely completes the code exchange on the callback. If your flow is a bare install link with no exchange, this setting strands every install. The **bot token is shown once.** Discord's own guidance: "You won't be able to view your token again unless you regenerate it, so make sure to keep it somewhere safe." Capture it the moment it appears (§9). Discord's reference documentation describes the API, not the portal UI, so treat the page names above as a map to confirm on screen rather than a contract — if a page is missing or renamed, say so instead of guessing. ## 4. Redirect URIs Add **every** callback host your platform serves under **OAuth2 → Redirects**. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Discord's rule, stated plainly in the OAuth2 docs, is that the `redirect_uri` you send is "whatever URL you registered when creating your application, **url-encoded**" — and the same value must be sent again in the token exchange, where it is one of the documented POST fields. Register all four before you test, because the first thing a missing entry produces is an error page the user sees instead of a consent screen, with no callback fired and nothing in your logs. A bot-only install link (`scope=bot`, no `response_type`) does not use a redirect URI at all. The moment you add any other scope, `response_type=code` becomes mandatory and the redirect URI is back in play (§7). This is why an install link that "worked" can break the day someone adds `identify` to it. ## 5. The decisive choice: bot token or OAuth2 access token One Discord app can issue **two credentials with different reach**. Choosing wrong is the expensive mistake here, because the wrong one does not fail at install — it fails later with a 401 or an empty list. | | **Bot token** | **OAuth2 access token** | | --- | --- | --- | | Where it comes from | The **Bot** page in the portal, once, by hand | The authorization code exchange, per customer | | Header | `Authorization: Bot ` | `Authorization: Bearer ` | | Acts as | The application, inside every server the bot was installed into | The one user who authorized, across what *they* can see | | Lifetime | Long-lived until reset | `expires_in: 604800` (7 days), refreshed with `grant_type=refresh_token` | | Multi-tenant shape | **One token for all customers** | One token per customer connection | | Reaches server content | Yes, subject to its role's permissions in each server | Only through user-scoped endpoints and the scopes granted | | Returned by the OAuth exchange? | **No.** The token response never contains the bot token | Yes | That last row is the single most common misunderstanding. Completing a `bot`-scope authorization **installs** the bot into the chosen server; it does not hand you the bot's token. The bot token is a separate, manually copied secret, and it is the same value for every customer — which also means it is the single credential whose compromise affects all of them at once. Most real connectors are **hybrid**: they use the bot token to read and write server content, and the user's OAuth token only to establish *which* servers that particular customer is entitled to see (otherwise every customer of the platform would see every server the bot is in). If you are building that shape, you need **both** credentials configured, and your scope list must include `bot` *and* the user scopes. > **As of 2026-09-20, the Unified.to Discord connector is exactly this hybrid.** It requires an OAuth2 client ID and > secret **and** a separately supplied bot token, and it sends the **bot token** on essentially every API call > (`Authorization: Bot …` against `https://discord.com/api/v10/`). The customer's OAuth access token is used almost > solely as a visibility filter: the connector lists the servers the *bot* is in, lists the servers the *user* is in, > and intersects the two. So the OAuth app and the bot must be **the same Discord application**, the bot must be > installed in a customer's server *and* the authorizing user must be a member of it, or that server simply will not > appear. Confirm the current design with the connector's owner before registering anything. ## 6. Scopes Scopes are **space-delimited** in the authorize URL and must be URL-encoded. Discord's documented catalogue, with the strings exactly as Discord spells them: | Scope | Grants | | --- | --- | | `identify` | "allows `/users/@me` without `email`" | | `email` | "enables `/users/@me` to return an `email`" | | `guilds` | "allows `/users/@me/guilds` to return basic information about all of a user's guilds" | | `guilds.members.read` | "allows `/users/@me/guilds/{guild.id}/member` to return a user's member information in a guild" | | `guilds.join` | "allows `/guilds/{guild.id}/members/{user.id}` to be used for joining users to a guild" | | `gdm.join` | join users to a group DM | | `bot` | "for oauth2 bots, this puts the bot in the user's selected guild by default" — see §7 | | `applications.commands` | "allows your app to add commands to a guild — included by default with the `bot` scope" | | `applications.commands.update` | update its own commands with a Bearer token — client credentials grant only | | `applications.commands.permissions.update` | update command permissions in a guild the user has permissions in | | `connections` | "allows `/users/@me/connections` to return linked third-party accounts" | | `role_connections.write` | update a user's connection and metadata for the app | | `webhook.incoming` | "generates a webhook that is returned in the oauth token response for authorization code grants" | | `applications.builds.read`, `applications.entitlements`, `applications.store.update` | app build/store/entitlement data | | `messages.read` | local RPC server access only — not a REST scope | **`guilds` and `guilds.members.read` are not the same thing, and the difference bites.** `guilds` gets you the *list* of servers the user is in — id, name, icon, whether they own it, and their permissions in it. It tells you nothing about who else is in those servers. `guilds.members.read` gets you **one** member object: the authorizing user's own membership (nickname, roles, joined date) in **one specific server**, one server at a time. Neither is a membership roster. Enumerating a server's members is a **bot-token** operation, and pulling it over the gateway needs the privileged `GUILD_MEMBERS` intent (§8). A connector that maps "employees" or "members" from Discord is doing bot-token work, and no amount of user scopes substitutes. **Scopes Discord gates behind approval.** The catalogue marks these "only available to approved partners" or otherwise needing Discord's approval: `activities.read`, `activities.write`, `applications.builds.upload`, `dm_channels.read`, `identify.premium`, `relationships.read`, `voice`, and every `rpc*` scope (`rpc`, `rpc.activities.write`, `rpc.notifications.read`, `rpc.voice.read`, `rpc.voice.write`). Do not put any of these in a scope list on the assumption you can request them; if the connector needs one, that is a partnership conversation with Discord, not a portal toggle. (One recent movement: **21 January 2026**, `relationships.read` was opened to Activities under the Social SDK terms without requiring approval — evidence this list moves, so re-check it.) **There is no `openid` scope.** Use `identify` and `email` for sign-in. > **As of 2026-09-20, the Unified.to Discord connector requests `bot`, `identify`, `email` and `guilds` for its > messaging objects, and adds `guilds.members.read` for the people/employee object.** Its sign-in-only flow requests > `identify`, `email` and **`openid`** — and `openid` is not in Discord's documented catalogue, so that entry should > be reviewed with the connector's owner rather than replicated. The connector does **not** send PKCE. ## 7. The `bot` scope, the permissions bitfield, and how a server install actually works Adding `bot` to the scope list turns the authorization into a **server install**. The extra query parameters: | Parameter | Meaning | | --- | --- | | `permissions` | "an integer corresponding to the permission calculations for the bot" — the role the bot gets in that server | | `guild_id` | pre-selects a server: "that guild will be preselected in the dialog if that user has permission to add the bot to that guild" | | `disable_guild_select` | `true` "disallow[s] the user from picking a different guild" | | `prompt` | `consent` re-approves the authorization; `none` skips the screen for an existing authorization. **Those are the two documented values** | | `integration_type` | `0` = GUILD_INSTALL (server), `1` = USER_INSTALL (the authorizing user only) | `response_type` is optional for a bare `bot` (+ `applications.commands`) install link. **Request any scope outside those two and `response_type=code` becomes mandatory**, the flow becomes a full authorization code grant, and the token response additionally contains a `guild` object describing the server the bot was just added to — useful for recording the install without a second call. The bot token is still not in there (§5). **The trap: `permissions` and intents are different bitfields with colliding bit numbers.** Both are integers, both are built from `1 << n`, and they mean completely unrelated things. `1 << 15` is: - **`ATTACH_FILES`** (`0x0000000000008000`, 32768) in the **permissions** bitfield — "Allows for uploading images and files"; - **`MESSAGE_CONTENT`** in the **gateway intents** bitfield (§8). Putting the `MESSAGE_CONTENT` bit into the `permissions` parameter does **not** grant message content. It silently asks for `ATTACH_FILES` and moves on. Message content is a portal toggle plus an intents value sent on the gateway identify — nothing in the authorize URL can turn it on. If you inherit a bitfield with a comment that does not match the permission at that bit, re-derive it from the permissions table rather than trusting the comment. Values worth having at hand: `ADMINISTRATOR` = `1 << 3` (8), `MANAGE_GUILD` = `1 << 5` (32), `VIEW_CHANNEL` = `1 << 10` (1024), `SEND_MESSAGES` = `1 << 11` (2048), `ATTACH_FILES` = `1 << 15` (32768), `READ_MESSAGE_HISTORY` = `1 << 16` (65536), `MANAGE_WEBHOOKS` = `1 << 29` (536870912). **Do not reach for `ADMINISTRATOR`.** It "allows all permissions and bypasses channel permission overwrites" — it is the whole server, including channels the installing admin never intended to share, and it "overrides any potential permission overwrites," so a customer cannot scope it down afterwards with channel settings. It also drags in the server-wide-2FA requirement (§2). Every customer security review asks about it, and "it was easier" is a bad answer. For a read-and-post messaging connector the honest minimum is closer to `VIEW_CHANNEL | READ_MESSAGE_HISTORY | SEND_MESSAGES` (1024 + 65536 + 2048 = 68608), plus `ATTACH_FILES` if you upload and `MANAGE_WEBHOOKS` if you create webhooks. Compute it from the table, write down which bits you chose and why, and hand that list over. > **As of 2026-09-20, the Unified.to Discord connector's authorize URL hard-codes `permissions=32776`** — that is > `ADMINISTRATOR` (8) plus the `1 << 15` bit (32768), which in a permissions bitfield is `ATTACH_FILES`, not > `MESSAGE_CONTENT`. It also sends `prompt=select_account`, which is not one of Discord's two documented `prompt` > values, and it does not send `guild_id`, `disable_guild_select` or `integration_type`. Raise all three with the > connector's owner rather than copying them into a new app's install link. ## 8. Privileged gateway intents — the wall Intents only matter if you open a **gateway (WebSocket) connection**. A REST-only integration can skip this section entirely. If you do connect to the gateway, this is where Discord integrations most often stall. Three intents are **privileged**: | Intent | Bit | Needed for | | --- | --- | --- | | `GUILD_MEMBERS` | `1 << 1` | Member list and member add/update/remove events | | `GUILD_PRESENCES` | `1 << 8` | Presence / online status | | `MESSAGE_CONTENT` | `1 << 15` | The **text** of messages your app was not directly mentioned in | Non-privileged ones you will pair with them include `GUILDS` (`1 << 0`) and `GUILD_MESSAGES` (`1 << 9`) — note that `GUILD_MESSAGES` gives you message *events*, and without `MESSAGE_CONTENT` those events arrive with empty content fields. That is the failure people describe as "the bot sees messages but they're blank." How it works now: 1. **Toggle them in the portal** — on the **Bot** page, under **Privileged Gateway Intents**. Discord: you must "toggle the privileged intents on the Bot page under the Privileged Gateway Intents section" before using them. `GUILD_PRESENCES` and `GUILD_MEMBERS` events "are turned off by default on all API versions." 2. **Send the matching bitfield on identify.** If you pass a privileged intent your app is not configured or approved for, "your Gateway connection will be closed with a **4014** close code." It does not degrade to a partial feed — it disconnects. 3. **At 10,000 users, it stops being self-serve.** Once the app can reach 10,000 unique users across its servers, the app or team owner gets a system DM and/or email and has **90 days** to submit a privileged intent review. Miss the window and "your app's current Privileged Intents access will be removed" — you can reapply later. Approved apps must **reapply annually**. 4. **You keep growing during review.** The app "will continue to function with the intents you are requesting access to" while the submission is pending, and — unlike the pre-June-2026 regime — it is no longer blocked from joining new servers. The review asks you to "be specific about your use case" with concrete examples, request only the intents you genuinely need, and explain data handling, retention and security. A request is rejected when "the information provided in your submission does not demonstrate that the stated use case requires access." Resubmission is allowed. **Plan for this at design time, not at 10,000 users.** If the connector's value proposition depends on reading message bodies across customers' servers, `MESSAGE_CONTENT` is a business dependency with an annual renewal and a human reviewer attached. Say so out loud in the summary. If the feature can be built on data the app is directly addressed with (mentions, slash commands, interactions) the intent is not needed at all, and that is worth an hour of design conversation before it is worth a submission. Note one documentation inconsistency to expect: the application-object flags still describe `GATEWAY_MESSAGE_CONTENT_LIMITED` / `GATEWAY_GUILD_MEMBERS_LIMITED` in terms of a **100-server** split, which is the pre-June-2026 framing. The gateway and review pages carry the current 10,000-user rule. Trust the newer pages and flag the discrepancy rather than reasoning from the stale one. ## 9. Capture the credentials | Credential | Where | Re-readable? | | --- | --- | --- | | **Application ID** (= OAuth2 client ID) | General Information | Yes | | **Public Key** | General Information — verifies inbound interaction request signatures (Ed25519) | Yes | | **Client secret** | OAuth2 page, **Reset Secret** | Treat as reset-only | | **Bot token** | Bot page, **Reset Token** | **No — shown once** | Endpoints to record alongside them: - Authorize: `https://discord.com/oauth2/authorize` - Token exchange **and** refresh: `POST https://discord.com/api/oauth2/token` - Revoke: `POST https://discord.com/api/oauth2/token/revoke` - API base: `https://discord.com/api`, current version **v10** (`https://discord.com/api/v10/`) **Token mechanics:** - Exchange and refresh are `Content-Type: application/x-www-form-urlencoded` POSTs. **JSON is rejected.** Exchange sends `grant_type=authorization_code`, `code`, `redirect_uri`; refresh sends `grant_type=refresh_token`, `refresh_token`. - Discord documents client credentials on the OAuth2 token endpoints via **HTTP Basic authentication** — client ID as the username, client secret as the password. (Many clients instead put `client_id`/`client_secret` in the form body; if yours does, note it as a deviation from the documented form and verify it against a live exchange rather than assuming.) - The documented response is `access_token`, `token_type: "Bearer"`, `expires_in: 604800`, `refresh_token`, `scope` — and, for a `bot`-scope authorization combined with other scopes, a `guild` object. - **`state` is not required by Discord but is strongly recommended**: Discord calls out CSRF and clickjacking and says `state` "should be a value that binds the user's request to their authenticated state." Send it; validate it. - **Revocation is authorization-wide.** `POST .../token/revoke` takes `token` and an optional `token_type_hint`, and "when you revoke a token, any active access or refresh tokens associated with that authorization will be revoked, regardless of the `token` and `token_type_hint` values you pass in." There is no revoke-just-this-one. - **The client credentials grant** (`grant_type=client_credentials`, Basic auth) returns "an access token for the bot owner" — it is a testing convenience for your own account, not a multi-tenant mechanism. Discord's own warning about keeping the secret out of source code is unusually emphatic here; take the hint. **Rotation.** Resetting the **client secret** invalidates the old one immediately: existing access tokens live out their remaining time, but every future exchange and refresh fails until the new secret is deployed. Resetting the **bot token** is worse — it is the credential doing the actual work, so every server's reads and writes stop the instant it is reset, across all customers at once, until the new token is deployed. Never reset either without explicit go-ahead and a cutover plan. Report each secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be reset, then move on. ## 10. Rate limits, and the Cloudflare ban nobody plans for - **Global:** "All bots can make up to **50 requests per second** to our API," independent of per-route limits. - **Per route, per top-level resource.** Limits are bucketed by route *and* by the top-level resource in the path (channel, guild, webhook), so "an endpoint with two different top-level resources may calculate limits independently." One busy server does not have to throttle another — unless your client shares a bucket by accident. - **Headers on every response:** `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (epoch seconds), `X-RateLimit-Reset-After` (seconds), `X-RateLimit-Bucket` (the bucket's opaque id), and on a 429 `X-RateLimit-Scope` with value `user`, `global` or `shared`. Honour `X-RateLimit-Reset-After` and `X-RateLimit- Bucket`; a client that only reacts to 429s is already late. - **429 body** carries `retry_after` (seconds to wait), `global` (whether it is the global limit) and `message`. - **The one that takes you off the air: Cloudflare bans.** "IP addresses that make too many invalid HTTP requests are automatically and temporarily restricted from accessing the Discord API," with the threshold at **10,000 responses of 401, 403 or 429 per 10 minutes**. Note what counts: **expired tokens (401) and missing permissions (403) count as invalid**, not just rate-limit hits. A connector looping over thousands of connections with stale tokens, or retrying 403s from servers where the bot lacks a permission, can ban the *platform's own egress IP* for every customer — a multi-tenant outage caused by single-tenant misconfiguration. Back off on 401/403 and stop re-trying them. - **Send a real User-Agent.** Discord requires the form `User-Agent: DiscordBot ($url, $versionNumber)` and warns that "client requests that do not have a valid User Agent specified may be blocked and return a Cloudflare error." Confirm your HTTP client actually sets one rather than shipping a library default. ## 11. Verification, App Directory, and growth gates Two separate gates, now decoupled (they were one process before June 2026): - **App Verification — required to grow past 100 servers.** The portal has an **App Verification** tab with a checklist; the headline requirement is that the **owner of the development team must verify their identity through Stripe**, Discord's identity provider. Until verified, the app cannot join its 101st server. Start this early: it depends on a specific human completing an identity check, which is exactly the kind of thing that blocks a launch for a week. - **Discovery / App Directory listing — optional.** Enabling Discovery requires "your team owner to complete identity and application verification"; you then work the **Discovery → Discovery Status** checklist in the portal and click **Enable Discovery**, after which "it may take up to 24 hours for your app to appear in the App Directory and App Launcher." Discord's inclusion guidelines additionally require a **publicly hosted privacy policy and terms of service**, compliance with the Discord Terms of Service, Community Guidelines, Developer Terms of Service and Developer Policy, no age-restricted content, a configured **install link**, implemented **slash commands**, and **2FA on your own account**. A listing is a go-to-market decision; it is not needed to serve customers. **Stop and ask** before answering anything you would have to invent on a verification or Discovery submission: company identity details, data-retention periods, subprocessors, security questionnaires, support SLAs, or user counts. ## 12. Verify end-to-end A half-working registration surfaces later as a customer who cannot connect. Prove it, and prove **each credential separately** — this is the step that catches the §5 mistake: 1. **Install into your test server through the real flow** — your platform's connect button or the real install link, not a hand-built URL, so the redirect URI matching is actually exercised. 2. **Read the consent screen.** Confirm it names the server, lists the permissions you expect, and does not mention permissions you did not intend (`ADMINISTRATOR` shows up as "Administrator" and is unmistakable). 3. **Complete the token exchange**, confirm you got `access_token`, `refresh_token`, `expires_in` and the `scope` string you asked for — and, for a `bot` install with extra scopes, the `guild` object. 4. **Make one call with the Bearer token** (e.g. the current user, or the user's guild list) and **one call with the `Bot` token** (e.g. a guild's channels). Both must work if your connector is a hybrid. Passing one and failing the other is the whole failure mode. 5. **Force a refresh** before the 7 days are up and confirm the connection survives it. 6. **If you use the gateway**, connect and confirm you receive a message event *with non-empty content* from a channel the bot was not mentioned in. That is the only real test of `MESSAGE_CONTENT`. 7. **Test from a second account in a server you do not own**, where the installer is an admin but not you. That is the path a customer takes. | Symptom | Cause | | --- | --- | | "Only the application owner can add this bot" | **Public Bot** is off on the Bot page (§3) | | Error page instead of a consent screen; no callback fired | `redirect_uri` not registered, or not url-encoded / not identical (§4) | | Install link works until someone adds a scope, then breaks | Any scope beyond `bot`/`applications.commands` makes `response_type=code` mandatory (§7) | | `invalid_scope` | Scope string not in Discord's catalogue (e.g. `openid`), or a scope that needs Discord's approval (§6) | | Install "succeeds" but every API call 401s | Using the OAuth access token where the bot token is required, or vice versa (§5) | | Waiting for a bot token in the token exchange response | It is never returned there — copy it from the Bot page (§5, §9) | | Bot is in the server but sees no channels | Permissions bitfield too narrow, or channel-level overwrites exclude the bot's role (§7) | | Message events arrive with empty content | `MESSAGE_CONTENT` intent not toggled / not approved (§8) | | Gateway closes with **4014** | A privileged intent was sent that the app is not configured or approved for (§8) | | Member list is empty or partial | `GUILD_MEMBERS` privileged intent, or a bot-token endpoint being called with a Bearer token (§6, §8) | | Everything dies ~7 days after connecting | Client is not refreshing; access tokens are `expires_in: 604800` (§9) | | `invalid_grant` on refresh | Authorization revoked, bot removed from the server, or the user revoked the app (§9) | | Bot stops joining new servers at exactly 100 | App Verification not completed (§11) | | Privileged intents silently removed | 90-day review window elapsed, or the annual reapplication was missed (§8) | | Whole platform gets Cloudflare-blocked from the API | 10,000+ 401/403/429 responses in 10 minutes from one IP (§10) | | Random requests blocked with a Cloudflare error | No valid `User-Agent: DiscordBot (...)` header (§10) | | Customer's admin cannot install despite being an admin | Installing requires `MANAGE_GUILD`; server-wide 2FA also gates powerful permissions (§2) | ## 13. Hand off — never commit the secret - **Do not** write the client secret, the bot token or the public key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. The bot token deserves extra care: it is one credential shared across every customer, and it cannot be re-read after it is reset. - If a code change is needed (a new callback host, a scope list, a permissions bitfield), keep it secret-free and say what the human must set out of band. - Close with: app name, Application ID, and the **Team** that owns it; where the client secret and bot token were delivered; the authorize / token / revoke URLs and the API base; **the exact scope strings**; **the permissions bitfield and which bits it is made of**; which gateway intents are toggled and whether any are privileged; Public Bot and Requires OAuth2 Code Grant states; verification and Discovery status; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: someone proposes `ADMINISTRATOR` in the permissions bitfield; resetting a bot token or client secret that live connections depend on; enabling **Requires OAuth2 Code Grant** on an app whose install flow does not complete an exchange; a privileged intent review or App Verification form asks for identity, compliance, retention or volume claims; a scope on the list is one Discord gates behind partner approval; the app is approaching 100 servers or 10,000 users and nobody owns the submission; transferring an app to a Team (one-way); installing into a server you were not told to touch; or the portal and docs do not match the **Platform state** section above. ## References Official Discord documentation only; every URL below returned HTTP 200 on 2026-09-20. Discord's developer help center (`support-dev.discord.com`) carries the App Verification and privileged-intent policy articles but is Cloudflare-protected and not machine-fetchable — open those from the portal or a browser. Note that old `discord.com/developers/docs/...` links 301 to `docs.discord.com/developers/...`. - OAuth2 (scopes, flows, bot authorization, token exchange, revocation) — https://docs.discord.com/developers/topics/oauth2 - Permissions and the bitwise permission flags table — https://docs.discord.com/developers/topics/permissions - Gateway, intents and privileged intents (close code 4014) — https://docs.discord.com/developers/events/gateway - Getting started with privileged intent review (10,000 users, 90 days, annual renewal) — https://docs.discord.com/developers/gateway/getting-started-with-privileged-intent-review - Gateway events — https://docs.discord.com/developers/topics/gateway-events - API reference (auth headers, base URL, API v10, User-Agent) — https://docs.discord.com/developers/reference - Rate limits, `X-RateLimit-*` headers and the invalid-request/Cloudflare ban — https://docs.discord.com/developers/topics/rate-limits - Opcodes and status codes — https://docs.discord.com/developers/topics/opcodes-and-status-codes - Application resource (installation contexts, install params, application flags) — https://docs.discord.com/developers/resources/application - User resource (`/users/@me/guilds`, `shard`, the 200 cap, `/users/@me/guilds/{id}/member`) — https://docs.discord.com/developers/resources/user - Guild resource — https://docs.discord.com/developers/resources/guild - Channel resource — https://docs.discord.com/developers/resources/channel - Message resource — https://docs.discord.com/developers/resources/message - Teams (roles, 2FA, one-way transfer) — https://docs.discord.com/developers/topics/teams - Getting started (portal walkthrough, bot token shown once) — https://docs.discord.com/developers/getting-started - Enabling Discovery / App Directory — https://docs.discord.com/developers/discovery/enabling-discovery - Webhook events — https://docs.discord.com/developers/events/webhook-events - Change log (docs migration, privileged intent change, `shard` requirement) — https://docs.discord.com/developers/change-log - Developer platform documentation index — https://docs.discord.com/developers/intro - Developer portal — applications — https://discord.com/developers/applications - Developer portal — teams — https://discord.com/developers/teams - Discord Terms of Service — https://discord.com/terms ## dropbox-oauth-app URL: https://docs.unified.to/skills/dropbox-oauth-app/SKILL.md # Dropbox OAuth2 App Registration Get a working Dropbox OAuth2 client — a Dropbox account, an app in the App Console, redirect URIs, scopes, and the **app key** and **app secret** (Dropbox's names for `client_id` and `client_secret`) — for a platform that connects many customers' Dropbox accounts. Three things in this portal are expensive to get wrong, and two of them are silent: 1. **The access type — App folder vs Full Dropbox — cannot be changed after the app is created.** Dropbox's own documented remedy is to delete the app and create a new one, which means a new app key and every existing customer re-authorizing. This is the single most costly mistake available here. Read §3 before clicking Create app. 2. **You only get a refresh token if the authorize URL carries `token_access_type=offline`.** Omit it and everything looks fine: the flow succeeds, you get an access token, you make a call. About four hours later the connection is dead and there is nothing to refresh with. §8. 3. **Development apps freeze at 50 linked users**, and the production review is not even looked at before you reach 50. Plan the approval before you need it, not the week the freeze lands. §6. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, publisher, description, website, icons | Shown on the consent screen and used in production review (§6) | | **Which Dropbox account owns the app** | An existing one, or a new sign-up (§2) | | **Access type: App folder or Full Dropbox** | Irreversible — decide deliberately (§3) | | **Personal/user app or team (Business API) app** | A different app type with different scopes (§3) | | **Redirect URIs** | Every callback host your platform serves (§4) | | **Scope set** | Exactly what the connector calls (§5) | | **Does the client send `token_access_type=offline` and store the refresh token?** | Blocking — see §8 | | **Expected user count and timeline** | Decides how urgent production approval is (§6) | ## Quick Start 1. Confirm a **new app** is needed — existing connections are bound to the current app key (§1). 2. Sign in to, or create, the Dropbox account that will own the app (§2). 3. Choose the access type **deliberately**, then create the app in the App Console (§3). 4. Register **every** redirect URI, exactly (§4). 5. Enable only the scopes the connector calls, on the Permissions tab (§5). 6. Decide when to apply for production status, and what the form will need (§6). 7. Copy the app key and app secret from the Settings tab (§7). 8. Confirm the client sends `token_access_type=offline` and stores the refresh token (§8). 9. Verify end-to-end with a second account, including a refresh after expiry (§9). 10. Hand the credentials over — never commit them (§10). ## Platform state (verified 2026-09-20 — re-verify before trusting) - The developer portal is **www.dropbox.com/developers**, and apps are managed in the **App Console** at `https://www.dropbox.com/developers/apps`. The documentation now lives under **docs.dropboxapi.com**; `developers.dropbox.com/oauth-guide` still resolves and redirects there. - The App Console app page has **four tabs: Settings, Permissions, Branding, Analytics.** App key and app secret, redirect URIs, status and the access type all live on **Settings**; scopes live on **Permissions**. - Apps are **scoped apps**. Scopes are selected in the Permissions tab; the access-type choice (App folder / Full Dropbox) is separate from and orthogonal to scopes. - **Access type cannot be edited.** The App Console documentation states plainly that to change it "you'll need to delete the existing app and create a new one with the desired access type." - **Long-lived access tokens are deprecated.** Access tokens are short-lived; the documented examples return `"expires_in": 14400` — four hours. Refresh tokens require `token_access_type=offline` at authorize time. - **PKCE is supported** (`code_challenge_method` of `S256` or `plain`), and is Dropbox's recommendation for clients that cannot keep a secret. Implicit grant still exists as a legacy option and should be disabled if unused. - **Development mode caps at 500 total linked users, with a hard checkpoint at 50** (§6). Team/Business apps cap at one team plus up to 5 additional development teams. - The OIDC discovery document at `https://www.dropbox.com/.well-known/openid-configuration` reports `authorization_endpoint` `https://www.dropbox.com/oauth2/authorize`, `token_endpoint` `https://api.dropboxapi.com/oauth2/token`, and token endpoint auth methods `client_secret_basic` and `client_secret_post`. If the App Console does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new app key, and every existing customer connection is bound to the old one** — all of them would have to re-authorize. Reuse the existing app for: adding a redirect URI, adding or removing a scope, rotating a compromised secret, uploading branding, or applying for production status. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, a team/Business app alongside a user app, or — the common forced case — **the access type is wrong and cannot be changed** (§3). Note that Dropbox's own guidance is one app key per distinct app, and that re-registering is a customer-visible re-authorization event, not a config change. Say which path you are taking first. ## 2. Account: sign in or sign up Any Dropbox account can create apps — there is no separate "developer account" tier to apply for. Sign in at `https://www.dropbox.com/developers` and go to the App Console. - Use an account the **company** controls, not a personal one belonging to whoever happened to set it up. The account owns the app, the credentials, the production approval and the analytics. - For **team/Business API** testing, Dropbox grants free Business development accounts by request; the request form is linked from the Business API overview and approval is discretionary. Start that early if you need it (§3). - The **same account is also the first linked user** while the app is in development mode (§6). Hand control back to the user for anything only they can do: sign-up email verification, two-step verification, accepting the developer terms and conditions, or requesting a Business development account. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste — the redirect URIs from §4 and the scope list from §5 — then continue once they report back with the app key. ## 3. Create the app — and the access-type decision you cannot undo **Settings → App Console → Create app.** The wizard asks three things, and the third is permanent. 1. **Choose an API** — the Dropbox API (user-facing) or the Business/team API path (see below). 2. **Choose the type of access you need** — **App folder** or **Full Dropbox**. See the box below. 3. **Name your app** — must satisfy the Branding Guide; names can be edited while the app is in development, but **once the app has production status the name is frozen** and only a support request can change it. ### The access type is permanent | Access type | What the app can reach | | --- | --- | | **App folder** | A single dedicated folder created for your app inside the user's `Apps` folder. Read and write **that folder only**. Users hand you content by moving files into it. | | **Full Dropbox** | All files and folders in the user's Dropbox, to the extent the scopes allow. | Scopes and access type are two different limits that both apply. A `files.content.read` scope on an App folder app reads *inside the app folder*, and nothing else — no error, just an empty or tiny view of the user's Dropbox. That is what an under-chosen access type looks like in production: not a failure, a silence. **It cannot be edited afterwards.** Dropbox's documented remedy is to delete the app and create a new one with the desired access type — a new app key, and every customer re-authorizing. So decide against the connector's actual job, not against what feels polite: - **A connector that syncs, lists, searches or reads a customer's pre-existing Dropbox content needs Full Dropbox.** App folder cannot see content the app did not put there. - **App folder is right** when the app only ever manages its own output — exports, generated documents, backups — and it is what Dropbox's review prefers, because it is the least privileged option. - **Extensions require Full Dropbox**, with no App folder alternative. If the user cannot answer this confidently, **stop and ask**. Two minutes of clarification here is worth more than every other step in this skill combined. Also note the **App folder name** setting on the Settings tab: for App folder apps it defaults to the app name and can be changed, but it must not be misleading to end users. ### Team / Business apps are a separate app type An app built on the **Dropbox Business API** is created the same way but is a different animal: - Authorization is performed by a **team administrator**, and the resulting token is associated with the **team**, not with the admin who clicked approve. The OAuth response carries an extra `team_id`. - Acting as an individual member is **impersonation via headers**: `Dropbox-API-Select-User` with a `team_member_id` for member operations, `Dropbox-API-Select-Admin` for team-owned content. Both require the **`team_data.member`** scope on the token — a dependency Dropbox enforces in the Permissions tab and in the OAuth flow. - Teams on the **team space** configuration need the `Dropbox-API-Path-Root` header to reach team content; without it, calls are rooted to the member's home namespace and team folders simply are not there. - Development mode for a team app means **one team**, plus up to 5 additional development teams enabled from the app's page. A team token and a user token are not interchangeable. If the platform needs both personal Dropbox accounts and Dropbox Business teams, that is a design question for the connector's owner — surface it, do not decide it. ## 4. Redirect URIs Register **every** callback host your platform serves, on the Settings tab under **OAuth 2 → Redirect URIs**. Dropbox checks the `redirect_uri` you send against the registered values **at authorization time**, and the match is exact. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes: - **All redirect URIs must be HTTPS**, except `localhost` URIs. - Matching is exact — no subdirectory matching, no trailing-slash forgiveness, no wildcards. A mismatch fails at connect time, not at save time, so a bad entry survives review and breaks customers. - The `redirect_uri` is technically *optional* for the code flow: with none, Dropbox shows the authorization code on screen for the user to copy. That is a manual-testing convenience, never a product flow. - **Disable "Allow implicit grant"** unless something genuinely uses it. Leaving a legacy `response_type=token` flow enabled on a server-side app is free attack surface. - The **Generated access token** button on the same tab mints a token for the owning account only. Useful for a smoke test; not an authorization mechanism. ## 5. Scopes — the Permissions tab Scopes are enabled per app on the **Permissions** tab, and requested per authorization via the space-delimited `scope` parameter on the authorize URL. The `scope` parameter can only request a **subset of what the Permissions tab has enabled**; it cannot exceed it. Omit `scope` entirely and Dropbox requests everything enabled on the tab — which is how an app ends up showing customers a consent screen far broader than it needs. Each API endpoint documents its required scope. The ones a storage connector typically needs: | Scope | Why | | --- | --- | | `account_info.read` | Read the authorizing account's profile (`/2/users/get_current_account`) | | `files.metadata.read` | List and read file/folder metadata (`/2/files/list_folder`) | | `files.content.read` | Download file contents | | `files.metadata.write` | Write file properties and tags | | `files.content.write` | Upload, create folders, move, delete | | `sharing.read` | Read sharing settings and shared links | | `openid` `profile` `email` | OIDC identity only — required for the `id_token`; `profile` for first/last name, `email` for email | | `team_data.member` | Team apps only — required to use the Select-User / Select-Admin headers | **Changing scopes after customers have authorized requires re-authorization.** A token carries the permission set it was granted; enabling a new scope on the Permissions tab does **not** upgrade tokens already issued, and a refresh can only return a subset of what was originally granted. Existing customers keep working with their old, narrower token until they run the authorize flow again — at which point the missing calls start returning 401 with the required scope named. If you widen the scope set, plan a re-authorization campaign, and look at `include_granted_scopes` (`user` or `team`) so a re-auth adds to previously granted scopes instead of replacing them. Ask for the least you need. Dropbox reviews scope breadth against actual functionality at production approval (§6), and an over-broad consent screen costs conversions before it ever costs a review cycle. ### Product fact — Unified.to's Dropbox connector **As of 2026-09-20, Unified.to's Dropbox connector requests:** `openid`, `profile` and `email` on every authorization, plus — for reading files — `files.metadata.read`, `files.content.read` and `sharing.read`; for writing files — `files.metadata.write`, `files.content.write` and `sharing.read`; and `account_info.read` for the account/employee object. Scopes are space-delimited. Also as of 2026-09-20, that connector: **appends `token_access_type=offline` to the authorize URL**, so it does receive a refresh token; **does not use PKCE** for Dropbox, sending the app secret in the token request body (`client_secret_post`, which Dropbox supports); authorizes at `https://www.dropbox.com/oauth2/authorize` and exchanges at `https://api.dropboxapi.com/oauth2/token`; refreshes with `grant_type=refresh_token` plus the app key and secret; and can run on Unified.to's **shared** Dropbox credentials rather than a customer-supplied app. Two consequences worth raising before you create anything. That scope set reads pre-existing user content and sharing state, which **App folder access cannot serve** (§3) — Full Dropbox is the likely correct choice, but confirm it. And no team scope is in that list, so this is a **user app, not a Business/team app**. Confirm both, and the current scope set, with the connector's owner before registering — this is a dated snapshot, not a contract. ## 6. Development vs production status Every new app is created in **development** status. It works exactly like a production app; it just cannot link many users. The numbers are specific, and the second one is the one that bites: - **500 total linked users** is the development ceiling. Additional users beyond your own account are enabled from the app's page with **Enable additional users**. - **At 50 linked users, a two-week clock starts.** You have two weeks to apply for *and receive* production approval. Miss it and the app's ability to link additional users is **frozen** — regardless of whether you are at 51 users or 400. - **A frozen app is only unfrozen by production approval.** Unlinking users does not help. Dropbox says this explicitly. - **Dropbox will not review a production application until the app has linked at least 50 users**, unless you make a case in the **Request early review** field of the form. So the naive plan — apply early, be safe — does not work; you apply early and wait, or you make the early-review argument. For a multi-tenant connector, treat 50 linked customers as a launch gate, and file the application (with the early-review justification if the timeline demands it) well before you approach it. **What the production application asks for**, from the app's page in the App Console via **Apply for Production**: - How your app uses the API — be descriptive; Dropbox says explicitly that more detail means faster approval. - An **app icon** (small and large), plus the Branding tab's publisher, description and app website. - Adherence to the **developer branding guidelines** and the **developer terms and conditions** — the application is rejected outright if it does not comply. Notably: do not imply Dropbox endorsed, built or partnered on the app. - A **privacy policy** specific to your app, describing what you do and do not do with user data. - Scope and access-type justification: review checks that the app does not request a broader permission than its functionality warrants. If a broader permission is for planned-but-unbuilt functionality, **say so in the request**. A denial comes with feedback and you can resubmit. **Once approved, the app name is frozen** — changing it afterwards requires a support request. Compliance attestations, legal claims, volume projections and security-questionnaire answers are business decisions. **Do not invent them** — collect them from the user or hand back. ## 7. Capture the credentials **App Console → your app → Settings tab.** The **App key** and **App secret** sit together; the secret is behind a **Show** control. Dropbox calls them app key and app secret; the OAuth spec calls them `client_id` and `client_secret`, and they are the same values. Unlike some portals, **the app secret can be re-read later** from this page by anyone with access to the owning account — which is convenient and also means account access equals credential access. Protect the owning account accordingly. Capture, for the handoff: - **App key** (`client_id`) and **app secret** (`client_secret`) - Authorize endpoint: `https://www.dropbox.com/oauth2/authorize` - Token endpoint: `https://api.dropboxapi.com/oauth2/token` - API base: `https://api.dropboxapi.com/` (content endpoints live on `https://content.dropboxapi.com/`) - The **access type** (App folder or Full Dropbox) and, for App folder apps, the app folder name - The exact **scope list** enabled on the Permissions tab - Current **status** (development or production) and the linked-user count Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. Regenerating the app secret invalidates the old one. Existing access tokens keep working until they expire (about four hours), but every token exchange and every refresh fails until the new secret is deployed — which for a connector with stored refresh tokens means every customer breaks within hours, not gradually. Never regenerate without explicit go-ahead and a cutover plan. ## 8. Token lifetimes and offline access This is the failure that looks like success. - Access tokens are **short-lived**. The documented token responses show `"expires_in": 14400` — four hours. Do not hardcode it; read `expires_in` from the response. - `token_access_type` on the **authorize** URL controls what you get back, and it **defaults to `online`**: - `online` (default) → a short-lived access token, **and no refresh token at all**. - `offline` → a short-lived access token **plus** a long-lived `refresh_token`. - The refresh token does not expire automatically and is reusable. (An optional `refresh_token_expiration_seconds` on the code exchange can give it an expiry; omit it for an indefinite token.) - Refreshing: POST `https://api.dropboxapi.com/oauth2/token` with `grant_type=refresh_token`, the `refresh_token`, and the app key and secret. You get a new access token and a new expiry. The refresh token itself is not rotated. - A user's approval stays valid until they revoke it from their Dropbox account's connected-apps settings. Revocation surfaces as a 401 — indistinguishable at the HTTP layer from an expired token, so handle both. **The trap, precisely:** forget `token_access_type=offline` and nothing complains. Authorization succeeds, the token response contains no `refresh_token`, and the connector works perfectly for one afternoon. Roughly four hours later every call returns 401 and there is no way back without dragging the customer through the authorize flow again. If the client is already deployed with `online`, fixing it is not a config change on the Dropbox side — **every affected customer must re-authorize**, because the missing refresh token was never issued. So before registering anything, confirm two separate things: that the client **sends `token_access_type=offline`**, and that it **persists the `refresh_token`** from the exchange response. Either one missing produces the same symptom. **PKCE** is available and recommended for clients that cannot keep the secret secure (desktop, mobile, single-page, open-source). Send `code_challenge` and `code_challenge_method=S256` on authorize, and the `code_verifier` (43–128 characters from `[0-9a-zA-Z\-\.\_\~]`) on the exchange in place of the secret. A confidential server-side connector may legitimately use the secret instead — but it is a choice to make knowingly, not by omission. ## 9. Verify end-to-end Testing with the owning account proves less than it appears — it is already a linked development user, and the Generated access token button bypasses the flow entirely. Test the path a customer takes. 1. Authorize from a **second Dropbox account** (enabled via **Enable additional users**) through your platform's real connect flow. 2. Inspect the raw token response: confirm a **`refresh_token`** is present, and note `expires_in` and the returned `scope` string. The `scope` string is the ground truth for what the token can do. 3. Make a read call against content the app should see. For a **Full Dropbox** app, list something the user created *before* installing the app — that is the check App folder access silently fails. 4. **Force a refresh** with `grant_type=refresh_token` and make the same call with the new access token. 5. If the account is a **Dropbox Business team**, repeat with the Select-User header and, for team-space teams, the Path-Root header. | Symptom | Cause | | --- | --- | | No `refresh_token` in the token response | `token_access_type=offline` missing from the authorize URL (§8) | | Works for ~4 hours, then every call 401s | Same — nothing to refresh with, token expired (§8) | | Redirect fails before consent, or the URI is rejected | Redirect URI not registered exactly, or not HTTPS (§4) | | Auth succeeds; user's existing files are invisible or the listing is nearly empty | App folder access type — cannot be changed, only replaced (§3) | | 401 naming a required scope | Scope not enabled on Permissions, or the token predates it and needs re-authorization (§5) | | Consent screen asks for far more than expected | `scope` parameter omitted, so all enabled scopes are requested (§5) | | No `id_token` in the response | OIDC scopes not explicitly requested, or not using `response_type=code` (§5) | | New users stop being able to link; existing ones fine | Development app frozen at the 50-user checkpoint (§6) | | Team app cannot see team folders | Missing `Dropbox-API-Path-Root` on a team-space team (§3) | | Team app 401s on a member call | Missing `team_data.member` scope, or a member ID not on the team (§3) | | Every customer breaks within hours of a credential change | App secret regenerated without a cutover (§7) | ## 10. Hand off — never commit the secret - **Do not** write the app secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a redirect host, a scope, `token_access_type=offline`), keep it secret-free and say what the human must set out of band. - Close with: app name and owning account; **access type, and the fact that it is permanent**; app key; where the secret was delivered; authorize and token endpoints; the exact scope strings enabled; whether the client sends `token_access_type=offline` and stores the refresh token; current status (development or production), the linked-user count, and the plan for the 50-user checkpoint; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the user cannot say confidently whether the connector needs **App folder or Full Dropbox** (§3 — this is unrecoverable, and worth blocking on); the client does not send `token_access_type=offline` or does not store the refresh token (report it — do not register and hope); the app needs to serve both personal accounts and Dropbox Business teams; the fix requires deleting and recreating an app that customers already use; regenerating the app secret is on the table; the production application asks for compliance, legal, privacy-policy or volume claims; branding, naming or icons must be invented; a Business development account is needed; or the App Console does not match the **Platform state** section above. ## References - OAuth Guide — https://developers.dropbox.com/oauth-guide - OAuth Guide (canonical) — https://docs.dropboxapi.com/dropbox-api/docs/oauth - Authorization reference (`/oauth2/authorize`, `/oauth2/token`, `token_access_type`, PKCE) — https://docs.dropboxapi.com/dropbox-api/docs/get-started/authorization - App Console tutorial (tabs, app key/secret, redirect URIs, access type is not editable) — https://docs.dropboxapi.com/dropbox-api/docs/get-started/tutorial/app-console - DBX Platform developer guide (App folder vs Full Dropbox, production approval, 50/500 users) — https://docs.dropboxapi.com/dropbox-api/docs/developer-resources/developer-guide - Developer branding guidelines — https://docs.dropboxapi.com/dropbox-api/docs/developer-resources/branding-guide - Developer terms and conditions — https://www.dropbox.com/developers/reference/tos - Authentication types (Select-User, Select-Admin, app auth) — https://docs.dropboxapi.com/dropbox-api/docs/auth-types - Dropbox Business API overview (team apps, development teams, `team_data.member`) — https://docs.dropboxapi.com/dropbox-api/api-reference/business-endpoints/overview - Team Files guide (namespaces, Path-Root, team space) — https://docs.dropboxapi.com/dropbox-api/docs/team-files - OpenID Connect guide — https://developers.dropbox.com/oidc-guide - Error handling (401/403 semantics, revocation) — https://docs.dropboxapi.com/dropbox-api/docs/error-handling - OIDC discovery document — https://www.dropbox.com/.well-known/openid-configuration - App Console — https://www.dropbox.com/developers/apps - Create an app — https://www.dropbox.com/developers/apps/create - Developer support / contact — https://www.dropbox.com/developers/contact - Users revoking third-party app access — https://help.dropbox.com/installs-integrations/third-party/third-party-apps - Team space overview — https://help.dropbox.com/organize/team-space-overview ## facebook-oauth-app URL: https://docs.unified.to/skills/facebook-oauth-app/SKILL.md # Facebook (Pages) OAuth2 App Registration Get a working Facebook OAuth2 client — a Meta app with the Pages use case, redirect URIs, `pages_*` permissions and an App ID and App Secret — for a platform that connects many customers' Facebook Pages on their behalf. The registration mechanics are not here; they are the same for every Meta product and live in the base skill. Three things are specific to Facebook Pages, and each one produces a failure that looks like something else. **First, the token you finish OAuth with is not the token most calls need.** Facebook Login hands you a *user* access token. Meta's own summary: "Most endpoints require Page access tokens." A user token has to be exchanged, per Page, for one of those — the documented route is `/{user-id}/accounts`, and reading the `access_token` field on a Page node works too — and a Page token minted from a short-lived user token is itself short-lived. A connector that stores the OAuth token and calls Page endpoints with it works in the Graph API Explorer and fails in production. **Second, permissions are only half of the authorization check.** Facebook has a second, per-Page axis called **tasks**. Meta: "When a User uses an app to interact with a Page, depending on the attempted action, we will first check if the User has been approved for a task that permits that type of action." A customer can grant every permission you asked for and still fail on one Page, because they hold `ANALYZE` on it and not `CREATE_CONTENT`. Nothing in your dashboard or your token shows this. **Third, the Pages use case adds a permission you did not ask for.** Selecting **Manage everything on your Page** adds `business_management`, `pages_show_list` and `public_profile` by default, and required ones "can't be removed." `business_management` is on the Access Verification list, so a read-only Page connector inherits the Tech Provider gate on day one. ## Built on: `meta-graph-app` **Read `meta-graph-app` first, in full, then come back here.** It owns: - **The Meta developer account, the business portfolio, and the app** — the creation wizard, and the fact that neither the app type nor any use case you add can be undone afterwards. The use case in §3 is one of those. - **Redirect-URI mechanics** — exact matching, HTTPS only, the dashboard's silently appended trailing slash, the requirement that authorize and token exchange send the same value, and this platform's callback list. §3 names only *which panel* on this product holds them. - **Access levels and the four review regimes** — Standard vs Advanced Access and why only app-role users can connect without it, App Review submission contents, Business Verification, Access Verification / Tech Provider and the `(#100)` that only customers see, Ongoing Review and the annual Data Use Checkup. §5 adds only which `pages_*` strings appear on the Access Verification list. - **The App ID and App Secret**, `appsecret_proof` and the "Require App Secret" switch, what rotating a secret breaks, and the rule that Meta issues no OAuth refresh tokens on any surface. - **Graph API versioning and the two-year rule**, the rate-limit headers and buckets, and the generic `#1` / `#4` / `#10` / `#17` / `#100` / `#102` / `#190` / `#200`, `#32` / `#80001` and 80000-series error codes. Pages publishes no shorter version clock of its own the way the Marketing API does, so the base's Graph API rule is the one that governs every call here. If you loaded only this file, you are missing all of the above, and nothing below substitutes for it — in particular, creating the app, registering redirect URIs, getting Advanced Access, capturing the secret and reading the generic error codes are base-skill steps. ## Inputs specific to Facebook Pages The base skill's input table applies in full. Collect these on top of it, in the same batch. | Input | Notes | | --- | --- | | **Which Page capabilities** the connector uses | Posts, reviews, insights, messaging — each maps to a different permission *and* a different task (§2, §4) | | **Read-only or publishing?** | Decides whether `pages_manage_posts` and its review submission are in scope (§4) | | **Login product**: classic Facebook Login or Facebook Login for Business | Changes the consent payload from `scope` to `config_id` and the panel the redirect URIs live in (§3) | | **Token type**: user access token or Business Integration System User token | Decides whether re-authorization is a scheduled campaign (§6) | | **A test Page you can perform every needed task on**, plus a second Page owned by someone with **no role on your app** | The only test that exercises both gates (§8) | | **Will the connector consume Page webhooks?** | Needs an app-level subscription *and* a per-Page install (§7) | ## Quick Start 1. Read **Platform state** below — the permissions reference is currently unreachable and the Pages guides contain permission-string typos, so where you copy strings from matters. 2. Create the app per the base skill and add the **Manage everything on your Page** use case (§3). 3. Decide classic Facebook Login vs Facebook Login for Business, and register the redirect URIs in that product's settings panel (§3). 4. Prune the use case's default permissions to what the connector calls, and know each one's task requirement (§4). 5. Run the base skill's four review regimes; note which `pages_*` strings are also on the Access Verification list (§5). 6. Wire the user token → `/me/accounts` → Page token exchange, and decide the token term *before* launch (§6). 7. If you need webhooks, subscribe the Page object at app level **and** install the app on each Page (§7). 8. Verify with a Page whose owner has no role on your app, and with a person who holds only some tasks (§8). 9. Hand off with the token term, the task requirements and the review status recorded (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) Read this alongside the base skill's Platform state; these are the Pages-specific facts. - **Meta's permissions reference is broken today.** `developers.facebook.com/docs/permissions` returns **HTTP 500**, and the `/documentation/permissions` twin returns HTTP 200 with a "Page Not Found" body. Meta's own Pages pages link to it. Until it is back, take exact permission strings from the Facebook Login for Business supported- permissions table or from each endpoint's Graph API reference, not from a guide. - **The Pages guides contain permission-string typos.** The Pages API Get Started page lists `pages_manage_read_engagement`; the Manage a Page and Posts guides list `pages_read_user_engagement`. Neither string appears in the Facebook Login for Business supported-permissions table, which lists `pages_read_engagement` and `pages_read_user_content`. Do not paste a permission out of a guide into an authorize URL. - **The Pages use case force-adds permissions.** Customizing **Manage everything on your Page**: "The following permissions are required for this use case and added by default: `business_management`, `pages_show_list`, `public_profile`," and "If a permission or feature is required for a use case, it can't be removed." - **Page Insights metrics have been deprecated in waves, for all API versions at once.** 14 March 2024, 15 June 2025 and 15 November 2025. `page_fans` → `page_follows`, `page_impressions` → `page_media_view`, `page_impressions_unique` → `page_total_media_view_unique`, `post_impressions` → `post_media_view`. Meta: "The API will return an invalid metric error when calling any of these metrics." Pinning an old Graph version does **not** protect you — these are version-independent. - **Meta's two Insights pages disagree by a year.** The Page Insights reference carries a banner reading "By June 15, **2026**, a number of the Page Insights metrics will be deprecated for all API versions," while the deprecated-metrics list dates that same wave "By June 15, **2025**." Trust the dated list, and say in your handoff which you followed. - **Page Insights has hard data limits** that read as bugs: "Page Insights data is only available on Pages with 100 or more likes," "Only the last two years of insights data is available," and "Only 90 days of insights can be viewed at one time when using the `since` and `until` parameters." - **`pages_manage_metadata` and `pages_messaging` are *not* on the Access Verification list**, while `pages_show_list`, `pages_read_engagement`, `pages_read_user_content`, `pages_manage_posts`, `pages_manage_engagement`, `pages_manage_ads`, `pages_manage_cta`, `pages_manage_instant_articles`, `page_events`, `pages_utility_messaging` and `read_insights` all are (§5). - **Every `pages_*` permission is available to both token types** under Facebook Login for Business — the supported- permissions table marks all of them ✓ for user access tokens *and* for Business Integration System User tokens. - **Switching an existing app to Facebook Login for Business is reversible for 30 days**, and the switch is not free: "If you request permissions or features from business clients that Facebook Login for Business doesn't support, those permissions and features will be **revoked immediately** once you switch." Newly created Business type apps cannot switch back at all. If the App Dashboard does not look like this, stop and report what you actually see rather than clicking on. ## 1. Reuse the existing app, or register a new one The base skill's reuse-versus-new decision applies unchanged. One Pages-specific addition: because the Pages use case cannot be removed once added (base §3) and it force-adds `business_management` (§ Platform state), an app created "just to try Pages" carries a permission on the Access Verification list forever. If a ticket proposes adding the Pages use case to an app that deliberately avoids Tech Provider review, that is a new-app decision, not a config change. Say which path you are taking before you touch anything. ## 2. The Page, its roles, and its tasks This is the concept with no equivalent on the other Meta surfaces, and the one that produces "it works for me." Permissions say what *your app* may attempt. **Tasks** say what *this person* may do *on this Page*. Meta checks the task first. The full set: | Task | Permitted actions (Meta's wording, abridged) | | --- | --- | | `ADVERTISE` | Create ads; create unpublished Page posts; create ads for a connected Instagram account | | `ANALYZE` | View Insights of the Page; view which Page admin published a post or comment | | `CREATE_CONTENT` | Publish content as the Page on the Page | | `MANAGE` | Assign and manage Page tasks | | `MANAGE_LEADS` | View and manage leads | | `MESSAGING` | Send messages as the Page | | `MODERATE` | Respond to and delete comments on Page posts; for a connected Instagram account, publish, moderate, message, sync contact info and create ads | | `VIEW_MONETIZATION_INSIGHTS` | View monetization insights | "If a person is given Admin access to a Page in the UI, that person is able to perform all tasks on that Page" — which is why testing with your own admin account never surfaces a task problem. What each Page capability actually demands, from the endpoint references: | Capability | Permissions | Page task the person must hold | | --- | --- | --- | | List the Pages a person manages | `pages_show_list` | — (the response's `tasks` array tells you what they hold) | | Read a Page's feed | `pages_read_engagement` + `pages_read_user_content` | `CREATE_CONTENT`, `MANAGE` **or** `MODERATE` | | Publish to a Page's feed | `pages_manage_posts` | `CREATE_CONTENT` | | Read a Page's recommendations/ratings | `pages_read_user_content` | `CREATE_CONTENT`, `MANAGE` **or** `MODERATE` | | Read Page Insights | `read_insights` + `pages_read_engagement` | `ANALYZE` | | Read or write a Page's webhook subscription | `pages_manage_metadata` + `pages_show_list` | `CREATE_CONTENT`, `MANAGE` **or** `MODERATE` | Two further traps here: - **Pages the customer does not own or manage are a different product.** Meta, on reading a Page's feed: "If you do not own or manage the Page, you will need the Page Public Content Access Feature" — a Feature reviewed separately from every permission, and Meta adds "use a system user access token to avoid rate limiting issues." - **You cannot fully enumerate who holds what.** `GET /{page-id}/roles` needs `MANAGE`, and "This edge only returns people who do not belong to a business. To find business users, query the Page Assigned Users edge." For a business-managed Page — i.e. most customers — the roles edge will look emptier than reality. Fixing a missing task is the Page owner's job in Page settings. Hand it back; do not loop. ## 3. The use case, the login product, and the redirect URIs **Add the use case.** In the creation wizard (base §3), pick **Manage everything on your Page**. Then **Dashboard → Customize the Manage everything on your Page use case** to add and remove permissions. Per the base skill this use case cannot be removed later, and its required permissions cannot be removed at all (§ Platform state). **Pick the login product, deliberately.** Meta ships two and the Pages API overview is explicit about which it prefers: "Facebook Login for Business is the preferred authentication and authorization solution for Tech Providers and business app developers who need access to their business clients' assets," while classic Facebook Login "is recommended for consumer authentication." | | **Classic Facebook Login** | **Facebook Login for Business** | | --- | --- | --- | | App type | Any | **Business** type only | | Consent payload | `scope` | `config_id` — a saved configuration; "`config_id` has replaced `scope` (which should not be used)" | | Partial grants | A user can decline individual permissions | All-or-nothing across the configuration | | Token types offered | User access token | User access token **or** Business Integration System User token (§6) | | Rollback | — | Allowed within **30 days** of switching; unsupported permissions are revoked immediately on switch | The configuration's own mechanics — creating one, the all-or-nothing grant, `response_type=code` for system-user tokens — are the same as on the ads surface and are documented in `meta-ads-oauth-app` §1; read them there rather than re-deriving them. What matters here is that the choice is visible to customers and, if you later roll back, "Facebook Login does not support the `config_id` parameter and you need to replace the `config_id` parameter with the `scope` parameter instead" — a client change, not a dashboard toggle. **Redirect URIs** live in that product's settings panel: **App Dashboard → Facebook Login (for Business) → Settings → Redirect URI**, with a **Check URI** button that validates the value before you save. Use it — the base skill's exact-match and trailing-slash rules are precisely what it checks for you. The same panel holds the **Deauthorize Callback URL** and the **Data Deletion Request URL**, both of which App Review asks about. **Product facts about the connector, as of 2026-09-20.** Recorded from one day's configuration; confirm with the connector's owner before relying on any of it. - It uses the **classic Facebook Login flow with `scope`**, not a Facebook Login for Business `config_id`. It sends `client_id`, `redirect_uri`, `state` and `scope` to the authorize dialog, and exchanges the code against the Graph host's token endpoint with `client_id`, `redirect_uri`, `client_secret` and `code` carried as a **JSON POST body** — the client secret travels in the request body, not in a Basic auth header and not in the query string. - Both its authorize URL and its token URL are **unversioned**. Per base §8 that means they resolve against whatever version the app dashboard's upgrade card is set to, and will drift when that changes. - Its Page-object calls are pinned to **Graph v21.0**, hard-coded into the request path of every object it implements — so the pin is a multi-file change, not a config edit. Per the base's version table v21.0 is in the last months of its life; calendar the re-pin. - It presents the access token as a **URL query parameter**, not an `Authorization: Bearer` header. Meta accepts both; the query-parameter form is the one that ends up in proxy and access logs. - Its login/identity scopes are `email` and `public_profile`, and on connect it reads the authorizing person's name and email from the Graph `/me` node. - **This platform ships shared default OAuth credentials for Facebook**, unlike its Instagram connector, which ships none. That means one shared Meta app's App Review, Advanced Access and Tech Provider status govern every customer who does not bring their own App ID and Secret — so the review work in §5 is not optional for it, and a single app-level restriction takes down every customer at once. - Two sibling connectors exist on the same Graph host, and each needs **different things registered**, so do not assume one app covers all three: the Messenger connector needs the **Messenger product** added plus an app-level webhook callback URL and verify token configured once per Meta app, and it stores an **App Secret per connection** for signature checking; the Marketplace connector is not a Pages product at all — it runs on Commerce/Catalog endpoints with `catalog_management` and `business_management` and depends on approved Marketplace Partner status. The Pages connector needs none of those, and they need none of the `pages_*` permissions below. ## 4. Permissions Request only what the connector calls; the base skill explains what an unused permission costs at review time. These are the strings as the Facebook Login for Business supported-permissions table spells them. | Permission | Grants | Add it when | | --- | --- | --- | | `pages_show_list` | Listing the Pages a person can perform a task on, with their tasks and Page tokens | Always — it is how you reach any Page at all (§6) | | `pages_read_engagement` | Reading a Page's own content and metadata | Always, for any read | | `pages_read_user_content` | Reading user-generated content on a Page: feed posts, comments, recommendations | Reading a feed or reviews | | `pages_manage_posts` | Publishing and scheduling Page posts | Only if the connector publishes | | `pages_manage_engagement` | Moderating comments and reactions as the Page | Only if the connector moderates | | `pages_manage_metadata` | Page settings — and the webhook subscription edge | Only if the connector manages settings or webhooks (§7) | | `read_insights` | Page and post Insights | Only if the connector reads metrics | | `pages_messaging` | Messaging as the Page | A different product — see the Messenger surface, not this one | Two Facebook-only properties of this family: - **They are exempt from the 90-day data-access expiry.** Meta's data-access rule is that "The expiration period for data access is 90 days, based on when the user was last active," after which "your app can't access their data" until re-authorization — but every permission in the table above appears on Meta's list of permissions that "do not expire." Your Pages permissions therefore survive an inactive customer; the *token* still does not (§6). Note the separate rule that does still bite: "if your app does not use a permission for 90 days, that permission may expire," even one approved through App Review. - **A grant can be partial by Page, not just by permission.** Debugging a token shows `granular_scopes` with a `target_ids` array — the specific Pages a scope was granted for. A customer who ticks two of their six Pages in the consent dialog produces a token that legitimately holds `pages_show_list` and legitimately cannot see four of their Pages. Read `granular_scopes` from the debug endpoint before treating a short Page list as a bug. **As of 2026-09-20, this platform's Facebook connector requests** `pages_show_list` and `pages_read_engagement` as its base Page-reading set, adds `pages_read_user_content` for Page posts and for reviews/recommendations, adds `pages_manage_posts` for publishing, and adds `read_insights` for Page metrics. That set lines up with the endpoint requirements in §2. It does **not** request `pages_manage_metadata`, which means it cannot manage Page webhook subscriptions (§7). Confirm the current set with the connector's owner before you register anything. ## 5. What the base skill's gates mean here All four review regimes in the base apply unchanged. Four Pages-specific consequences: **Nothing is grantable to a stranger without App Review.** Meta, on the Pages API: "All permissions require App Review before an app user can grant them to your app after it is live. For Business apps, which do not have app modes, permissions must be approved for Advanced access before they can be granted to your app by an app user without a role on the app itself or a role in a Business that has claimed it." **Most of this family is on the Access Verification list**, so a multi-tenant Page connector needs Tech Provider status for the claiming business on top of App Review and Business Verification, and will show the base skill's `(#100)` symptom until it has it. Covered: `pages_show_list`, `pages_read_engagement`, `pages_read_user_content`, `pages_manage_posts`, `pages_manage_engagement`, `pages_manage_ads`, `pages_manage_cta`, `pages_manage_instant_articles`, `page_events`, `pages_utility_messaging`, `read_insights` — and `business_management`, which the use case adds for you. Not covered: `pages_manage_metadata`, `pages_messaging`. **The use case pre-commits you.** Because `business_management` is required by the Pages use case and is on the list, there is no read-only Pages configuration that escapes Access Verification. Budget for it from the start rather than discovering it when the first customer cannot connect. **Features are a separate approval from permissions.** Meta: "Some endpoints require a Feature which must be approved through the App Review process before your app can use them when your app goes live. Features allow you to access public Page data without a permission or the ability to perform a task on the Page." Page Public Content Access is the one a Pages connector most often discovers it needs (§2), and asking for it changes the review conversation. ## 6. Tokens: the third rung the other Meta surfaces do not have The user-token ladder itself — the short-lived token from the code exchange, the server-side exchange into a ~60-day long-lived token, and the fact that an expired token cannot be exchanged — is identical to the ads surface and is documented in `meta-ads-oauth-app` §5. Read it there. What Pages adds is a **third rung**, and its term is inherited rather than chosen: | Rung | How you get it | Term | | --- | --- | --- | | User access token | The code exchange (base §7; `meta-ads-oauth-app` §5) | Short-lived, then long-lived if you exchange it | | **Page access token from a short-lived user token** | `GET /{user-id}/accounts` with that token | Meta calls what comes back "a **short-lived** Page access token" | | **Page access token from a long-lived user token** | `GET /{user-id}/accounts` with the long-lived token; "The person requesting the token must have a role on the Page" | Meta: long-lived Page access tokens "do not have an expiration date and only expire or are invalidated under certain conditions" | | Business Integration System User token | A Facebook Login for Business configuration (§3) | Defaults to never expire | The consequences that decide whether Page connections survive: - **The Page token's term is decided upstream, not at `/me/accounts`.** The same call returns a token that dies in an hour or a token that does not expire, depending entirely on which user token you called it with. This is the single highest-leverage fact on this surface: doing the user-token exchange *before* enumerating Pages converts a connector from hourly breakage to indefinite operation. - **Page tokens are per person, per Page, per app.** "Page access tokens are unique to each Page, admin, and app combination." Caching them is fine; caching them without tying the entry to all three, and without an expiry, is how a stale token outlives the user token that minted it. - **The Business Integration System User token is the alternative that removes re-authorization**, and the FLB supported-permissions table marks every `pages_*` permission available to it. Its general mechanics are in `meta-ads-oauth-app` §5; the Pages-relevant prerequisites Meta states are that logins can only come from **web surfaces**, the customer must have or create a business portfolio, and **your app must be associated with a business portfolio you have full control of, separate from the client's**. To test the flow "the tester must have a role on the app and full control of the client business" — so this path cannot be smoke-tested with a stranger's Page the way §8 tests the user-token path. - **Either token type can be revoked by the customer, in two different places.** Business Integration System User tokens: **Business Manager → Settings → Business Settings → Integrations → Connected apps**. User tokens: **Settings & privacy → Settings → Security and login → Business Integrations**. Neither revocation notifies you; you learn about it from a `(#190)`. **Product facts about the connector, as of 2026-09-20.** Confirm with the connector's owner before relying on them. - **It performs no short-lived → long-lived exchange at all**, and neither does any other connector on this platform: Meta's token-extension parameter appears nowhere in the product, so no Facebook, Messenger or Marketplace connection ever gets a long-lived user token. Given that Meta's code-grant user token is good for about one to two hours and Meta issues no refresh token on any surface (base §7), a Facebook connection is expected to stop working within about two hours of being created, and the customer must re-authorize to restore it. This is the single most consequential gap on this surface and should be raised with the connector's owner before any registration work is treated as finished. - **The same gap compounds at the Page rung.** Because Page tokens are minted from that short-lived user token, every Page token the connector obtains is itself short-lived, and no long-lived Page token — the one Meta says has no expiration date — is ever created. Fixing the user-token exchange fixes both rungs at once; fixing neither means the connector never reaches the "never expires" state that this surface makes available. - It resolves Page tokens on demand and **caches them in an in-process cache that evicts only on capacity, with no time-to-live**, so an entry can be served after the user token behind it has expired. It also keys the cache by the connection when it has one and by the *workspace* when it does not, so two connections in one workspace can read each other's cached Page token. - It obtains the Page token by reading the `access_token` field on the Page node rather than from the accounts listing, and then **mutates the in-flight connection's stored credential in place** to swap the user token for the Page token — an approach its own notes describe as deliberate. It is worth confirming with the connector's owner that the mutated object cannot outlive the request or be persisted, because a Page token written back onto a connection record would be a silent, hard-to-diagnose credential substitution. ## 7. Webhooks: two subscriptions, not one Page webhooks fail silently for a reason that is not a permission problem, and this is the Facebook-specific shape of it: **there are two independent subscriptions and you need both.** 1. **App level.** Configure the callback URL and verify token once per Meta app, choose the **Page** object, and subscribe to the fields you want — `feed` for posts, reactions and shares; `messages` for Messenger. 2. **Page level.** "Webhook notifications will only be sent if your Page has installed your Webhooks configured-app, and if the Page has not disabled the App platform in its App Settings." You install it by posting to the Page's `subscribed_apps` edge **with that Page's access token**. And the rule that turns a half-finished setup into silence rather than an error: "It is possible to subscribe to more fields at the page level than the app level. However, **only fields with subscriptions at both the page and app levels will get Webhooks**." Requirements for the Page-level install: a Page access token from a person who can perform `CREATE_CONTENT`, `MANAGE` or `MODERATE` on that Page, plus `pages_manage_metadata` and `pages_show_list`. Messaging fields need the `MESSAGING` task and `pages_messaging` instead. **Product facts about the connector, as of 2026-09-20.** The Facebook Pages connector implements **no webhook handling and does not request `pages_manage_metadata`**, so Page events are not available through it today; reads are poll-only. The separate Messenger connector does implement the two-level pattern — an app-level callback configured by hand and a programmatic per-Page install — which is a working reference for what adding Page webhooks here would involve, and a reminder that the app-level half is a dashboard task a human must do. ## 8. Capture the credentials, and verify The App ID and App Secret, and where they live, are in the base skill. Unlike the Instagram surface there is **no second, separately-displayed pair** here — if someone hands you an "Instagram App ID" for a Pages connector, they are on the wrong product. Record these Pages-specific values alongside the base skill's list: the use case you added and the permissions it force-added; the login product and, if Facebook Login for Business, the configuration ID and token type; the task each capability requires; whether Page Public Content Access is in scope; and the pinned Graph version. Verification follows the base skill's round trip, with these layered on: 1. Run authorize → callback → code exchange, then **exchange the user token for a long-lived one and confirm the connector stored the new value** before doing anything else. Everything below inherits that decision (§6). 2. Call `/me/accounts` and read the `tasks` array that comes back for each Page. That array, not your permission list, is what predicts which calls will succeed. 3. Make one real read with a **Page** token — a feed read or an Insights read — at the pinned version. 4. Repeat against a Page owned by someone with **no role on your app**. Base §10 explains what the two failures at this step mean and which gate each one points at; this is the only test that reaches either. 5. Repeat once more as a person who holds only `ANALYZE` on the Page. Insights should work and publishing should not. If publishing succeeds, you tested with an admin account by mistake. | Symptom | Cause | | --- | --- | | Works in the Graph API Explorer, fails from the connector | The Explorer's Page-token dropdown was used; the connector is still sending the user token (§6) | | Every Page call dies about an hour or two after each connect | The short-lived user token was never exchanged, so the Page tokens minted from it are short-lived too (§6) | | `(#283)` *That action requires the extended permission `pages_read_engagement` and/or `pages_read_user_content` and/or `pages_manage_ads` and/or `pages_manage_metadata`* | The named permission was not granted — Facebook's own hint at which one (§4) | | `(#210)` *User not visible* | The call needs a Page access token and is being made with a user token, or the Page/user pair is not visible to this token (§6) | | `(#190)` subcode `463` / `460` / `458` | Session expired / user logged out or changed password / user de-authorized the app — three different remediations, all requiring the subcode to tell apart | | `(#459)` or `(#483)` | The person is checkpointed, or in consent-app blocking — customer-side, unfixable by you | | `(#368)` | The action "has been deemed abusive or is otherwise disallowed" — publishing volume or content, not auth | | One customer succeeds on some Pages and fails on others | Either a missing **task** on those Pages (§2), or a granular grant that covered only some Pages — check `granular_scopes.target_ids` (§4) | | Publishing fails for a customer who can read fine | They hold `ANALYZE`/`MODERATE` but not `CREATE_CONTENT` on that Page (§2) | | Reading a Page the customer does not manage fails | Needs the **Page Public Content Access** Feature, reviewed separately from permissions (§2, §5) | | Insights returns an invalid-metric error | A deprecated Page Insights metric is in the request; one bad name fails the whole call, and pinning an old version does not help (§ Platform state) | | `(#3001)` subcode `1504028` | No metric was specified on an Insights call | | Insights returns nothing for a small Page | Page Insights needs 100 or more likes (§ Platform state) | | Insights range silently truncated | More than 90 days requested at once with `since`/`until` (§ Platform state) | | Webhooks never arrive, no error anywhere | The field is subscribed at only one of the two levels, or the Page never installed the app, or the Page disabled the App platform (§7) | | The roles list looks emptier than the customer says | `/{page-id}/roles` omits people who belong to a business; query Page Assigned Users (§2) | | A permission approved in review stops working after a quiet quarter | Unused permissions may expire after 90 days (§4) | ## 9. Hand off Follow the base skill's handoff rules and add, to its closing list: the use case added and the permissions it force-added; the login product and, if Facebook Login for Business, the configuration ID, token type and the date the 30-day rollback window closes; the exact `pages_*` strings and the Page task each capability requires; whether Page Public Content Access is needed; the token term the platform ends up with at both the user and Page rungs, and the re-authorization cadence that implies; the pinned Graph version; and, if webhooks are in scope, which fields are subscribed at app level and how Pages get installed. ## Stop and ask The base skill's stop-and-ask list applies. Additionally, hand back to a human when: the connector calls Page endpoints with a user access token and there is no plan to mint Page tokens, since that is a code change and not a registration one; there is no short-lived → long-lived exchange, which makes every connection and every Page token derived from it short-lived; the choice between user access tokens and Business Integration System User tokens has not been made, since it decides whether re-authorization is a recurring campaign; adding the Pages use case would force `business_management` onto an app whose owner does not want Tech Provider review; customers' Pages are business-managed and you cannot confirm who holds which task; the connector needs to read Pages the customer does not manage, which is a Feature review rather than a permission; or an Insights implementation hard-codes metric names with no plan to track Meta's deprecation waves. ## References Facebook Pages specific. The generic Meta developer-account, app-creation, review, credential, token-ladder and Graph API references are in `meta-graph-app` and `meta-ads-oauth-app`. Every link below verified to return HTTP 200 on 2026-09-20. - Pages API overview (tasks, Features, Page access tokens, permissions, the `/me/accounts` flow) — https://developers.facebook.com/docs/pages-api/overview - Customize the "Manage everything on your Page" use case (forced permissions, redirect URI panel, Tech Provider) — https://developers.facebook.com/docs/pages-api/create-an-app - Pages API get started (short-lived Page tokens, composite post IDs) — https://developers.facebook.com/docs/pages-api/getting-started - Manage a Page (tasks and tokens from `/user_id/accounts`, `/page_id/roles`) — https://developers.facebook.com/docs/pages-api/manage-pages - Pages API posts (publishing, scheduling, Page Public Content Access) — https://developers.facebook.com/docs/pages-api/posts - `/{user-id}/accounts` reference (the `tasks` field, error codes `283`, `459`, `483`, `368`) — https://developers.facebook.com/docs/graph-api/reference/user/accounts/ - Page feed reference (read/publish tasks and permissions, Page Public Content Access) — https://developers.facebook.com/docs/graph-api/reference/page/feed/ - Page ratings reference (recommendations: `pages_read_user_content`, error `210`) — https://developers.facebook.com/docs/graph-api/reference/page/ratings/ - Page insights reference (`read_insights` + `ANALYZE`, the 100-like and 90-day limits, error `3001`/`1504028`) — https://developers.facebook.com/docs/graph-api/reference/page/insights/ - Page roles reference (business users are not returned) — https://developers.facebook.com/docs/graph-api/reference/page/roles/ - Page `subscribed_apps` reference (the both-levels rule, install permissions) — https://developers.facebook.com/docs/graph-api/reference/page/subscribed_apps/ - Webhooks for Pages (app-level fields, per-Page install, messaging requirements) — https://developers.facebook.com/docs/graph-api/webhooks/getting-started/webhooks-for-pages - Authentication versus data access (the 90-day data-access expiry and the permissions exempt from it) — https://developers.facebook.com/docs/facebook-login/auth-vs-data - Access token debugging and error handling (`granular_scopes`, subcodes `458` / `460` / `463`) — https://developers.facebook.com/docs/facebook-login/access-tokens/debugging-and-error-handling - Deprecated Facebook Page Insights metrics (the 2024, 2025 waves and their replacements) — https://developers.facebook.com/docs/platforminsights/page/deprecated-metrics/ - Page Insights API updates, 15 August 2025 (the `impressions` and `page fans` deprecation notice) — https://developers.facebook.com/blog/post/2025/08/15/page-insights-api-updates/ ## fathom-oauth-app URL: https://docs.unified.to/skills/fathom-oauth-app/SKILL.md # Fathom OAuth2 App Registration **Which Fathom this is:** the **AI meeting notetaker** — `fathom.video` / `fathom.ai`, developer docs at `developers.fathom.ai`, API at `https://api.fathom.ai/external/v1`, objects called *meetings*, *recordings*, *transcripts*, *teams*. It is **not Fathom Analytics (`usefathom.com`)**, the privacy-first website analytics product whose API talks about *sites*, *pageviews* and site-scoped API tokens. If the page in front of you says "sites", close it — you are on the wrong Fathom. Get a working Fathom OAuth2 client — a Fathom account, a registered marketplace application, redirect URIs, the `public_api` scope, and a client ID and secret — for a platform that connects many customers' Fathom accounts. Four things here cost more than they look. 1. **One production redirect URI per app.** Fathom's FAQ is explicit: multiple *development* redirect URIs are supported, and "for multiple production URIs, use separate OAuth apps." A platform with four regional callbacks therefore needs **four apps and four credential pairs**, not one app with four URIs (§4). 2. **OAuth tokens cannot ask `/meetings` for content.** `include_summary` and `include_transcript` are documented as "Unavailable for OAuth connected apps." Summaries and transcripts must be fetched one recording at a time from the `/recordings` endpoints — which are the *heavy* rate-limit class, 30 calls per 60 seconds and reducible to 5 (§8). 3. **There is exactly one scope, `public_api`.** There is no read/write split and no per-object scope. What a credential can see is decided entirely by the Fathom user's own view permissions — which is also the single most common support ticket in this category (§6). 4. **Refresh tokens are one-time-use and rotate.** Two workers refreshing the same connection concurrently means one succeeds and the other gets **HTTP 400** (§7). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, description, logo | Shown to the customer at install and on any listing | | **Which Fathom account registers the app** | Registration is behind a normal Fathom login (§2) | | **Route: OAuth app, or per-customer API keys** | Decides everything downstream (§1) | | **Every production callback URL** | One per app — count them, that is your app count (§4) | | **Development callback URLs** | Multiple are allowed on one app (§4) | | **Whether customers need org-wide coverage** | Drives the admin view-access conversation (§6) | | **Whether you need webhooks** | Secret and webhook ID are returned **once**, at creation (§7, §8) | | **Whether you need media downloads** | Separate rate class, signed URLs expire ~24h (§8) | | **Support/contact address for launch** | Fathom routes partner questions through `help@fathom.video` | ## Quick Start 1. Confirm the route — OAuth app for a multi-tenant connector, API keys for one customer's own tooling (§1). 2. Count production callbacks; that is how many apps you will register (§4). 3. Sign in to Fathom with the account that will own the app (§2). 4. Register at `https://fathom.video/marketplace_applications/new` — a ~2-minute self-serve form (§3). 5. Enter redirect URIs; HTTPS is required (§4). 6. Use the only scope there is: `public_api` (§5). 7. Read §6 before promising any customer "all our meetings" — that is not what a credential gets by default. 8. Capture client ID and secret; store rotated refresh tokens and take a lock around refresh (§7). 9. Verify from a **second, unrelated** Fathom account, then hand the credentials over (§9, §10). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **OAuth registration is self-serve, not partner-gated.** Fathom's OAuth guide points at `https://fathom.video/marketplace_applications/new` with the caption "Configure your redirects and receive your OAuth credentials. (2 mins)". Nothing in the documented flow makes credentials wait on a partnership call. - **The "review" in Fathom's OAuth page is about promotion, not permission.** The page says OAuth apps "are eligible for promotion by Fathom and can unlock visibility in our App Marketplace… They are subject to review to ensure the best experience for all users," and then describes the mechanism: a **quarterly** review of app usage, and at **20+ users** Fathom reaches out about their integrations listing, Crossbeam and co-marketing. No documented pre-launch approval gate blocks installs. If the registration form tells you otherwise, stop and report that. - **API access is on every plan.** "API access—keys and webhooks—are included on all plans." There is no API tier to buy, and recordings/transcripts/summaries are not separately licensed add-ons. - **One scope exists: `public_api`.** "Currently, the only available scope is: `public_api` — Access to the Fathom API." - **Documented token endpoint:** `https://api.fathom.ai/external/v1/oauth2/token`, `application/x-www-form-urlencoded`, grant types `authorization_code` and `refresh_token`, with `client_id` and `client_secret` **in the body** — no HTTP Basic variant is documented. Fathom's own partner example (Pylon) posts to the same URL the same way. - **The literal authorize URL is not printed in the docs** — the SDKs build it from client ID, redirect URI, scope and state. Observed on 2026-09-20: an unauthenticated `GET /external/v1/oauth2/authorize` returns **302 to that host's sign-in page** on both `api.fathom.ai` and `fathom.video`, while an unknown path under the same prefix returns 404. Treat the exact host as **confirmed by behaviour, not by documentation**, and take the value the registration form or SDK gives you over any value you inherited. - **Refresh tokens are single-use and rotate.** Each refresh returns a new access token *and* a new refresh token and invalidates the old one; an unused refresh token "stays valid until the user revokes access." Concurrent refresh of one connection returns **HTTP 400** to the loser — "wrap refreshes in a lock if you run them concurrently." - **Redirect URIs must be HTTPS**, multiple **development** URIs are supported, and **multiple production URIs are not** — use separate apps. - **API shape:** base `https://api.fathom.ai/external/v1`; cursor pagination via `next_cursor` → `cursor`; **default page size 10 and no parameter to raise it**; no bulk endpoint; no `GET /meetings/{id}` — you list with filters, and the numeric ID in a Fathom call URL is **not** the `recording_id` the API uses. - **Rate limits:** 60 requests per 60 seconds globally, **per account** (per API key or OAuth token); **heavy requests** — the `/recordings` summary and transcript endpoints, plus `/meetings` with `include_summary` or `include_transcript` — 30 per 60 seconds, "during periods of elevated activity this limit may be adjusted down to 5 every 60 seconds"; recording downloads 30 per 60 seconds; the OAuth token endpoint 60 per 60 seconds **per OAuth app**. Responses carry `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, and `Retry-After` on 429. - **Recently added** (Fathom changelog): API + SDKs + webhooks October 2025; MCP April 2026; `meeting_url`, `meeting_type`, `include_highlights`, `shared_with`, and webhook `id` in June 2026; the **download** endpoint and the **users-and-permissions** endpoint in July 2026. Anything written before mid-2026 will not know about the last two. - **No real-time transcripts.** "Transcripts are available only after post-call processing completes." If the portal or the docs do not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: OAuth app, or per-customer API keys Fathom supports two credentials, and its own FAQ draws the line: use an **API key** "if you only need access to your own (or your team's) meetings—for internal tools or personal automation"; use **OAuth** "if you're building an app that other Fathom users will install so they can connect their own accounts." A platform connecting other organizations' accounts is the OAuth case. | | OAuth app | Per-customer API key | | --- | --- | --- | | Who creates it | You, once per callback host | Each customer, in their own settings | | How it is sent | `Authorization: Bearer ` | `X-Api-Key: ` header | | Customer effort | Click Allow | Generate a key and paste it to you | | `include_summary` / `include_transcript` on `/meetings` | **Not available** | Available (heavy rate class) | | Expiry | Short-lived access token + rotating refresh token | No documented expiry; dies when the user is deactivated | | Revocation | User revokes access | Only by deactivating/removing that user — "Admins can't directly revoke another user's API key" | A key detail for the API-key route: keys keep working if the user leaves a team or downgrades — only *what they can see* changes. And when a user is deactivated, their key "stops working and will return **4xx**." **Registering a new OAuth app mints a new client ID, and every existing customer authorization is bound to the old one.** Reuse the existing app for changing a redirect, rotating a compromised secret, or diagnosing a failure. Register a new one only for a new callback host (§4), a replacement for a compromised app, or a deliberately separate product. Say which path you are taking before you touch anything. ## 2. Account: sign in or sign up Sign in at `https://fathom.video/`. Observed 2026-09-20: both `https://fathom.video/marketplace_applications/new` and the settings page at `https://fathom.video/customize` redirect an unauthenticated request to `https://fathom.video/users/sign_in` — so a Fathom login is a prerequisite for both the OAuth form and the API-key screen. API access is included on all plans, so a free account is enough to *register*; what a test account can *see* is a separate question (§6). Hand control back to a human for anything only they can do: account signup, email verification, accepting terms, or any SSO/2FA step. Do not retry a blocked step in a loop. **If this session has no browser automation**, do not pretend to click. Hand the user an ordered click path and the literal values to paste — the redirect URIs from §4 and the scope from §5 — and continue when they come back with the client ID. ## 3. Register the app `https://fathom.video/marketplace_applications/new`, described by Fathom as "Configure your redirects and receive your OAuth credentials. (2 mins)". The form is behind the login, so **do not narrate fields you cannot see** — read what is on screen and report it. Expect, at minimum, an app name, redirect configuration, and the issued client ID and secret. Launch-side expectations Fathom documents, none of which block credentials: - Before sharing the app, review Fathom's About Us page for boilerplate language, logos and brand guidelines. - `help@fathom.video` is the documented channel for launch support, listing copy, and API/product feedback. - Quarterly, Fathom reviews OAuth app usage; at **20+ users** they reach out about the integrations listing, a Crossbeam connection and co-marketing. ## 4. Redirect URIs **This is the section that changes your architecture.** Fathom's FAQ: > Can a single OAuth app register multiple redirect URIs? **Only multiple development redirect URIs are supported. For > multiple production URIs, use separate OAuth apps.** and > Is HTTPS required for redirect URIs? **Yes.** So a platform that serves several data centers registers **one app per production callback** and stores a separate client ID and secret per region. For Unified.to those callbacks are one per data center — confirm the live list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Practical consequences to decide *before* registering: - **Four apps means four consent screens and four listings.** Agree the naming with the owner ("Acme (EU)" or one name reused), because customers see it. - **The dev callback may be able to ride along** as a development redirect URI on an existing app rather than needing its own app. Confirm against the form; the FAQ allows multiple *development* URIs but does not define which of the form's fields is "development." - **A credential pair is bound to its region.** The commonest self-inflicted failure on a multi-app vendor is a token exchange sending region A's secret to an authorization issued for region B's client ID. ## 5. Scopes One scope exists, and the SDK examples pass it literally: ``` public_api ``` Fathom: "Currently, the only available scope is: `public_api` — Access to the Fathom API." What that means in practice: - **No least-privilege lever exists at the app level.** You cannot ask for read-only, and you cannot ask for meetings without also being able to reach teams and team members. If a customer's security review wants narrowing, the only real control is the Fathom user's own view access and sharing settings (§6) — say so plainly rather than promising a scope you cannot request. - **Send `scope=public_api` on the authorize request** and keep sending it; there is nothing to add later, so there is no incremental-consent step to design around. ## 6. What a credential can actually see — the classic failure A valid Fathom credential that returns "only one person's meetings" is almost never broken. It is working exactly as documented. **The documented model, for API keys:** - "API keys are scoped to the user who creates them. Your key can only access meetings you've recorded or that have been shared with you or your Team." And: "Whatever a user can view in Fathom, their API key can access." - "**API keys are per user, not per org, and there are no org-level keys.** For org-wide access, grant one or more **Admins view access to all shared calls**; that Admin's key can then read everything shared across teams." - "**API keys *never* grant access to other users' private meetings**" — admin or not. "Private calls stay host-only." **The permission dimensions**, which Fathom exposes on its users endpoint (added July 2026, `GET /users`, **admin only** — it returns 403 unless the caller's `settings_access` is `account_admin`): | Dimension | Values | | --- | --- | | `settings_access.level` | `none`, `team_admin`, `account_admin` | | `view_access.level` | `own_meetings`, `team`, `multiple_teams`, `all_teams` | The two are independent — Fathom's help centre spells out that "someone can be a Team Admin and still not see team calls if View Access is restricted." **`view_access` is the field that predicts what the customer's connection will return.** When a customer reports missing meetings, reading that value (from an admin credential) ends the argument in one call. **For OAuth tokens:** Fathom describes OAuth as other users installing your app "so they can connect their own accounts," and states that rate limits apply "per account (per API key or OAuth token)." It does **not** publish a separate visibility rule for OAuth tokens, and there is **no documented admin-consent or org-wide install**. Plan for a token seeing what the authorizing user sees, confirm it empirically on a multi-user test account (§9), and do not tell a customer an OAuth install covers their whole organization until you have watched it do so. **What to tell a customer who wants org-wide coverage:** have an Account Admin be the person who connects, and have that admin's **view access set to all teams' calls**. Their private, unshared calls still will not appear — nothing you configure changes that. **Team-plan caveats** worth knowing before a small customer files a bug: the webhook trigger scopes `my_shared_with_team_recordings` and `shared_team_recordings` are documented as **Team Plans only**, and `my_recordings` on a Team Plan "excludes recordings you've shared with any teams." ## 7. Capture the credentials From the registration form, capture and label: - **Client ID** and **client secret** — one pair per app, i.e. one per production callback (§4) - **Token endpoint** — `https://api.fathom.ai/external/v1/oauth2/token` - **Authorize endpoint** — as given by the form/SDK (see the Platform state note; do not paste an inherited value without checking it) - **Scope** — `public_api` - **API base URL** — `https://api.fathom.ai/external/v1` Fathom does not publish a secret-rotation or secret-re-read procedure. **Treat the secret as capture-once**: if it is lost or believed compromised, `help@fathom.video` is the documented channel, and rotation is a cutover — every code exchange and refresh fails until the new value is deployed. Never rotate without explicit go-ahead. **Refresh handling is a correctness requirement, not a nicety:** - Each refresh returns a **new** access token and a **new** refresh token; the old refresh token is invalidated. - An unused refresh token stays valid until the user revokes access — so idle connections do not silently die, but a client that replays the *original* refresh token fails on the **second** refresh. - Concurrent refreshes of the same connection: one wins, the other gets **400**. Take a per-connection lock. **Webhook credentials are separate, and returned exactly once.** Creating a webhook returns its `id` and a `secret` (prefixed `whsec_`). Fathom: a webhook's ID "is returned only when you create it and can't be retrieved afterward via the API—and it isn't included in delivered payloads," and deleting via the API requires it (otherwise delete it in the UI). **Persist both the ID and the secret at creation**; without the secret you cannot verify deliveries at all. Report secrets once so the user can paste them into their secret store, say plainly that they are now in the transcript, then move on. ## 8. Content, webhooks and limits — the operational shape **Getting transcripts and summaries.** For OAuth connections, `/meetings` will not carry them; use `GET /recordings/{recording_id}/transcript` and `GET /recordings/{recording_id}/summary`. Both endpoints have two modes: **omit** `destination_url` and the data comes back in the response; **pass** `destination_url` and the response is just that URL while Fathom POSTs the payload to it asynchronously. That field is *your* callback, not a download link — a client that fetches the URL it sent is a bug waiting for the async path to be enabled. Combined with a **fixed page size of 10** and a heavy-request budget of **30/minute (reducible to 5)**, a backfill of transcripts is dominated by the per-recording calls, not the listing. Size it that way, honour `Retry-After` on 429, and never re-authorize on a 429. **Summaries:** one per recording, always the account's **default** template, always Markdown, not selectable via the API. **Downloads:** `POST /recordings/{recording_id}/download` starts async generation; poll the status endpoint or supply a `destination_url`. Signed URLs expire **~24 hours** after generation, downloads are private to the API client that created them, and "limited-access shares get **403 Forbidden**" — downloading needs more than view access. **Webhooks.** Create with `POST /webhooks`: `destination_url` plus `triggered_for` (at least one of `my_recordings`, `shared_external_recordings`, `my_shared_with_team_recordings`, `shared_team_recordings`), and **at least one** of `include_transcript`, `include_crm_matches`, `include_summary`, `include_action_items` must be true. Behaviour worth designing for: - **Webhooks are tied to summary generation** — "no post-call summary means no webhook," which is why a 30-second test call never fires one. Fathom's own test advice is to record a brief ~2-minute meeting. - **One event per new meeting, fired when content is first ready.** No later event if the transcript, summary or the call's visibility changes afterwards — "Poll the API if you need later updates." Visibility is evaluated at the moment the call finalizes. - **Retries happen on non-2xx or timeout**, with the same `webhook-id` header — dedupe on it. There is no manual re-send. - **Verification:** headers `webhook-id`, `webhook-timestamp`, `webhook-signature`. Sign `${id}.${timestamp}.${body}` over the **raw** body with HMAC-SHA256, keyed by the base64-decoded portion of the secret **after** the `whsec_` prefix, base64 the result, compare constant-time against each space-delimited signature with its `v1,` prefix stripped, and reject timestamps outside ~5 minutes. - **No custom headers** can be added to outgoing deliveries, and **the payload does not say which user it belongs to** — Fathom's guidance is to "route each user's webhooks to a separate, user-specific destination URL rather than a shared endpoint." A per-connection callback path is the design that works. **Retention.** By default Fathom keeps recordings until someone deletes them. **Retention policies are Enterprise-only, are not self-serve** (arranged through `help@fathom.video`), and are **one policy per team, applied to all calls**; once active, "any recording older than the set period will be permanently deleted." So transcripts do not expire on a universal schedule — but on an Enterprise customer with a policy, history silently truncates, and a resync will not bring it back. Ask before promising a customer a permanent archive. > ### Product note — how this platform's connector uses Fathom (as of 2026-09-20) > > Dated facts, not a live reading. Confirm with the connector's owner before acting on them. > > - The connector supports **both** credential types: an API key sent as `X-Api-Key`, and OAuth2 bearer tokens. Its > customer-facing instruction for the key route points at the API Access area of Fathom user settings. > - API base `https://api.fathom.ai/external`, cursor pagination on `next_cursor` over an `items` array, page size 10 — > matching Fathom's documented maximum. > - It requests the `public_api` scope for its meeting and directory reads, exchanges and refreshes with > `client_id`/`client_secret` in a form-encoded body (no Basic auth), and stores a bearer token. > - Its configured authorize **and token** hosts are on `fathom.video`, while Fathom documents the token endpoint on > `api.fathom.ai`. Both hosts answered on 2026-09-20 — raise it with the owner rather than assuming either is wrong. > - It reads meetings (surfaced both as calendar events and as recordings), teams and team members. Because Fathom has > no single-meeting GET, it re-lists a ±2-minute `created_at` window to resolve one record — which spends the global > limit on every single-record read. > - It fetches transcripts per recording from the recordings transcript endpoint — the heavy class (§8). > - It subscribes one webhook per connection with **all four** trigger scopes and summary+transcript included, and > deletes by the ID returned at creation. > > **Raise with the owner before registering anything:** > > 1. **One production redirect per app** (§4) versus four regional callbacks — this is an app-count decision, not a > form field. > 2. The **webhook secret returned at creation is not retained**, so deliveries cannot be HMAC-verified; the callback > is effectively an open ingest endpoint (§7, §8). > 3. Two of the four trigger scopes are **Team Plans only** (§6) — a solo customer's subscription may be rejected. > 4. **Summaries never arrive on list reads** (the list call does not request them, and OAuth connections could not get > them there anyway), and the dedicated summary endpoint is not called — so summary data appears only in webhook > payloads (§8). > 5. The transcript path treats a returned `destination_url` as something to GET, whereas that field is the callback > URL Fathom posts to when you supply one (§8). ## 9. Verify end-to-end Authorizing on the account that registered the app proves very little here. Test the path a customer takes: 1. Authorize from a **second, unrelated** Fathom account through the platform's real connect flow, using the region whose client ID you configured. 2. Confirm the token response carries **both** an access token and a refresh token. 3. Force a refresh, then force a **second** refresh using the token the first one returned. That is what catches a client that ignores rotation. 4. Fire **two refreshes concurrently** if the platform has multiple workers — one should 400. Confirm the lock. 5. List meetings, then fetch **one** transcript from `/recordings/{id}/transcript`. On an OAuth connection, confirm you never rely on `include_transcript`/`include_summary`. 6. On a **multi-user** test account: connect as a plain user, count the meetings; change that user's view access to all teams' calls; re-run and compare. That is the empirical answer to §6 for OAuth. 7. Record a ~2-minute real meeting and confirm the webhook arrives, the signature verifies, and the `webhook-id` deduplicates a retry. | Symptom | Cause | | --- | --- | | Connection returns only the connecting user's meetings | Working as documented — that user's `view_access` is `own_meetings`; org-wide needs an admin with view access to all shared calls (§6) | | Some meetings never appear, even for an admin | Private, unshared calls are host-only and no credential reaches them (§6) | | Second refresh fails, first one worked | Client replayed the original refresh token instead of the rotated one (§7) | | `400` on refresh under load | Two workers refreshed one connection concurrently; take a per-connection lock (§7) | | Transcript/summary empty on an OAuth connection | `include_transcript`/`include_summary` are unavailable to OAuth apps — use the `/recordings` endpoints (§8) | | `429` that clears then returns quickly | Heavy-request budget (30/60s, reducible to 5/60s) on the recordings endpoints, not the global limit (§8) | | `403` on the users/permissions endpoint | Caller is not an `account_admin` (§6) | | `403` on a download | Limited-access share; downloading needs more than view access (§8) | | Download URL stops working overnight | Signed URLs expire ~24h; request a new download (§8) | | Webhook never fires after a short test call | No post-call summary was generated, so no webhook — record ~2 minutes (§8) | | Webhook fires once, later edits never arrive | By design; content changes and visibility changes do not re-fire — poll (§8) | | Webhook subscription rejected for a small customer | Team-Plan-only trigger scopes requested on a non-team plan (§6) | | Authorization succeeds but the callback is rejected | Wrong region's app: a production redirect lives on exactly one app (§4) | | A recording ID from a Fathom call URL 404s | The number in a call URL is not the API's `recording_id`; find it via the meetings list (Platform state) | | Key that worked yesterday now 4xx | The Fathom user was deactivated or removed from the team (§1) | ## 10. Hand off — never commit the secret - **Do not** write the client secret or a webhook secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the user, for their secret store. - If a code change is needed (a callback host, refresh-token rotation, a refresh lock, webhook signature verification), keep it secret-free and say what the human must set out of band. - Close with: app name(s); which Fathom account owns them; **each** client ID labelled with the callback it serves; where each secret was delivered; the authorize/token endpoints and the API base; the scope (`public_api`); the refresh-rotation and locking requirement; where webhook IDs and secrets are stored; and anything left for the user. ## Stop and ask Hand back to a human rather than guessing when: the platform serves more than one production callback and nobody has agreed to **multiple apps** (§4); the client does not store **rotated** refresh tokens or has no refresh lock (§7); a customer has been promised **org-wide** meeting coverage and nobody has confirmed an admin with all-teams view access will be the connecting user (§6); webhook deliveries are being accepted **without signature verification** (§7); the customer is on Enterprise with a **retention policy** and expects a permanent archive (§8); the registration form does not match §3; or anything in **Platform state** no longer holds. ## References Official Fathom pages only. Every URL verified to return HTTP 200 on 2026-09-20. - Developer docs home — https://developers.fathom.ai/ - Quickstart (API key, key scoping, pagination, filters) — https://developers.fathom.ai/quickstart - Building with OAuth (registration, review, featured at 20+ users) — https://developers.fathom.ai/oauth - SDK OAuth guide (token endpoint, grants, rotation, scope, token-endpoint rate limit) — https://developers.fathom.ai/sdks/oauth - API overview and rate limiting (global, heavy, download, OAuth) — https://developers.fathom.ai/api-overview - Webhooks (creation, verification algorithm, headers) — https://developers.fathom.ai/webhooks - FAQ (redirect URIs, refresh tokens, permissions, org access, webhook behaviour) — https://developers.fathom.ai/faq - Changelog — https://developers.fathom.ai/changelog - Documentation index — https://developers.fathom.ai/llms.txt - OpenAPI specification — https://developers.fathom.ai/api-reference/openapi.yaml - List meetings (filters, `include_*`, OAuth restriction) — https://developers.fathom.ai/api-reference/meetings/list-meetings - Get transcript — https://developers.fathom.ai/api-reference/recordings/get-transcript - Get summary — https://developers.fathom.ai/api-reference/recordings/get-summary - Request a download (access rules, ~24h URLs) — https://developers.fathom.ai/api-reference/recordings/request-a-download - Get download status — https://developers.fathom.ai/api-reference/recordings/get-download-status - List users and their permissions (admin-only; `view_access` levels) — https://developers.fathom.ai/api-reference/users/list-users-and-their-permissions - List teams — https://developers.fathom.ai/api-reference/teams/list-teams - List team members — https://developers.fathom.ai/api-reference/team-members/list-team-members - Create a webhook (trigger scopes, secret, ID) — https://developers.fathom.ai/api-reference/webhooks/create-a-webhook - Delete a webhook — https://developers.fathom.ai/api-reference/webhooks/delete-a-webhook - New meeting content ready (webhook payload) — https://developers.fathom.ai/api-reference/webhook-payloads/new-meeting-content-ready - Partner example: Pylon's OAuth and webhook wiring — https://developers.fathom.ai/inspiration/pylon - OAuth app registration form (sign-in required) — https://fathom.video/marketplace_applications/new - User settings, API Access section (API keys, webhooks) — https://fathom.video/customize - Help centre: Public API (user-level keys, 60 calls/minute) — https://help.fathom.video/en/articles/8368641 - Help centre: understanding permissions (settings access vs view access) — https://help.fathom.video/en/articles/10783489 - Help centre: retention policies (Enterprise-only, not self-serve) — https://help.fathom.video/en/articles/6057089 - Fathom integrations listing page — https://www.fathom.ai/integrations - Fathom About Us (brand assets and boilerplate for launch) — https://www.fathom.ai/about-us ## freshbooks-oauth-app URL: https://docs.unified.to/skills/freshbooks-oauth-app/SKILL.md # FreshBooks OAuth2 App Registration Get a working FreshBooks OAuth2 client — a FreshBooks account, an app on the developer page, redirect URIs, scopes, a client ID and secret — for a platform that connects many customers' FreshBooks businesses. Three things decide whether this works, and only one of them is on the registration form. **A FreshBooks token does not name an account.** After the flow you call the identity endpoint to discover which businesses the user belongs to — and each one carries *three* different identifiers. Invoices, expenses, taxes and payments are addressed by a short alphanumeric **account id**; projects, services and time tracking by a numeric **business id**; the chart of accounts, journal entries and the ledger reports by a **business UUID**. Swap two of them and FreshBooks returns 404, which reads like "this customer has no data". That is §7. **Scopes are ticked on the app, not sent on the authorize URL.** FreshBooks' authorize URL takes no `scope` parameter. Whatever is checked on the app is what every user consents to, and adding a scope later means every existing user must re-authorize. That is §5. **Refresh tokens are single-use and there is only ever one alive.** Every token response invalidates the previous refresh token immediately. A client that keeps reusing the original one works once and then strands the customer. That is §9. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name** and description | Shown to the user on the FreshBooks consent screen (§3) | | **FreshBooks account email** | Any FreshBooks account — trial or paid — can own apps (§2) | | **Redirect URIs** | Every callback host your platform serves; HTTPS only, no query strings (§4) | | **Scope set** | Exactly the `user::` strings the connector calls (§5) | | **New app or an edit to an existing one?** | New credentials orphan every existing customer connection (§1) | | **Will this be listed on the FreshBooks AppStore?** | Listing is a review process with its own requirements (§11) | | **Does the client persist the rotated refresh token?** | Blocking — a client that does not will fail on the second refresh (§9) | | **Which objects the connector reads/writes** | Decides scopes, and decides whether you need account-scoped, business-scoped or UUID-scoped paths (§7, §8) | ## Quick Start 1. Confirm a **new app** is actually needed — existing connections are bound to the current client ID (§1). 2. Sign in to, or create, a FreshBooks account; a free trial doubles as the test account (§2). 3. Create the app on the developer page and fill in name and redirect URI (§3). 4. Add **every** redirect URI, one per line, HTTPS, no query strings (§4). 5. Tick **every** scope the connector needs — they cannot be sent at authorize time (§5). 6. Capture the client ID and client secret (§6). 7. Work through the account-id / business-id / business-UUID indirection before writing client code (§7). 8. Establish which API generation each endpoint belongs to (§8). 9. Confirm the client handles ~12-hour access tokens and single-use rotating refresh tokens (§9). 10. Verify a real authorize → callback → token → refresh → read round trip (§12). 11. Hand the credentials over — never commit them (§13). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The developer portal is self-serve and there is no approval gate on connecting other accounts.** You create an app from the developer page inside any FreshBooks account and get a client ID and secret immediately. FreshBooks documents an **AppStore review** with statuses *Development, Pending, In Review, Approved, Suspended, Rejected* — that review governs **being listed in the AppStore**, not whether your OAuth app functions. FreshBooks does not publish a connection cap for unlisted apps, and does not publish a statement that an unreviewed app is limited to the developer's own account; if that matters commercially, ask FreshBooks rather than assuming either way. - **Scopes live on the app.** The documented authorize URL is `https://auth.freshbooks.com/oauth/authorize/?response_type=code&redirect_uri=&client_id=` — no `scope` parameter. FreshBooks' own scope guide tells you to *edit your application to include the scopes*, and warns that afterwards "you'll need your app's users to authorize your app once again". - **`user:profile:read` is added to every new app by default**, because basic calls need it. - **Scopes cannot be narrowed on a live token.** "It's not possible to remove scopes from an existing access token. The only way to reduce or add consented scopes is to revoke the token and start with the app authorization flow again." - **Access tokens are JWTs.** FreshBooks replaced fixed-length bearer tokens with variable-length JWTs for new apps in April 2021, with a 1 May 2023 deadline for legacy apps. Store them in a `TEXT`-sized column — FreshBooks explicitly warns about 64-character assumptions. - **`client_credentials` is not supported.** The token page states it inline: authorization code grant only. There is no machine-to-machine path and no way to connect an account without a browser round trip. - **Two API generations coexist, and both are current.** The Classic XML API (`/api/2.1/xml-in`, API token or OAuth 1.0) is the dead one; everything at `api.freshbooks.com` is the live one. But *inside* the live API there is a second, subtler split between account-scoped legacy accounting paths and newer business-UUID-scoped ledger paths. See §8 — this is the generation question that actually costs time. - **Rate limits are real but unpublished.** FreshBooks states there is no daily request cap but that requests "will be rate-limited if too many calls are made within a short period of time", and its AppStore requirements name the status: **HTTP 429 `HTTP_RATE_LIMITED`**, with the right to disable an app that "is hitting us aggressively". No numeric threshold and no `Retry-After` contract is documented. - **The API changelog is one page.** The only entry FreshBooks publishes under "API Changelog" is the bearer-token → JWT migration. Do not expect a dated feed; treat the individual endpoint pages as the source of truth. If the portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer connection is bound to the old one** — every customer would have to re-authorize, and each re-authorization is an action only they can take. Reuse the existing app for: adding a redirect URI, adding a scope, rotating a compromised secret, or diagnosing a failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, or a deliberate test app. FreshBooks itself recommends the last one — its scope guide tells you to **duplicate your app from the developer portal** and hunt missing scopes against the copy before touching the production app. That is the right move here too (§5). Say which path you are taking before you touch the portal. ## 2. Account: sign in or sign up - **Any FreshBooks account owns apps.** There is no separate developer registration and no separate developer account tier. Sign in at `https://www.freshbooks.com/` and go to the developer page. - **Developer page**: `https://my.freshbooks.com/#/developer`. It is an authenticated single-page app, so it will not render for an unauthenticated fetch — that is expected, not an outage. - **Test accounts**: FreshBooks' migration guide says to "create test accounts (sandboxes) by signing up for a free trial account". There is **no separate sandbox host and no second key pair** — production and test run against the same endpoints with the same credentials. Whichever account authorizes is the account you touch, so be careful which one you are signed into. - **Who may authorize**: FreshBooks states that accessing data in an account "will require an owner, admin, or manager to allow access to your application within a web browser". Lower-privileged roles exist in the identity model (§7) and matter to what your connector can resolve afterwards. Hand control back to the user for anything only a human can do: signup, email verification, CAPTCHA, MFA, accepting terms, and clicking Allow on the consent screen. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste — the redirect URIs from §4 and the scope strings from §5 — then continue once they report back with the client ID. ## 3. Create the app On the developer page, create an application. The form asks for the **name of the application and a redirect URI**. Then open the app and work through **App Settings**, where the portal also shows you a ready-made, clickable authorization URL for your client ID — useful for testing the flow by hand before any code exists. What carries more weight than it looks: - The **name and description are what the consent screen shows the customer.** Use the go-to-market name. - The **scope list is part of the app record, not the request** (§5). Getting it wrong is the most expensive mistake on this form, because fixing it later forces every user to re-consent. - Duplicating an app is a first-class action in this portal and is the documented way to test scopes safely (§5). ## 4. Redirect URIs Add **every** callback host your platform serves. FreshBooks allows multiple redirect URIs per application, "specified on separate lines of the Redirect URI field". For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Rules FreshBooks states explicitly: - **HTTPS only.** The one concession: while developing, if you cannot set up a certificate, you may manually change the URL to HTTP in your browser to complete the connection. That is a hand-testing escape hatch, not a registered value. - **No query string parameters in a redirect URI.** If you need to carry data through the flow, url-encode it into the `state` parameter; FreshBooks inserts `state` into the redirect URI when it sends the user back. All four URIs above are query-free, which is why they are legal. - The `redirect_uri` you send at the **token exchange must be the same one** used to obtain the code — FreshBooks' own exchange and refresh examples both carry it. ## 5. Scopes — chosen on the app, never on the authorize URL This is the part people get wrong because every other vendor works the other way. FreshBooks scopes read `user::`. Two actions only: **`read`** (reading the full information about a resource) and **`write`** (creating, editing, archiving or deleting). The objects FreshBooks lists: ``` bill_payments bill_vendors billable_items bills business clients credit_notes estimates expenses invoices journal_entries notifications online_payments other_income payments profile projects reports retainers taxes teams time_entries ``` Three things that list does not tell you: 1. **`user:profile:read` is granted to every new app by default** and is needed for basic calls. 2. **The list is incomplete.** `user:uploads:read` and `user:uploads:write` are not on the scopes page, but they are documented as required on the expense-attachment and invoice-presentation-attachment pages. Trust the individual endpoint page over the summary list. 3. **`admin:all:legacy` is the pre-October-2021 blanket scope.** If an existing app still carries it, it is a legacy app and moving it to granular scopes forces every user to re-authorize. How to establish the exact set, FreshBooks' own method: - Read the **Access Requirements** box at the top of each endpoint page you call — it names the scopes verbatim. - Then confirm empirically: **duplicate the app** with only `user:profile:read`, make each call you need, and read the **HTTP 403** back. Since October 2021 that error names both *the scopes you provided* and *the scopes needed for the API call*. Note them, add them to the duplicate, re-authorize, repeat until clean, and only then edit the production app. - Request the narrow set. FreshBooks' AppStore requirements state that an app "should request only the permissions that are necessary for it to function and as described in your listing", and the consent screen shows the user exactly what you ticked. **Product fact.** As of 2026-09-20, this platform's FreshBooks connector maps each unified object to the narrowest scopes it needs rather than asking for one blanket list — drawn from `user:profile:read`, `user:invoices:read` / `:write`, `user:clients:read` / `:write`, `user:expenses:read` / `:write`, `user:bills:read` / `:write`, `user:credit_notes:read` / `:write`, `user:payments:read` / `:write`, `user:taxes:read` / `:write`, `user:journal_entries:read` / `:write`, `user:billable_items:read` / `:write`, `user:projects:read` / `:write`, `user:uploads:read` / `:write`, `user:teams:read`, `user:reports:read`, and `user:account:read` / `:write`. Two of those are worth checking against the portal picker before you tick anything: **`user:uploads:*` is documented only on the attachment pages**, and **`user:account:*` does not appear on FreshBooks' published object list at all** — confirm both are offered on the app's scope picker, and if `user:account:*` is not, work out from the 403 responses what the ledger-account, tax and report endpoints actually want. The connector's list is a **specification for what to tick in the portal**, because the connector sends no `scope` parameter at authorize time (§6). ## 6. Capture the credentials From the developer page → your app → **App Settings**: - **Client ID** and **client secret** — both shown on the app page. The client ID is also embedded in the app's own portal URL and in the authorization link the portal generates, so it is not a secret; the client secret is. - **Endpoints**, all shared between test and production — there is no sandbox host: | Purpose | URL | | --- | --- | | Authorize | `https://auth.freshbooks.com/oauth/authorize/` | | Token exchange and refresh | `https://api.freshbooks.com/auth/oauth/token` | | Revoke a bearer or refresh token | `https://api.freshbooks.com/auth/oauth/revoke` | | API base | `https://api.freshbooks.com` | | Identity | `https://api.freshbooks.com/auth/api/v1/users/me` | - **Exchange format.** FreshBooks documents the token call as a POST with `grant_type`, `client_id`, `client_secret`, `code` and `redirect_uri` **in the request body** — a JSON body in the authentication reference, multipart form fields in the tutorial; both are shown working. There is no HTTP Basic variant, and no PKCE: the client secret travels in the body, so this is a confidential-client flow only. - **Revocation** takes `client_id`, `client_secret` and `token` (either a bearer or a refresh token) in the body. Rotating the secret breaks every exchange and refresh until the new value is deployed. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. **Product fact.** As of 2026-09-20, this platform's FreshBooks connector sends only `client_id`, `redirect_uri`, `response_type` and `state` on the authorize request — **no `scope` parameter**, which matches FreshBooks' documented URL and confirms that the portal's scope picker is the whole story. It does **not** use PKCE. It exchanges the code and refreshes with a JSON POST carrying the client id and secret in the body, and replaces the stored refresh token with the rotated one from each response. It is configured to use the platform's shared FreshBooks OAuth credentials by default. It does **not** currently wire up the revocation endpoint, so disconnecting a customer on this side does not invalidate their FreshBooks tokens — raise that if a customer asks about revocation guarantees. ## 7. The identity indirection — one token, three identifiers Nothing on the registration form warns you about this, and it is where FreshBooks connectors break. Right after the exchange, call: ``` GET https://api.freshbooks.com/auth/api/v1/users/me Authorization: Bearer ``` FreshBooks calls this the **Identity Model**. A user is identified by email across the whole product, and the response's `business_memberships` array lists every business they can reach. Each membership carries a `role` and a `business` object with **`id`** (numeric business id), **`business_uuid`**, **`account_id`** (short alphanumeric, e.g. `e6Wmk`) and `name`. FreshBooks is candid about why there are three: *"For historical reasons, FreshBooks has shifted from an account concept to a business concept, but many resources still make use of accountIds."* | Identifier | Shape | Path family | Examples | | --- | --- | --- | --- | | **account id** | short alphanumeric | `/accounting/account/{accountId}/…`, `/uploads/account/{accountId}/…`, `/events/account/{accountId}/…` | invoices, clients, expenses, expense categories, taxes, payments, bills, credit notes, attachments, webhook callbacks | | **business id** | integer | `/projects/business/{businessId}/…`, `/timetracking/business/{businessId}/…`, `/comments/business/{businessId}/…`, `/auth/api/v1/businesses/{businessId}/…` | projects, services, time entries, team members | | **business UUID** | UUID | `/accounting/businesses/{business_uuid}/…` | chart of accounts, ledger accounts, journal entries, balance sheet, profit and loss, cash flow, general ledger, trial balance | FreshBooks states the first two rules plainly — accounting endpoints take `accountId` (`/accounting/account/{accountId}/invoices/invoices`), non-accounting endpoints take `businessId` (`/timetracking/business/{businessId}/time_entries`) — and its webhook payloads carry **both** `account_id` and `business_id` for exactly this reason: *"Because some FreshBooks resources use account and others business, both the `account_id` and `business_id` are provided."* The third, the business UUID, is the newer ledger surface (§8). What follows from that: - **Resolve all three at connect time and store all three.** Deriving one from another later is not possible without another identity call. - **A mismatch is a 404, not a 403.** Sending a business UUID where an account id belongs returns "not found", which in a sync pipeline reads as "this customer has no invoices". When a connection is healthy but every object is empty, suspect the identifier before you suspect the data. - **One identity can belong to several businesses.** Accountants and bookkeepers routinely do. Picking one silently means syncing the wrong company. Decide explicitly whether one connection means one business — and if so, model several businesses as several connections. - **Role matters.** FreshBooks maps API roles to UI names: `owner`/`admin` → Owner, `business_partner` → Admin, `business_manager` → Manager, `business_employee` → Employee, `business_accountant` → Accountant, plus `contractor` and `client`. FreshBooks says an **owner, admin or manager** can grant access — so a perfectly valid connecting user may arrive with a role that is neither `owner` nor `admin`. Any logic that only recognises those two will resolve nothing and produce the 404 pattern above. **Product fact.** As of 2026-09-20, this platform's FreshBooks connector calls the identity endpoint once right after authorization, picks the **first membership whose role is owner or admin**, and stores that business's account id, numeric business id and business UUID against the connection; every later call builds its path from those three. Two consequences to verify with the connector's owner before promising anything to a customer: a user who authorizes with a **manager, admin-in-the-UI (`business_partner`), accountant or contractor** role resolves to nothing and all subsequent calls 404; and a user with **several businesses** gets exactly one of them, chosen by FreshBooks' ordering. ## 8. Two API generations — and the split inside the live one Ask "which API generation" and you get two different answers. Both matter. **The dead one is easy.** FreshBooks Classic used a single fixed URL (`https://.freshbooks.com/api/2.1/xml-in`), an XML payload with the action in the body (``), and authenticated with an API token or OAuth 1.0. The modern API at `api.freshbooks.com` is JSON, one URL per action, and **only supports OAuth 2.0**. FreshBooks publishes a per-endpoint table of what survived the move — notably **staff, gateways, currencies, default terms, email templates, contractors and the Classic detail reports did not**. If anything you are looking at mentions `xml-in` or an API token, it is the dead generation; do not build on it. **The live split is the one that bites.** Inside the modern API, two path families coexist: - **Account-scoped legacy accounting**: `/accounting/account/{accountId}/…` — invoices, clients, expenses, expense categories, taxes, payments, bills, credit notes, and the older report family under `/accounting/account/{accountId}/reports/accounting/…` (invoice details, payments collected, tax summary). - **Business-UUID-scoped ledger**: `/accounting/businesses/{business_uuid}/…` — the chart of accounts (`/ledger_accounts/accounts`), journal entries, and the ledger report family (`/reports/balance_sheet`, `/reports/profit_and_loss`, `/reports/trial_balance`, `/reports/cash_flow`, `/reports/general_ledger`, `/reports/chart_of_accounts`, `/reports/manual_journal_entry_report`). Neither is deprecated; they address different things. Two practical rules: - **Some business-scoped endpoints require extra ceremony.** Journal entries require the header `x-api-version: 2023-09-25` on create, read and update. The chart of accounts and several ledger reports require `use_ledger_entries=true` as a mandatory query parameter. Omit either and you get an error that looks like a permission or identifier problem but is not. - **Ledger identifiers are UUIDs, not integers.** A journal entry line references an account by `account_uuid`, which you get from the chart-of-accounts call. Account ids from the legacy family will not substitute. **Product fact.** As of 2026-09-20, this platform's FreshBooks connector reads the chart of accounts, journal entries, profit and loss, and cash flow from the **business-UUID** family, and invoices, clients, expenses, categories, taxes, payments, bills, credit notes, attachments and webhook callbacks from the **account-id** family — matching FreshBooks' docs. It reads the **balance sheet and trial balance from the account-scoped path** (`/accounting/account/{accountId}/reports/accounting/balance_sheet` and `…/trial_balance`), which still responds but which FreshBooks' current documentation only shows under the business-UUID family. Treat those two as the pair most likely to move; confirm with the connector's owner before relying on them. ## 9. Token behaviour: ~12 hours, one refresh token alive, rotation on every use | Token | Lifetime | Rule | | --- | --- | --- | | authorization `code` | **5 minutes**, single use | Delay or reuse and the exchange fails | | `access_token` | JWT; FreshBooks' own example returns **`expires_in: 43200`** (12 hours) | FreshBooks says only "check the expiry in the token" — read `expires_in`, do not hard-code 12 hours | | `refresh_token` | **Never expires — but is single-use** | A new one is issued with every access token; all older ones become invalid immediately | The three ways this bites: 1. **Rotation, with no grace window.** FreshBooks: *"Refresh Tokens live forever, but are one-time-use, and only one Refresh Token can be alive at any time per user per application. A new Refresh Token is generated every time a Bearer Token is issued … and all old Refresh Tokens immediately become invalid."* Persist the new refresh token in the same transaction as the access token. If your app fails to save one, the customer must re-authorize — there is no documented grace period, unlike some other vendors. 2. **Refreshing kills the old access token.** FreshBooks states that "requesting a new access token via a refresh token invalidates the previous access token immediately". Two workers refreshing the same connection concurrently will knock each other out; serialize refreshes per connection. (Note that FreshBooks' authentication reference also says bearer tokens "don't interfere with each other's lives" and that several can be valid at once — the two pages disagree. Design for the stricter reading: one live access token, one live refresh token.) 3. **An expired access token is a plain 401.** FreshBooks says so explicitly: call with an expired token and you get `401 unauthorized`, meaning refresh — not a scope or identifier problem. You do not have to wait for the 401; you can refresh proactively from `expires_in`. ## 10. Rate limits, pagination and the shape of errors - **Rate limits**: no daily cap, but bursts are throttled. FreshBooks names **HTTP 429 `HTTP_RATE_LIMITED`** in its AppStore requirements and instructs apps to "hold off making api calls for a moment", reserving the right to disable an app that hits aggressively. **No numeric threshold and no `Retry-After` contract is published** — use exponential backoff and do not invent a number for the customer. - **Pagination**: `per_page` is **silently capped at 100**. Page 0 returns page 1; a non-positive-integer page is an error; a page beyond the last returns an empty list. So a list that stops at 100 rows is the cap, not the data. - **Filters** follow documented patterns per resource — Equals, In, Like, Between, Datetime, Bool. Date-range `Between` filters on columns like `updated` generally have **day-level granularity** and take `YYYY-MM-DD`. - **Errors**: the accounting family returns a numbered error envelope — `1001 RequiredField`, `1003 AccessDenied`, `1004` for field validation, `1012 UnknownResource`, `409` for concurrent modification. Other families return plain HTTP semantics: 401 when no valid token was provided, 403 when the resource is not accessible to this token, 404 when the resource in the URL is not found. A **403 that names the scopes provided and the scopes needed** is the scope error from §5, not a permission problem in the customer's account. ## 11. Webhooks, if the connector uses them Not part of registration, but it fails in a way that looks like a registration problem. - Callbacks are registered by POST to the **account-scoped** callbacks endpoint, and the scope required depends on the event noun (e.g. `bill` events need `user:bills:read`). - **There is a verification handshake.** On registration FreshBooks POSTs a verification code and the callback id to your URI; you must PUT them back before any notifications flow. A webhook that registers cleanly and then delivers nothing is almost always an unanswered handshake. You can ask FreshBooks to resend the code at any time. - **Deliveries are signed.** Every webhook carries `X-FreshBooks-Hmac-SHA256`: a base64-encoded HMAC of a UTF-8 JSON string of the parameters, keyed with **the verification code from the handshake**. Store that code — it is the signing secret. FreshBooks warns that the JSON must be serialized with a space after each `:` and `,` or the signature will not match. - Payloads are form-urlencoded and carry `name`, `object_id`, `account_id`, `business_id` and `identity_id`. - Anything other than a 2xx within **ten seconds** is a failure; failures are retried, then dropped, and a persistently failing URL may be disabled (re-enable by resending the verification token). **Product fact.** As of 2026-09-20, this platform's FreshBooks connector registers callbacks under the account id and answers the verification handshake, but does **not** verify the `X-FreshBooks-Hmac-SHA256` signature on inbound deliveries. Flag that to the connector's owner as a security gap rather than assuming it is handled elsewhere. ## 12. Verify end-to-end Testing with the account that owns the app hides the multi-business and role problems. Test wider. 1. Authorize through your platform's real connect flow and confirm the token response carries **both** an access token and a refresh token, and read `expires_in` rather than assuming it. 2. Call the identity endpoint and confirm you store **all three** identifiers for the chosen business (§7). 3. Make one read from each path family: an account-scoped one (invoices), a business-scoped one (projects or team members), and a business-UUID one (chart of accounts). All three must work before you call this done. 4. Authorize with a user whose role is **manager / `business_partner` / accountant**, not just the owner, if customers will connect that way. 5. Authorize an identity with **two businesses** and confirm the behaviour is what you told the customer it would be. 6. Force a **refresh**, then force a **second** refresh using the token the first one returned. That is what catches a client ignoring rotation. 7. Deliberately call an endpoint whose scope you did not tick, and confirm you surface the 403 as "reconnect needed", not as a server error. | Symptom | Cause | | --- | --- | | Every accounting call 404s, connection otherwise healthy | Account id never resolved — often an authorizing user whose role is not owner/admin (§7) | | Projects, services or time tracking 404 while invoices work | Account id sent where the numeric business id belongs (§7) | | Chart of accounts, journal entries or ledger reports 404 | Business UUID missing, or an account id used on a business-scoped path (§7, §8) | | Journal entry calls fail with a version or schema error | Missing `x-api-version: 2023-09-25` header (§8) | | Chart of accounts or a ledger report returns an error on a correct-looking URL | Missing mandatory `use_ledger_entries=true` (§8) | | 403 naming "the scopes you provided" and "the scopes needed" | Scope not ticked on the app, or the user consented before it was added — re-authorize (§5) | | Customer consented but the consent screen listed fewer permissions than expected | Scopes come from the app record, not the authorize URL (§5) | | 401 on a call that worked an hour ago | Access token past its life (~12h) — refresh (§9) | | Refresh fails and the customer must reconnect | The stored refresh token was already spent; only one is alive per user per app (§9) | | Second refresh fails, first one worked | Client is not persisting the rotated refresh token (§9) | | Two workers both refresh, then both get 401 | Concurrent refreshes — each invalidates the other's access token (§9) | | Exchange fails immediately after consent | Authorization code older than 5 minutes, already used, or a `redirect_uri` that differs from the one used for the code (§4, §9) | | Authorize page errors, or the redirect is rejected | Redirect URI not registered exactly, not HTTPS, or carries a query string (§4) | | 429 `HTTP_RATE_LIMITED` | Burst throttling — back off; no published threshold (§10) | | A list never returns more than 100 rows | `per_page` is silently capped at 100 (§10) | | Webhook registers, then nothing arrives | Verification handshake never answered (§11) | | Data lands against the wrong company | The identity had several business memberships and one was picked silently (§7) | | An old integration guide mentions `xml-in` or an API token | Classic API — the dead generation (§8) | ## 13. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a redirect host, a scope addition, storing rotated refresh tokens), keep it secret-free and say what the human must set out of band. - Close with: app name; the FreshBooks account that owns it; client ID; where the secret was delivered; the authorize, token and revoke endpoints; **the exact scope strings ticked on the app**; whether the client persists the rotated refresh token; how the account id, business id and business UUID are resolved and stored; whether the webhook signature is verified; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the client does not persist rotated refresh tokens (report it — do not register and hope); a scope you need is not offered on the app's scope picker; the customer needs multi-business behaviour the connector does not implement (§7); existing users would have to re-authorize because scopes changed, and nobody has agreed to that customer communication; the product needs AppStore listing, and the review's requirements, timeline or commercial terms are in question; anyone asks for a guaranteed connection cap, rate-limit number or `Retry-After` contract, none of which FreshBooks publishes; or the portal does not match the **Platform state** section above. ## References - Getting started / introduction — https://www.freshbooks.com/api/start - Authentication (authorize URL, token, refresh, revoke, redirect URI rules) — https://www.freshbooks.com/api/authentication - Get Authenticated tutorial (`expires_in`, 5-minute code, refresh behaviour) — https://www.freshbooks.com/api/get-authenticated-on-the-freshbooks-api - Identity Model (account id vs business id, roles, `business_memberships`) — https://www.freshbooks.com/api/identity_model - Scopes — https://www.freshbooks.com/api/scopes - How to find the right scopes for your app (403 scope error, duplicate-app method) — https://www.freshbooks.com/api/how-to-find-the-right-scopes-for-your-app - Errors and accounting error codes — https://www.freshbooks.com/api/errors - Request limits — https://www.freshbooks.com/api/limits - Search, paging and includes (`per_page` capped at 100) — https://www.freshbooks.com/api/parameters - Webhook callbacks (handshake, HMAC header, payload fields) — https://www.freshbooks.com/api/webhooks - Chart of Accounts (`/accounting/businesses/{business_uuid}/ledger_accounts/accounts`) — https://www.freshbooks.com/api/chart-of-accounts - Journal Entries (`x-api-version: 2023-09-25`) — https://www.freshbooks.com/api/journal-entries - Reports index (account-scoped vs business-UUID-scoped report paths) — https://www.freshbooks.com/api/reports - Balance Sheet report — https://www.freshbooks.com/api/balance-sheet-report - Cash Flow report — https://www.freshbooks.com/api/cash-flow-report - General Ledger report — https://www.freshbooks.com/api/general-ledger-report - Expense attachments (`user:uploads:*`) — https://www.freshbooks.com/api/expense-attachments - Invoice presentation and attachments — https://www.freshbooks.com/api/invoice_presentation_attachments - Team members (`/auth/api/v1/businesses/{business_id}/team_members`) — https://www.freshbooks.com/api/team-members - Taxes — https://www.freshbooks.com/api/taxes - How to migrate from the Classic XML API — https://www.freshbooks.com/api/how-to-migrate-from-the-classic-xml-api - FreshBooks Classic migration (endpoint-by-endpoint survival table) — https://www.freshbooks.com/api/freshbooks-classic-migration - Requirements for public apps on the AppStore (429, review statuses) — https://www.freshbooks.com/api/requirement-for-public-apps-on-the-freshbooks-app-store - How to create your FreshBooks app listing — https://www.freshbooks.com/api/how-to-create-your-freshbooks-listing - API changelog: bearer tokens replaced with JWT tokens — https://www.freshbooks.com/api/bearer-tokens-replaced-with-jwt-tokens - Postman collection — https://www.freshbooks.com/api/postman-collection - Developer page (create and edit apps; sign-in required) — https://my.freshbooks.com/#/developer ## github-oauth-app URL: https://docs.unified.to/skills/github-oauth-app/SKILL.md # GitHub OAuth2 App Registration Get a working GitHub OAuth2 client for a platform that connects many customers' GitHub accounts. A successful run produces: a registered app, every callback URL, the scope set or permission set, a client ID, a client secret, and — for a GitHub App — a downloaded private key and a webhook secret. **The expensive decision comes before any of that: OAuth App or GitHub App.** They are two different products with two different credential shapes, two permission models, two token lifecycles, two rate-limit formulas, and two install stories. Choosing wrong is not a settings change — the client ID differs, so migrating re-onboards every customer you already have. Read §A before you click **New**. Three other things cost a cycle if missed: **callback URL wildcard matching became a per-URL setting on 2026-08-03**, so a new app matches redirect URIs exactly and multi-region callbacks must be sent explicitly (§4); the GitHub App **private key is downloadable exactly once** and is as sensitive as the client secret (§6); and an org owner can block both app types independently of anything you configure (§8). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **OAuth App or GitHub App** | The fork. If the user has not decided, walk §A with them before anything else | | **App name**, description, homepage URL | The name must be unique across GitHub; GitHub App names cap at 34 characters | | **Owner account** | A personal account or an organization the user administers (§2) | | **Callback URLs** | Every callback host the platform serves (§4) | | **Scope set / permission set** | What the connector actually calls (§5) | | **Public or private install** | "Any account" (multi-tenant) vs "Only on this account" (§3) | | **Webhook URL + whether webhooks are used** | GitHub Apps get centralized webhooks; a webhook secret is generated here (§3) | | **GitHub.com, Enterprise Cloud, or Enterprise Server** | GHES needs its own registration on the customer's instance (§9) | | **New app, or an edit to an existing one** | Re-registration orphans existing connections (§1) | ## Quick Start 1. Decide **OAuth App vs GitHub App** — this is the run's real decision (§A). 2. Confirm a **new** app is needed; an edit keeps every existing customer connected (§1). 3. Sign in as the owning personal account or org admin (§2). 4. Register the app — **Settings → Developer settings → GitHub Apps / OAuth Apps → New** (§3). 5. Add **every** callback URL, and decide wildcard matching deliberately (§4). 6. Set scopes (OAuth App) or fine-grained permissions and events (GitHub App) (§5). 7. Capture client ID, client secret, and — GitHub App only — **generate and download the private key** (§6). 8. Confirm token lifetimes and that the client stores rotated refresh tokens (§7). 9. Work the org-approval path customers will hit (§8). 10. Verify with a real authorize → callback → refresh → read round trip on a **second** account (§10). 11. Hand the credentials over; never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **GitHub's own guidance is that GitHub Apps are preferred to OAuth Apps**, for finer permissions, short-lived tokens and independence from the installing user. OAuth Apps are not deprecated and no sunset has been announced; GitHub names enterprise-level resources as the case where an OAuth App is still the answer. - **Callback URL wildcard matching is now an explicit per-URL setting.** Apps that had a *single* callback URL enabled before **2026-08-03** have wildcard matching on, preserving the old behaviour — which is why nearly every OAuth App and many older GitHub Apps behave loosely while a freshly registered app matches exactly. GitHub warns that wildcard matching lets an attacker send authorization codes to any subdomain or subdirectory of the callback URL. Leave it off. - **Both app types now expire user tokens by default.** Newly created OAuth Apps and GitHub Apps issue user access tokens valid **8 hours** (`expires_in: 28800`) with a refresh token valid **6 months** (`refresh_token_expires_in: 15897600`). The old never-expiring OAuth token is an opt-out, not the default. - **Installation access tokens are moving to a stateless format.** From **2026-04-27** GitHub began a staged rollout of `ghs_APPID_JWT`-style installation tokens. They are no longer the old fixed 40-character strings. Any code, column or validator that assumes a token length will start failing mid-rollout. - **Up to 10 callback URLs** per app, both types. **Up to 25 private keys** per GitHub App. A user or org may own **up to 100 OAuth Apps**. - **Rate limits** (§7) differ by an order of magnitude between the two models, and that difference grows with the customer. If the developer settings pages do not look like this, stop and report what you actually see rather than clicking on. ## A. OAuth App vs GitHub App — decide this first | | **OAuth App** | **GitHub App** | | --- | --- | --- | | **Who grants access** | An individual user authorizes it | An org owner or repo admin **installs** it, picking repositories | | **Access granularity** | Coarse scopes; `repo` is effectively all-or-nothing on private repos | Fine-grained repository / organization / account permissions, each read, write or admin | | **Repository targeting** | Every repo the user can see, within the scope | Only the repositories chosen at install | | **Credentials** | Client ID + client secret | Client ID + client secret **+ a private key (.pem)**, plus a webhook secret | | **Token types** | User access token only | **Installation** token (acts as the app/bot) and **user-to-server** token (acts as the user) | | **Token lifetime** | 8h user token + 6-month refresh token (default) | Installation token: **1 hour**, re-minted from the private key. User token: 8h + 6-month refresh | | **Identity in the API** | The authorizing user (`@octocat`) | A bot (`@yourapp[bot]`) for installation tokens | | **Survives the user leaving** | No — access dies with the user's membership or token | Yes — the installation is owned by the org | | **Rate limit** | 5,000 req/hr, fixed (15,000 for an Enterprise Cloud-owned app acting for a user) | 5,000 base, **+50/hr per repo above 20 and +50/hr per org user above 20, to a 12,500 ceiling**; 15,000 flat for Enterprise Cloud installs | | **Webhooks** | Configure per repository and per org yourself | One centralized webhook for everything the installation covers | | **Org gatekeeping** | Org **OAuth App access restrictions** — owner approves the app for the org | Org controls who may **install**, and permission changes need re-approval | | **Enterprise-level resources** | Still the supported path | Limited | | **Scopes in the authorize URL** | Required, space-delimited | **Not accepted** — access comes from the app's configured permissions | **For a multi-tenant connector, register a GitHub App.** The reasons are cumulative, not stylistic: customer security teams will refuse a connector that asks for blanket `repo`; installation tokens keep working when the employee who connected the integration leaves; the installation rate limit grows with the customer instead of throttling your largest accounts at a flat 5,000/hr; and one centralized webhook replaces per-repository webhook plumbing. **Choose an OAuth App only when** the connector genuinely needs to act *as the user* across everything that user can see (a code-search or "show me all my repos" product), when it must reach enterprise-level resources, or when the platform's connector already exists as an OAuth App and you are just adding a callback or a scope. **Switching later is a migration, not a config change.** A GitHub App has a different client ID, a different consent screen, an install step the OAuth flow does not have, and tokens that cannot be carried over. Every existing customer re-authorizes. Plan it as a project with a dual-run window, or do not start it. ## 1. Reuse the existing app, or register a new one A new registration means a new client ID, and every stored token is bound to the old one — every customer re-authorizes. **Edit the existing app** for: adding a callback URL, adding a scope or permission, rotating a compromised secret, enabling device flow, or diagnosing an authorization failure. **Register a new app** only when the user asks for one: a replacement for a compromised app, a separate app for a separate product, a GHES instance that needs its own (§9), or a deliberate OAuth App → GitHub App migration. Say which path you are taking before you start clicking. One asymmetry worth knowing: **adding permissions to an existing GitHub App is not free either.** Each account where the app is installed is prompted to approve the new permissions, and an installation that declines keeps running on the old ones. So a permission change ships as a slow, partial rollout you have to tolerate in code — not as a flag day. Adding an OAuth scope has no such prompt; the user simply re-authorizes and the new scope appears on the token. ## 2. Account: sign in or sign up Apps can be owned by a **personal account** or by an **organization** the user administers. Prefer an organization for anything a team will maintain — a personal-account app dies with the person's access. Ownership is chosen at registration. Sign-up, email verification, 2FA (GitHub requires it), accepting terms, and organization membership are things only the human can do. Hand back rather than looping on a blocked step. **If this session has no browser automation** — the usual case for a CLI or cloud run — do not pretend to click. Give the user an exact, ordered click path with the literal values to paste (§4 callback URLs, §5 scopes or permissions), then continue once they report back with the client ID. For a GitHub App there is a scripted alternative: the **manifest flow** (§3) hands the whole registration over as one URL. ## 3. Create the app **GitHub App** — Settings → Developer settings → **GitHub Apps** → **New GitHub App** (`https://github.com/settings/apps/new`; sign-in required, so this URL 403s to an unauthenticated fetch). Fields that carry more weight than they look: - **GitHub App name** — unique across all of GitHub, max 34 characters. It becomes the bot identity and the install URL slug, so it is hard to change later without confusing customers. - **Callback URL** — up to 10 (§4). - **Expire user authorization tokens** — leave selected. GitHub recommends it and it is the default. - **Request user authorization (OAuth) during installation** — decides whether a fresh install also returns an authorization `code`. Read the install-callback trap below before choosing. - **Setup URL** + **Redirect on update** — where GitHub sends users after an install, and whether it also does so when an installation is *modified* (repositories added or removed). - **Webhook URL / Active / Webhook secret** — set a secret. GitHub signs deliveries with HMAC-SHA256 in the `X-Hub-Signature-256` header; without a secret you cannot tell a real delivery from a forged one. - **Permissions** and **Subscribe to events** (§5). An event you lack the permission for will not even appear in the list. - **Where can this GitHub App be installed?** — **Any account** for a multi-tenant connector; **Only on this account** restricts installation to the owning account. > **The install-callback trap.** A *fresh* install redirects to the **setup URL** with an `installation_id` — and no > authorization code, unless you enabled "Request user authorization (OAuth) during installation". A user who is > *already* installed and simply authorizes comes back to the **callback URL** with a `code` and no > `installation_id`. A connector must handle both shapes, and resolve the installation from a user token in the > second case. GitHub also warns explicitly that **a bad actor can hit the setup URL with a spoofed > `installation_id`** — never mint a token from a callback-supplied installation id without first generating a user > access token and confirming that installation belongs to that user. **Manifest flow** (GitHub App only, and the best option for a scripted or repeatable registration): POST a JSON manifest — name, URLs, `hook_attributes`, permissions, events, callback URLs — to GitHub's app-creation endpoint, let the user name and confirm it, and GitHub redirects back with a temporary code. **Exchange that code within one hour** for the app's ID, its **`pem` private key**, and a generated **webhook secret**. There is also a URL-parameter variant (`https://github.com/settings/apps/new?name=…&public=true&request_oauth_on_install=true&…`) that pre-fills the form for a human to review — the right tool when you have no browser automation. **OAuth App** — Settings → Developer settings → **OAuth Apps** → **New OAuth App** (`https://github.com/settings/developers`). Fields: application name, homepage URL, description, authorization callback URL (up to 10), **Enable Device Flow** (leave off unless asked — GitHub flags it as a phishing risk), and token expiration, which is on by default. ## 4. Callback URLs Register **every** callback host the platform serves. GitHub accepts up to 10 per app, so all four fit in one registration. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Rules that bite: - **If `redirect_uri` is omitted, GitHub uses the first callback URL.** With four data centers registered, an authorize request that forgets `redirect_uri` silently sends EU customers to the US callback. Always send it. - **Wildcard matching is now opt-in per URL** (see Platform state). With it off — the default for anything registered after 2026-08-03 — the redirect URI must match exactly. With it on, host (excluding subdomains) and port must match and the path must be a subdirectory, so `https://oauth.example.com/path` and `https://example.com/path/sub` match `https://example.com/path`, but a different port or a sibling path does not. **Leave it off** for fixed, server-side callbacks like these. - A mismatch surfaces as `redirect_uri_mismatch` at connect time, not at save time. - Loopback is special-cased for desktop clients: literal `127.0.0.1` (not `localhost`) may use a different port than the registered URL. Irrelevant to a server-side connector, and not a workaround for a wrong callback. ## 5. Scopes (OAuth App) or permissions (GitHub App) **OAuth App scopes** are space-delimited in the authorize URL (`%20`-encoded). GitHub normalizes overlapping requests, so asking for `user`, `user:email` and `read:user` together yields one non-redundant token. | Scope | Grants | | --- | --- | | `repo` | Full control of private and public repositories — code, statuses, deployments, invitations, collaborators, webhooks, **and** org-owned projects, invitations, team memberships and webhooks | | `public_repo` | The same, public repositories only | | `repo:status` | Commit statuses without code access | | `read:org` | Read org membership, projects and team membership | | `admin:org` | Fully manage the org, its teams, projects and memberships | | `user` | Read/write profile info; includes `user:email` and `user:follow` | | `read:user` / `user:email` | Read profile / read email addresses | | `workflow`, `gist`, `notifications`, `project` | Feature-specific | **The `repo` problem.** There is no read-only private-repository scope. Anything that reads a private repo's contents needs `repo`, which also grants *write* to code and reaches org-owned resources. That is the single biggest reason customer security reviews reject an OAuth App connector, and it is not solvable by wording the consent screen better — it is solvable by registering a GitHub App with `Contents: read`. **GitHub App permissions** replace scopes entirely; the authorize URL does not accept a `scope` parameter for a GitHub App client. Permissions are grouped into **repository**, **organization**, **account** (and, on enterprise accounts, **enterprise**) sets, each set to read, write or admin. Pick the minimum: a user-to-server token gets the **intersection** of the app's permissions and the user's own, so over-requesting does not buy reach — it only costs you approvals. Webhook events are gated by the matching permission. ## 6. Capture the credentials From the app's settings page: - **Client ID** — public, stable. Note that a GitHub App's client ID is **not** its App ID; both are shown, and they are used in different places. - **Client secret** — **Generate a new client secret**. Copy it at generation; treat it as show-once and generate a replacement if it is lost. To rotate without downtime, generate the new secret, deploy it, then delete the old one. - **Private key — GitHub App only, and this is the part people lose.** Click **Generate a private key**; the browser downloads a **PKCS#1** `.pem` **once**. GitHub keeps only the public half; there is no way to re-download it. This key signs the app JWT that mints installation access tokens, so anyone holding it can act as your app on every installation — **it is at least as sensitive as the client secret.** An app may hold up to **25** keys, which is how you rotate: generate the new one, deploy, then delete the old. Keys do not expire; they must be revoked manually. Verify a key matches what GitHub holds by comparing SHA-256 fingerprints: ```bash openssl rsa -in PATH_TO_PEM_FILE -pubout -outform DER | openssl sha256 -binary | openssl base64 ``` - **Webhook secret** — GitHub App only; store it wherever the webhook receiver reads it. - **Endpoints** — `https://github.com/login/oauth/authorize`, `https://github.com/login/oauth/access_token`, API base `https://api.github.com`. On GHES all three live on the instance host (§9). Report each secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 7. Token lifetimes, refresh, and rate limits **User access tokens (both app types).** 8 hours, refresh token 6 months, both rotated on every refresh: once you use a refresh token, "that refresh token and the old user access token will no longer work". A client that keeps reusing the original refresh token fails on the second refresh, not the first — which is why this bug reaches production. Refresh via `POST https://github.com/login/oauth/access_token` with `client_id`, `client_secret`, `refresh_token` and `grant_type=refresh_token`. If the refresh token itself expires, the customer must re-authorize. Toggling expiration **on** for an app does not retroactively expire tokens already issued — delete those explicitly via `DELETE /applications/{client_id}/token`. **Installation access tokens (GitHub App).** Not an OAuth refresh at all: you sign a short-lived RS256 JWT with the private key (GitHub caps app JWTs at 10 minutes — back-date `iat` slightly to absorb clock skew), then `POST /app/installations/{installation_id}/access_tokens`. The result lives **1 hour** and can optionally be narrowed to specific repositories (up to 500) or to a subset of the app's permissions. There is nothing to store long-term except the private key and the installation id — which is a real operational advantage, and the reason a GitHub App connector has no "refresh token expired" failure mode. **Rate limits.** OAuth App user tokens: 5,000 req/hr per user (15,000 when the request is made on a user's behalf by an app owned by an Enterprise Cloud org). GitHub App installations: 5,000 base, **+50/hr for each repository beyond 20 and +50/hr for each org user beyond 20, capped at 12,500**; Enterprise Cloud installations get a flat 15,000. Secondary limits apply to both: no more than 100 concurrent requests, 900 points/minute per endpoint, 90 seconds of CPU per 60 seconds of real time, and **2,000 OAuth access token requests per hour** — that last one is the one a connector doing bulk re-authorization trips. GitHub returns primary and secondary rate-limit rejections as **HTTP 403**, not 429; a client that only retries on 429 will treat throttling as a permission error. ## 8. Org approval — the part that fails for customers, not for you Both models have an org gate, and they are different gates. **OAuth Apps: organization OAuth App access restrictions.** New organizations have them **on by default**. With restrictions on, a member cannot authorize a third-party OAuth App for org resources — they can only *request* approval, and only an **organization owner** can grant it. When an org first enables restrictions, already-authorized OAuth apps **immediately lose access** to that org's resources, and hook deliveries from private org repositories stop going to unapproved apps. So a connector that worked yesterday can break for one customer with no change on your side. The fix is always customer-side: an owner approves the app for the org. **GitHub Apps: installation permission.** By default a repository admin may install an app that requests neither organization permissions nor the repository-administration permission; anything broader needs an **organization owner**. Owners can further restrict installation to owners only, and can separately **disable app access requests** entirely. With both set, a repo admin can neither install nor ask. When a member lacks permission to install, GitHub emails the org owner with the request — but note this works when installing directly from the app owner, **not** when installing from GitHub Marketplace. Say which gate applies in your summary, and give the customer the exact ask ("an org owner must approve *AppName* at Settings → Third-party access", or "an org owner must install *AppName* and grant it these repositories"). ## 9. GitHub.com vs Enterprise Cloud vs Enterprise Server - **GitHub.com / Enterprise Cloud** — same hosts and endpoints. Enterprise Cloud changes the *numbers* (15,000/hr) and adds enterprise-level permissions, not the flow. - **GitHub Enterprise Server** — a different product for this purpose. **An app registered on GitHub.com cannot be installed on a GHES instance**; each instance must register its own. Every URL moves to the instance host: API base `https://HOSTNAME/api/v3`, GraphQL at `/api/v3/graphql`, and OAuth at `https://HOSTNAME/login/oauth/authorize` and `/login/oauth/access_token`. New endpoints and webhooks reach GHES later than GitHub.com, and the `x-github-enterprise-version` response header is how you branch on that. Practically, a GHES customer either hands you credentials from their own instance or self-hosts the app. This is why a platform typically carries a **third** connector for GHES rather than a hostname toggle on the first two — see the product note below. ## Product state — Unified.to (verified 2026-09-20; confirm with the connector's owner) As of 2026-09-20 Unified.to ships **both** models side by side, which makes this the rare place a reader can compare them against one product: - **The GitHub connector uses the OAuth App model.** It offers OAuth2 plus a personal-access-token option, sends `user` and `user:email` as login scopes, requests scopes per object type, exchanges the code as a JSON POST, and supports the refresh grant. Its scope map is dominated by **`repo`** — repository, branch, commit, pull request, file, project and Copilot-agent objects all request it — with `issues` for issue and comment objects, `read:org` plus `read:user`/`user` almost everywhere, and `admin:org` only for organization writes. It appends `prompt=select_account` to the authorize URL, pins the REST API version by header, and remaps GitHub's 403 rate-limit responses to 429 so retry and webhook sync treat them as throttling. Two dated quirks worth checking before you promise anything: platform-shared OAuth credentials for this connector are **switched off**, so every workspace must bring its own client ID and secret; and the help link it points customers at for obtaining those credentials **404s**. - **The GitHub App connector uses the installation model.** It requires the App's **private key** alongside the client ID and secret — mandatory, because the connector exists specifically to mint installation tokens — and it re-mints a fresh 1-hour installation token on every "refresh" instead of exchanging an OAuth refresh token. It **strips the `scope` parameter** from the authorize URL, because a GitHub App's access comes from its configured permissions; the scope map it inherits is effectively inert, so **the permissions you tick at registration are the real contract** for this connector. It sends customers to the App's install page (the account and repository picker) rather than a bare authorize URL, and handles both callback shapes from §3: an `installation_id` from a fresh install, or a bare `code` from an already-installed user, in which case it exchanges the code for a user token and looks the installation up. Two things to flag: when a user has **several installations it takes the first**, which is wrong for anyone installed on more than one org; and it mints from the callback-supplied installation id, which is exactly the spoofing risk GitHub warns about in §3. Its object coverage matches the OAuth connector minus the Copilot-agent object. - **A third connector covers GitHub Enterprise Server**, taking the instance hostname and a personal access token or OAuth2, with all endpoints rewritten to that host per §9. That split exists for the reason in §9 — it is not redundancy. This skill does not cover registering an app on a customer's GHES instance. Treat all of the above as dated observation, not contract. Confirm scope sets, the shared-credentials switch and the multiple-installations behaviour with the connector's owner before you register anything against them. ## 10. Verify end-to-end Authorizing on the account that owns the app proves little. Test the path a customer takes. 1. Authorize from a **second account or org**, through the platform's real connect flow. 2. **GitHub App:** install on an org with a *subset* of repositories selected, then confirm a read call returns only those repositories — that is the whole point of the model, and the easiest thing to get silently wrong. 3. Confirm the token response carries what you expect: a `refresh_token` and `expires_in: 28800` for a user token, or a 1-hour installation token. 4. Force a refresh, then force a **second** refresh using the token the first returned. That is what catches a client that ignores rotation. 5. Send `redirect_uri` explicitly and confirm a non-default data center callback works. 6. If webhooks are on, deliver one and verify the `X-Hub-Signature-256` HMAC. | Symptom | Cause | | --- | --- | | `redirect_uri_mismatch` | Callback not registered exactly; or wildcard matching off and the URI differs (§4) | | EU/AU customers land on the US callback | `redirect_uri` omitted — GitHub used the first registered callback (§4) | | `incorrect_client_credentials` | Wrong client ID or secret; or GitHub App client ID confused with App ID (§6) | | `bad_verification_code` | Code already used or older than 10 minutes | | `unverified_user_email` | The user's primary GitHub email is unverified — customer-side fix | | `application_suspended` | GitHub suspended the app; contact GitHub Support | | `device_flow_disabled` | Device flow not enabled in app settings | | Auth succeeds, org's private repos invisible | OAuth App: org restrictions not approved. GitHub App: repo not selected at install (§8) | | Worked yesterday, one customer now 403s | Org enabled OAuth App restrictions, or revoked/narrowed the installation (§8) | | Second refresh fails | Client is not storing the rotated refresh token (§7) | | GitHub App auth fails for everyone after a key change | Private key deleted before the replacement was deployed (§6) | | Install succeeds but no token minted | Fresh install returned `installation_id` to the setup URL with no `code` (§3) | | Throttled but the client never backs off | GitHub returns rate limits as **403**, not 429 (§7) | | Installation token rejected by a length check | Stateless `ghs_APPID_JWT` format rollout (Platform state) | | Nothing works against a customer's self-hosted instance | GHES needs its own app on its own host (§9) | ## 11. Hand off — never commit the secret or the key - **Do not** write the client secret, the private key, or the webhook secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - The **private key deserves the same handling as the secret** — it mints tokens for every installation. If it has to cross a boundary, base64 it into a secret manager entry, never a repository. - If a code change is needed (a callback host, a scope, an added permission), keep it credential-free and say what the human must set out of band. - Close with: app type (OAuth App or GitHub App) and why; app name and owning account; client ID; where each secret was delivered; authorize/token endpoints and API base; the exact scope strings or the permission list; install visibility (public/private); token lifetimes; the org-approval ask customers will need; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the OAuth App vs GitHub App choice is not settled and the answer changes the product (§A); someone proposes migrating a live connector between the two models — that re-authorizes every customer; the client does not store rotated refresh tokens (report it, do not register and hope); a GitHub App private key is missing and the app has live installations; the fix requires an org owner to approve or install something, or to change org-wide app policy; a GHES customer needs their own registration on their own instance; a Marketplace listing, publisher verification, or a security questionnaire asks for compliance, legal or volume claims; or the developer settings pages do not match the **Platform state** section. ## References Every link verified to return 200 on 2026-09-20. The `github.com/settings/...` registration pages require sign-in and are deliberately not listed here. - GitHub Apps vs OAuth Apps — https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps - Registering a GitHub App — https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app - Creating an OAuth App — https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app - About the user authorization callback URL (wildcard matching) — https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url - About the setup URL (spoofed `installation_id` warning) — https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-setup-url - Authorizing OAuth apps (web + device flow) — https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps - Scopes for OAuth apps — https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps - Choosing permissions for a GitHub App — https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app - Generating an installation access token — https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app - Generating a user access token — https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app - Refreshing user access tokens — https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens - Managing private keys for GitHub Apps — https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps - Registering a GitHub App from a manifest — https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest - Registering a GitHub App using URL parameters — https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-using-url-parameters - Making a GitHub App public or private — https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/making-a-github-app-public-or-private - Making your GitHub App available for GitHub Enterprise Server — https://docs.github.com/en/apps/sharing-github-apps/making-your-github-app-available-for-github-enterprise-server - About OAuth App access restrictions — https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions - Limiting OAuth app and GitHub App access requests and installations — https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization/limiting-oauth-app-and-github-app-access-requests-and-installations - Requesting a GitHub App from your organization owner — https://docs.github.com/en/apps/using-github-apps/requesting-a-github-app-from-your-organization-owner - Installing a GitHub App from a third party — https://docs.github.com/en/apps/using-github-apps/installing-a-github-app-from-a-third-party - Rate limits for the REST API — https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api - Validating webhook deliveries — https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries - Troubleshooting authorization request errors — https://docs.github.com/en/apps/oauth-apps/maintaining-oauth-apps/troubleshooting-authorization-request-errors - Troubleshooting access token request errors — https://docs.github.com/en/apps/oauth-apps/maintaining-oauth-apps/troubleshooting-oauth-app-access-token-request-errors - Best practices for creating an OAuth app — https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/best-practices-for-creating-an-oauth-app - Best practices for creating a GitHub App — https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/best-practices-for-creating-a-github-app - Token expiration and revocation — https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation ## gmail-oauth-app URL: https://docs.unified.to/skills/gmail-oauth-app/SKILL.md # Gmail OAuth2 App Registration Registering the client is the easy part. **Every Gmail scope that can read a mailbox is a restricted scope**, which means Google verification *plus* an externally-performed CASA security assessment *plus* an annual revalidation — and unlike Drive, **Gmail has no `drive.file`-style per-item escape hatch**. There is no scope that reads "only the messages the user picked." If the product reads mail at all, the assessment is on the roadmap, and that fact sets the project timeline before a single console field is filled in. One real lever exists, and it is not a hatch so much as a different product: **`gmail.send` is a *sensitive* scope, not a restricted one**, and an app that only ever sends mail can stay out of the restricted tier entirely. It also cannot read, list or search anything. That trade, the narrowest-scope ladder behind it, and the Gmail-specific token and quota behaviour are what this file is for. Everything else is shared. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns all the console mechanics, and this file does not repeat them: - Choosing/creating the Cloud project and organization, enabling APIs, and who may administer it. - Configuring the app on the Google Auth Platform (Branding, Audience, Data Access, Clients) and creating a **Web application** client — the only type that yields a usable client secret. - Redirect-URI rules (exact matching, HTTPS-only, propagation delay) and the platform's per-data-center callback list. - The generic scope model: declaring every scope on Data Access, the non-sensitive / sensitive / restricted tiers, the rule that the app's tier is its most sensitive scope, and `access_type=offline` + `prompt=consent` for refresh tokens. - The client secret shown and downloadable **only once at creation**, add-then-disable rotation, `Testing` vs `In production` (including the seven-day refresh-token expiry), the 100-user caps, verification, and the annual CASA assessment at assurance level **AL1 or AL2**. If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Gmail-specific ones, and they decide the cost of the project: | Input | Notes | | --- | --- | | **Does the product read mail, or only send it?** | The single question that decides restricted vs sensitive tier (§1, §2) | | **If it reads: bodies, or headers and labels only?** | Does not lower the tier — `gmail.metadata` is fully restricted (§2) | | **Does it need to permanently delete, or is trash enough?** | Only `https://mail.google.com/` deletes past trash; almost nothing needs it (§2) | | **Which of Google's four permitted Gmail application types is this?** | Restricted Gmail scopes are unobtainable outside them (§3) | | **Is there budget and a named owner for a CASA assessment and its annual renewal?** | Blocking for any mailbox read | | **Consumer accounts, one Workspace customer, or a multi-tenant connector?** | Decides whether `Internal` removes the review at all (§3) | | **Real-time updates needed?** | Pub/Sub push adds a topic, an IAM grant and a 7-day renewal loop (§6) | | **Expected send volume per user per day** | Gmail sending limits are per mailbox and unrelated to API quota (§5) | ## Quick Start 1. Work the base skill's console steps, and **enable the Gmail API** on the project (`gmail.googleapis.com`). A perfect client against a project with the API off fails on the first call, not at authorization. 2. Settle the scope decision before touching Data Access: §1 (tiers), §2 (the ladder), §3 (permitted app types). 3. If any mailbox-reading scope is in the set, confirm the app is a permitted application type **and** that CASA is funded and owned, then start verification now — not after launch. 4. If the product only sends, request `gmail.send` alone and say plainly that this keeps the app out of the restricted tier (§2). 5. Add Pub/Sub topic and IAM grant only if push is in scope (§6). 6. Finish the base skill's capture, round-trip and handoff steps, adding the Gmail checks in §8. ## Gmail platform state (verified 2026-09-20 — re-verify before trusting) - **Restricted Gmail scopes, per Google's own restricted-scope list and the Gmail scopes page:** `https://mail.google.com/`, `gmail.readonly`, `gmail.metadata`, `gmail.modify`, `gmail.insert`, `gmail.compose`, `gmail.settings.basic`, `gmail.settings.sharing`. That is every scope that can see a mailbox. - **`gmail.send` is *sensitive*, not restricted** — it appears on the Gmail scopes page under Sensitive and is absent from the restricted list. Sensitive means verification with a justification and a demo video; it does **not** pull in the CASA assessment. This is the one meaningful tier lever Gmail offers, and it is easy to miss because so much third-party writing (and some older runbooks) flattens "all Gmail scopes" into "restricted." - **`gmail.labels` and `gmail.addons.current.action.compose` are non-sensitive**; the other add-on scopes (`gmail.addons.current.message.metadata`, `...message.readonly`) are sensitive. Add-on scopes only apply inside a Workspace add-on's runtime and are not a way for a server-side connector to read mail. - **There is no per-message equivalent of `drive.file`.** Nothing grants access to "just the messages the user picked." - **Restricted Gmail scopes require a permitted application type** — Google publishes four for Gmail (§3) — and, above Google's thresholds, an annual third-party security assessment. - **Refresh tokens carrying Gmail scopes are invalidated when the user changes their account password.** This is a documented Gmail-only rule, not a general OAuth one (§4). If the scope tables do not look like this, stop and report what you actually see. ## 1. The Gmail scope tiers | Tier | Gmail scopes | What it costs | | --- | --- | --- | | **Non-sensitive** | `gmail.labels`, `gmail.addons.current.action.compose` | Basic app verification only. No assessment. | | **Sensitive** | `gmail.send`, `gmail.addons.current.message.metadata`, `gmail.addons.current.message.readonly` | Additional app verification: per-scope justification and a demo video. No security assessment. | | **Restricted** | `https://mail.google.com/`, `gmail.readonly`, `gmail.metadata`, `gmail.modify`, `gmail.insert`, `gmail.compose`, `gmail.settings.basic`, `gmail.settings.sharing` | Full verification **plus** an annual CASA security assessment. | Three traps in that table: - **`gmail.compose` is restricted, `gmail.send` is not**, even though both end in a sent message. `gmail.compose` manages drafts (it can create, list and read drafts, which means reading mail the app did not write), and that is what puts it in the restricted tier. An app that composes its own body and posts it does not need it. - **`gmail.metadata` is fully restricted despite never returning a body.** "It's only headers and labels" is not an argument Google's tiering accepts. Choosing it buys the entire restricted-tier cost for a fraction of the capability — and it also disables mail search (§2). If metadata is genuinely all the product needs, that is an argument about the product, not a cheaper configuration. - **`https://mail.google.com/` is the full-access scope and covers IMAP, SMTP and POP3 as well as the REST API.** Its only unique capability over `gmail.modify` is permanent deletion that bypasses trash. Requesting it "to be safe" hands a reviewer the widest possible scope to interrogate, for a capability almost no integration uses. **The tier of the app is the tier of its most sensitive scope** (base skill §5). One `gmail.readonly` in the set puts the whole project into restricted verification, however narrow everything else is. ## 2. The narrowest-scope ladder Google's own guidance is to "choose the most narrowly focused scope possible." For Gmail that is an argument you can actually win with a reviewer, because the scopes differ sharply in what they permit. Work down this ladder and stop at the first rung that does the job: | Scope | Can do | Cannot do | | --- | --- | --- | | `gmail.labels` (non-sensitive) | See and edit label definitions | Touch any message | | `gmail.send` (**sensitive**) | Send a message the app composed | **Read anything at all** — no list, no get, no search, no drafts, no threads | | `gmail.metadata` (restricted) | List messages, read headers and labels | **Read message bodies**; and it **cannot use the `q` search parameter** — filtering is limited to `labelIds` and `includeSpamTrash` | | `gmail.readonly` (restricted) | Read messages, threads, attachments, settings; full search | Any write | | `gmail.compose` (restricted) | Create/read/update drafts and send | Modify labels on arbitrary messages | | `gmail.modify` (restricted) | Read, compose, send, label, trash | Permanently delete bypassing trash | | `https://mail.google.com/` (restricted) | Everything, including IMAP/SMTP/POP | — | Two of those rows carry most of the argument: - **`gmail.send` cannot read.** That is the whole point, and it is why it escapes the restricted tier. Any feature that says "show the user their sent history", "thread the reply", "detect a bounce", "sync the conversation into the CRM" is not implementable on `gmail.send`. If the product only injects outbound mail, `gmail.send` removes an assessment and its annual renewal outright — present that to the owner rather than deciding it. - **`gmail.metadata` cannot read bodies and cannot search.** The `q` parameter is rejected under that scope, which rules out every "find messages matching…" feature and forces label-based enumeration. Teams routinely pick `gmail.metadata` believing it is the cheap tier, get the full restricted cost anyway, and then discover search is gone. If you are paying restricted prices regardless, `gmail.readonly` is usually the honest request. Requesting a narrower scope later is easy; widening one after launch **re-consents every customer** and can force an out-of-cycle re-assessment. Decide up front. ## 3. Permitted application types — the gate before the paperwork Restricted Gmail scopes are not available to every app willing to pay for an assessment. Google's Workspace user-data and developer policy limits Gmail restricted scopes to four approved use cases: - **Email clients** — built-in and web clients that let users compose, send, read and process mail through a UI. - **Email backup applications** — apps that automatically back up mail. - **Productivity enhancement** — apps that improve the email experience for productivity purposes (CRM sync, delayed send, AI summarization). - **Reporting / monitoring** — apps that use information from email to provide reporting or monitoring services (itinerary extraction, package tracking). **If the app is not one of these, restricted Gmail scopes are not obtainable at any price.** Check it first; it is the one Gmail constraint no budget or scheduling fixes, and Google's restricted-scope review explicitly asks the app to demonstrate that it is a permitted application type. The same policy attaches **Limited Use** obligations that survive approval and that you must not answer on the user's behalf: no transfer or sale of Gmail data except under the narrow listed exceptions (explicit user consent, security, legal compliance, merger with prior notice); **no use of Gmail data for advertising of any kind, including retargeting and interest-based ads**; and **no human reading of user mail** unless there is documented explicit consent, the data is aggregated and anonymized for internal operations, it is needed for a security investigation, or law requires it. The last one has teeth for anything involving manual QA of customer mailboxes or training on mail content — raise it with the owner rather than attesting to it. The `Internal` audience exemption in the base skill applies to Gmail too: an app whose every user is in the owner's own Workspace organization needs no Google review. That is available to a customer registering their own app, and by definition not to a vendor registering one client for many companies. ## 4. Gmail-specific token behaviour On top of the base skill's refresh-token rules (offline access, `prompt=consent`, 100 tokens per account per client, six-month inactivity expiry), Gmail adds one of its own, and it is the source of most "our Gmail connections randomly break" reports: > **A refresh token that contains Gmail scopes is invalidated when the user changes their account password.** Google documents this as a Gmail-scope-specific condition. It is not a bug, not rotation, and not recoverable in code: the token is gone and the user must re-authorize. Consequences worth designing for: - **`invalid_grant` on refresh is the expected outcome of a routine password reset.** Treat it as "reconnect required" and surface that to the end user, rather than retrying or paging someone. - **Enterprise password-rotation policies produce correlated waves of disconnects** across one customer's users. A cluster of `invalid_grant` failures inside one domain, on one day, is almost always this — not a client problem. - A mixed-scope token is still a Gmail token. Bundling `gmail.readonly` into an otherwise identity-only grant brings this invalidation rule along with it. - Workspace session-control policies produce a similar-looking `invalid_grant`; the distinguishing signal is that session-control expiry hits the whole domain on a schedule, and the fix is the admin's. ## 5. Enable the API, then live inside Gmail's two separate limits Enable **Gmail API** (`gmail.googleapis.com`) on the project. If push is in scope, enable the **Cloud Pub/Sub API** too (§6). Then keep two unrelated ceilings straight — people conflate them constantly, and the fixes are opposite: **API quota, measured in quota units.** Per project: 1,200,000 units per minute. **Per user per project: 6,000 units per minute.** Method costs are uneven: `labels.list` 1, `history.list` 2, `messages.list` 5, `messages.get` 20, `threads.get` 40, and `messages.send`, `drafts.send` and `users.watch` 100 each. So a per-user budget of 6,000 units a minute is roughly 300 full message fetches — a naive "list then get every message" backfill burns it in seconds, and it burns it **per mailbox**, so the project-level headroom never saves you. Google's billing threshold sits at 80,000,000 units per 24 hours per project. - Over the per-project ceiling you get `403 dailyLimitExceeded` / `rateLimitExceeded` — a project quota increase is at least possible. - Over the **per-user** ceiling you get `403 userRateLimitExceeded` or `429`, and Google states plainly that **per-user limits cannot be raised**. The only remedies are fewer requests, exponential backoff starting at one second or more, and spreading work across accounts. Note that Gmail signals rate limiting as a **403**, not only a 429 — code that treats 403 as an authorization failure will disconnect healthy connections under load. - Keep batches to **50 requests or fewer**; larger batches trigger rate limiting on their own. **Mailbox sending limits, measured in messages and recipients.** Entirely separate from quota units, enforced per mailbox by Gmail, not by the API: - **Workspace:** 2,000 messages per day (500 on a trial, and trial limits are not raised); 2,000 recipients per message with at most 500 external; **500 recipients per message when sending via the Gmail API**; 10,000 recipients per day; 3,000 external recipients per day; 3,000 unique recipients per day of which 2,000 external. - **Consumer Gmail:** 500 messages per day and 500 recipients per message. - Hitting either locks sending for **up to 24 hours** ("You have reached a limit for sending email"), while incoming mail and everything else keeps working. No quota increase touches this; it is a mailbox property. ## 6. Push notifications via Pub/Sub Gmail push is not a webhook URL you paste into a form — it is a Cloud Pub/Sub topic in **your** project, and it changes what the registration has to include: 1. Enable the Cloud Pub/Sub API and create a topic (`projects//topics/`) plus a push or pull subscription. 2. Grant **Publisher** rights on that topic to Google's own service account, `gmail-api-push@system.gserviceaccount.com`. Missing this grant is the single most common cause of "watch succeeds, nothing ever arrives." An organization with a domain-restricted-sharing policy needs an explicit exception before that grant can be made. 3. Call `users.watch` with the topic name. It accepts `https://mail.google.com/`, `gmail.modify`, `gmail.readonly` or `gmail.metadata` — so **push needs a restricted scope**; there is no send-only or non-sensitive path to it. 4. **The watch expires and must be re-registered at least every seven days** — Google recommends calling `watch` daily. A push integration that works for a week and then silently stops is this, not a token problem. 5. Each watched user is capped at **one notification per second**; excess notifications are dropped, not queued. Push tells you *that* something changed; you still reconcile with `history.list`. `users.watch` costs 100 quota units per call, so a daily re-watch across a large tenant is a real line in the per-user budget from §5. ## 7. Workspace admin controls specific to Gmail The base skill covers generic API controls. Gmail sits under a sharper version of them, and a Workspace admin can stop a perfectly verified app cold: - **Gmail is one of the services for which an admin can mark high-risk OAuth scopes restricted**, independently of any general app allowlisting. With Gmail marked restricted, an app requesting a Gmail scope is blocked at consent unless that specific app is **Trusted** — the user simply sees that the app is blocked. - The three app states matter here: **Trusted** overrides the service restriction; **Limited** can reach only unrestricted services, which means a Limited app cannot touch Gmail once Gmail is restricted; **Blocked** reaches nothing. - **Flipping Gmail to restricted revokes tokens immediately** for already-installed apps that are not trusted. Existing connections stop working at once — the symptom is a customer-wide outage with no change on your side. - An admin can also **block all third-party API access** domain-wide, which kills even sign-in scopes. - **Pre-authorization is by OAuth client ID** (or a Marketplace App ID): the admin adds the app under API controls and marks it Trusted. Give the customer the exact client ID — nothing else identifies the app to them. A Workspace customer who wants the connection approved before users touch it does exactly this. - **Domain-wide delegation** is the other pre-authorization route: the admin authorizes a service account's client ID against a comma-delimited list of Gmail scopes under API controls. It works only for mailboxes in a Workspace domain — consumer `@gmail.com` accounts cannot be impersonated — and the scope list registered there must match what the app actually requests, character for character. When exactly one customer cannot connect and everyone else can, this section is the first place to look, and the fix is theirs, not yours. ## Product fact — what the Gmail connector asks for > **As of 2026-09-20, Unified.to's Gmail connector requests:** > > - **Login/identity only:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/userinfo.email`, > `https://www.googleapis.com/auth/userinfo.profile` > - **Read (messages and channels):** the identity set **plus** `https://www.googleapis.com/auth/gmail.readonly` > - **Write (messages):** the identity set **plus** `https://www.googleapis.com/auth/gmail.compose` and > `https://www.googleapis.com/auth/gmail.modify` > - **Webhooks:** the identity set **plus** `https://www.googleapis.com/auth/gmail.readonly` > > Tier consequence: `gmail.readonly`, `gmail.compose` and `gmail.modify` are **all restricted**, so the read, write and > webhook configurations each put the registering app squarely in the restricted tier — verification *and* an annual > CASA assessment, and the app must be one of the four permitted Gmail application types (§3). Only the > login/identity-only configuration stays non-sensitive. There is no metadata-only or send-only variant on offer, so > the §2 ladder cannot be walked down without a change on the connector side. > > Auth quirks worth knowing before registering: the connector drives the **legacy authorize endpoint** > (`accounts.google.com/o/oauth2/auth`) against the current token endpoint, and it does send `access_type` and > `prompt`, so offline refresh tokens are obtainable. It also offers a **Google service-account credential path** as an > alternative to the user OAuth flow, and that path mints its token against the **full-access Gmail scope** > (`https://mail.google.com/`) — meaning a Workspace admin using it must authorize the widest Gmail scope under > domain-wide delegation (§7), not a narrow one. Finally, the connector **suppresses background connection tests** for > Gmail specifically, because the per-user quota in §5 is tight enough that routine health checks would compete with > customer traffic, and it **remaps Gmail's 403 rate-limit responses to 429** so sync backs off instead of treating > throttling as an auth failure. > > Scope sets change. This note is dated, not live; confirm with the connector's owner before submitting anything. ## 8. Gmail-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Make one real Gmail read (list messages, then get one) and confirm the Gmail API is enabled and returns data. 2. Inspect the granted `scope` in the token response against what was requested — a granular-consent screen lets the user drop scopes, and a partially granted Gmail set fails later on a specific call, not at consent. 3. If the app requested `gmail.metadata`, confirm a `q` search is rejected and a body is absent **before** shipping a feature that assumes either works. 4. If the app requested `gmail.send`, confirm it can send **and** that any read call is refused — that asymmetry is the whole scope, and it is easy to test with a leftover broader token and conclude wrongly. 5. If push is in scope, publish a test change, confirm a Pub/Sub message arrives, and confirm the 7-day re-watch job exists and runs. 6. Change the test account's password, then refresh. The refresh **must** fail with `invalid_grant` (§4). Confirm the product surfaces "reconnect" rather than retrying. | Symptom | Cause | | --- | --- | | Every Gmail call fails although scopes look right | Gmail API not enabled on the project | | `invalid_grant` on refresh for scattered users | Those users changed their Google password — Gmail-scoped refresh tokens are invalidated (§4) | | A whole customer domain fails `invalid_grant` on one day | Enterprise password rotation, or a Workspace session-control policy (§4) | | One customer's users are blocked at the consent screen | Their admin marked Gmail's high-risk scopes restricted and this app is not Trusted (§7) | | A customer's connections all die at once with no change on your side | Admin flipped Gmail to restricted; untrusted apps' tokens are revoked immediately (§7) | | `403 userRateLimitExceeded` / `429` under load | Per-user 6,000 units/minute — cannot be raised; back off and batch ≤50 (§5) | | `403 rateLimitExceeded` misread as an auth failure | Gmail signals throttling as 403; treat it as a retry, not a disconnect (§5) | | Sending stops for a day, reads still work | Mailbox sending limit, not quota and not auth (§5) | | `users.watch` succeeds, no notifications ever arrive | `gmail-api-push@system.gserviceaccount.com` lacks Publisher on the topic (§6) | | Push works for a week, then stops | `watch` not re-registered within 7 days (§6) | | `q` search parameter rejected | App is on `gmail.metadata`, which forbids it (§2) | | Restricted-scope request rejected despite complete paperwork | App is not one of the four permitted Gmail application types (§3) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - The product reads mail and nobody has confirmed **budget and a named owner for the CASA assessment and its annual renewal** — register nothing until that is answered. - The app may not qualify as one of Google's **four permitted Gmail application types** (§3). That is a product classification, not a console choice. - Someone wants to narrow live connections to `gmail.send`, or widen them from it — either direction re-consents every customer and changes what the product can do (§2). - Anyone asks you to attest to **Limited Use** terms: data transfer, advertising use, or whether humans read customer mail (§3). Those are commitments the owner makes, not answers you compose. - A customer's Workspace admin must mark the app **Trusted** or authorize **domain-wide delegation** — you supply the client ID and the exact scope strings; they make the change (§7). - Send volumes are near the mailbox sending limits and someone expects a quota increase to fix it (§5). - The Gmail scope tiers do not match the **Gmail platform state** section above — in particular if `gmail.send` has moved into the restricted list. ## References Gmail-specific only; the base skill carries the generic Google OAuth references. Verified to resolve on 2026-09-20. - Choose Gmail API scopes (tiers and per-scope descriptions) — https://developers.google.com/gmail/api/auth/scopes - Restricted scopes list (the Gmail entries, including IMAP/SMTP/POP under `mail.google.com`) — https://support.google.com/cloud/answer/13464325?hl=en - Google Workspace user data and developer policy (Gmail appropriate access + Limited Use) — https://developers.google.com/workspace/workspace-api-user-data-developer-policy - Gmail API usage limits (quota units per method, per-project and per-user ceilings) — https://developers.google.com/gmail/api/reference/quota - Handle Gmail API errors (403 vs 429, backoff, batch size) — https://developers.google.com/gmail/api/guides/handle-errors - Push notifications with Cloud Pub/Sub (topic, publisher grant, 7-day renewal, 1/sec cap) — https://developers.google.com/gmail/api/guides/push - `users.watch` reference (accepted scopes, expiration) — https://developers.google.com/gmail/api/reference/rest/v1/users/watch - `users.messages.list` reference (the `gmail.metadata` restriction on `q`) — https://developers.google.com/gmail/api/reference/rest/v1/users.messages/list - `users.messages.send` reference (which scopes may send) — https://developers.google.com/gmail/api/reference/rest/v1/users.messages/send - Synchronizing a Gmail client (`history.list` partial sync) — https://developers.google.com/gmail/api/guides/sync - Gmail sending limits in Google Workspace — https://knowledge.workspace.google.com/admin/gmail/gmail-sending-limits-in-google-workspace - Consumer Gmail sending limits — https://support.google.com/mail/answer/22839?hl=en - Control which third-party and internal apps access Workspace data (trusted/limited/blocked, high-risk Gmail scopes, token revocation) — https://knowledge.workspace.google.com/admin/apps/control-which-third-party-and-internal-apps-access-google-workspace-data ## google-ads-oauth-app URL: https://docs.unified.to/skills/google-ads-oauth-app/SKILL.md # Google Ads API OAuth2 App Registration Get a Google Ads API client that can actually read a customer's live ad accounts: an OAuth2 client ID and secret, an approved **API access level** for the Cloud project that owns them, a Google Ads manager account, and the `login-customer-id` header wired into the calls. The thing that makes Google Ads different from plain Google OAuth is that **the OAuth client is only half the credential**. The other half is an access level Google grants after reviewing your tool — historically issued as a 22-character *developer token* from a Google Ads manager account, and since 9 September 2026 attached to the Google Cloud project instead. The two halves come from two different consoles owned by two different Google products, and a perfectly valid OAuth client whose project sits at **Test** access can authorize every customer in the world and then fail every single API call against a real ad account. Budget for the review, not the clicking. ## Built on: `google-cloud-console-oauth` **Read that skill first.** It is the whole registration mechanic; this file is the Ads delta and assumes it. What it covers, and what you will be missing if you load only this file: - Picking or creating the **Cloud project**, enabling APIs, permissions, and the Google Auth Platform page layout. - Creating the OAuth client: **Web application** is the type that yields a usable client secret; the others do not. - **Redirect URI** rules — exact match, HTTPS only, no wildcards, propagation delay — and the Unified.to callback list. - **Declaring every scope** on the Data Access page, and how scope tiers drive review. - The **one-time client secret** (hashed; full value shown only at creation) and add-then-disable rotation. - **Testing vs In production**, the seven-day refresh-token expiry, the 100-user caps, and OAuth app verification. Where this file and the base ever disagree, the base wins. ## Inputs to collect before you start The base skill's input list applies in full. These are the extra ones Google Ads needs. Ask in one batch; never invent. | Input | Notes | | --- | --- | | **Which Google Cloud project** owns, or will own, the OAuth client | This is the credential that now carries API access (§1, §2, §5) | | **Existing developer token**, if any, and the manager account it came from | Determines reuse vs. re-application (§1, §5) | | **Manager (MCC) account** that will sit at the root, and its 10-digit customer ID | Needed for `login-customer-id` and for test hierarchies (§2, §7) | | **Tool profile**: full-service, reporting-only, or internal | Decides the access level you need and whether RMF applies (§5) | | **Expected daily operation volume** | Decides Explorer vs. Basic vs. Standard (§5) | | **Google account** that will own the project, with 2SV/passkey already enabled | Blocking for generating refresh tokens (§4) | ## Quick Start 1. Reuse the existing **project** if you can — a new one starts back at Test access, with no way to carry the old approval across (§1). 2. Create or select the Google Ads **manager account**, plus a **test manager account** if you are below Explorer, and enable the Google Ads API in the project — that grants **Test** access automatically (§2). 3. Run the base skill's consent-app and Web-application-client steps with the Ads overrides: External, In production, complete branding (§3). 4. Request the single `https://www.googleapis.com/auth/adwords` scope with `access_type=offline` (§4). 5. Work the **access level** ladder — Test → Explorer → Basic → Standard, brand verification included. The long pole (§5). 6. Capture all three credentials: client ID, client secret, and the developer token / access-level state (§6). 7. Verify against a **test** account first, then a production one (§7), and hand off (§8). ## Platform state (verified 2026-09-20 — re-verify before trusting) This area changed eleven days ago. Anything written before September 2026 — including parts of Google's own docs — describes a process that no longer exists. - **Developer tokens were sunset on 9 September 2026.** API access levels now attach to the **Google Cloud project** that owns your OAuth client ID and secret (or your service account), not to a token. You may still send the `developer-token` header; it is accepted and ignored, so existing code keeps working. Google says it will start **rejecting** developer tokens in a future major API version. - **Access levels were migrated automatically**, based on the last 90 days of API call logs: every Cloud project seen using an approved developer token inherited that token's level. A token that was approved but never used, or unused for 90 days, transferred **nothing** — those projects sit at Test. - **You can no longer re-associate an approved developer token with a new Cloud project.** Google supported this case-by-case until the sunset; it does not any more. A new project must apply for access on its own merits. - **Sign-up moved to the Google Cloud Console.** Applications started from the Google Ads manager account's **API Center** are no longer processed. API Center still exists for historical lookup and will be retired. The one exception Google calls out: the **App Conversion Tracking API** still issues developer tokens from API Center. - **There are now four access levels, not three**: Test, **Explorer**, Basic, Standard. Explorer is new and is the first rung that touches production accounts. - **Brand verification is a prerequisite for Basic access** on new applications. Basic is then reviewed automatically, within minutes. Standard is still a manual audit. - **2-Step Verification has been required since 21 April 2026** for users generating new refresh tokens, and from **5 August 2026** Google began requiring **passkeys** for some Google Ads API users. Existing refresh tokens are unaffected either way. - **Known issue, open as of this date**: projects on the Google Cloud **Free Trial**, or with a suspended or disabled billing account, get rejected for Explorer and Basic even after brand verification, with a rejection email blaming brand verification or eligibility. Workarounds: upgrade the project to a paid tier, remove billing from the project entirely, or use a different brand-verified project. - **Second known issue**: projects whose level was upgraded from Test *after* 9 September 2026 may still get `AUTHORIZATION_ERROR` on production calls while test-account calls succeed. Google has a fix rolling out; the interim workaround is a brand-new Cloud project applying for Explorer. - **Current API version is v25** (released 22 July 2026, sunset tentatively August 2027). v22 sunsets around October 2026. Versions sunset on a published cadence, so the version in your URL path is a maintenance commitment, not a one-time choice. If either console does not look like this, stop and report what you actually see rather than clicking on. ## 1. Reuse the project, or start new The base skill covers reusing versus replacing the **OAuth client**. Google Ads adds a second, independent decision that people conflate with it: the **Cloud project**, which is what now holds the approved access level. **Reuse the Cloud project** unless you have a specific reason not to. Spinning up a fresh one to "start clean" silently demotes you to Test access and puts you back at the bottom of the review ladder, with no way to carry the old approval across. The only good reasons to start new: deliberately separating deployment tiers (Google's own policy requires separate projects for development, staging and production), or the current project is stuck behind the Free Trial billing defect above. Say which path you are taking, and why, before you touch anything. ## 2. Accounts: Cloud project and Google Ads manager account You need both. They are different products with different sign-ins, and the most common failure mode here is setting up one and assuming the other came with it. **Google Cloud project** — created or selected per the base skill. Billing is optional for the Google Ads API itself (there is no charge for the API), but see the Free Trial defect above before deciding to leave a trial billing account attached. Enable the API at `https://console.cloud.google.com/flows/enableapi?apiid=googleads.googleapis.com`. Enabling it grants the project **Test** access automatically. **Google Ads manager account (MCC)** — the account type that sits above client ad accounts and is what `login-customer-id` names. Create one at `https://ads.google.com/home/tools/manager-accounts/`. You still need a manager account even though it no longer issues your access level: it is where customer accounts get linked, where the Google Ads API Terms of Service are signed (the `MISSING_TOS` error points at `https://ads.google.com/aw/apicenter`), and it is the root of any test hierarchy. **Test manager account** — separate, and required for development below Explorer access. Create it at `https://ads.google.com/nav/selectaccount?sf=mt`, signed in with a Google Account **not** linked to your production manager account. Test and production accounts cannot interact at all, so a test account cannot live under a production manager. Any client account created under a test manager is automatically a test account; they show as *cancelled* in the UI with a red **Test account** label, serve no ads, and return empty metrics. Limits worth knowing: no bid simulations, no conversion uploads, no billing, roughly 50 accounts per hierarchy, and Google permanently deletes test accounts after one year with no API activity or logins. Ads-specific steps only a human can do: signing the Google Ads API Terms of Service on the manager account, 2SV and passkey enrollment, and entering payment details. When you hand a user a click path, have them report back both the client ID **and** the access level shown on the Google Ads API Overview page — the second decides whether anything works at all. ## 3. Consent screen: the Google Ads overrides Configure the consent app and create the **Web application** client exactly as the base skill describes, with two Ads-specific deviations: - **Publish the app, even if it is internal.** Set the audience to **External** and the publishing status to **In production**. Workspace users will land on **Internal** by default and must click **Make external** first. Google Ads' own documentation explicitly overrides the general Cloud guidance here, and a Basic access application from a project still in **Testing** will not pass. (Testing also caps refresh-token lifetime at seven days — see the base skill; with the `adwords` scope you are squarely in that trap.) - **Fill in *all* branding before you go further.** App name, logo, home page, privacy policy, terms of service. Half-filled branding is the usual reason **brand verification** — the prerequisite for Basic access (§5) — fails. ## 4. The `adwords` scope and the authentication mandates The Google Ads API has exactly **one** scope, and it covers both reading and writing: ``` https://www.googleapis.com/auth/adwords ``` There is no read-only variant, no per-object split, and no way to request a narrower grant. Say that plainly to a customer security team that asks for least privilege — the narrowing happens through Google Ads *user roles* on the authorizing account, not through OAuth scopes. Identity scopes (`openid`, `profile`, `email`) are for sign-in only and grant no ads access. Request it with `access_type=offline` (see the base skill for the refresh-token rules). The Google Ads API does not support hybrid simultaneous sign-in, and **does not support domain-wide delegation (2LO)** — so the service-account shortcut that works for some Workspace APIs is not available here. **The authentication mandates are customer-side and blocking.** Since 21 April 2026 the Google Ads API requires 2-Step Verification for any user generating a new refresh token; from 5 August 2026 Google began requiring a **passkey** for some users, and for those users passwords, TOTP and SMS codes are disallowed outright. Separately, a manager account administrator can mandate 2SV across owned sub-accounts — in that case existing refresh tokens keep working for everything *except* the Google Ads API, which fails with `TWO_STEP_VERIFICATION_NOT_ENROLLED` until the user enrolls. Note also that a new passkey may carry a **7-day security delay** before it is trusted, so "just add a passkey" is not a same-day fix. None of this is something you can register your way around; it belongs in your customer onboarding instructions. **Product fact.** As of 2026-09-20, Unified.to's Google Ads connector requests the single `https://www.googleapis.com/auth/adwords` scope for every ads object it supports — organizations, campaigns, groups, ads, creatives, assets, targets, promoted items and reports, read and write alike — plus `openid`, `profile` and `email` for the login/identity flow. Its authorize request carries `access_type` and `prompt` so that offline refresh tokens are issued; token exchange and refresh are form-encoded POSTs sending client ID and client secret in the body, and access tokens are presented as bearer tokens. It also requires **a second, non-OAuth credential alongside the client ID and secret — a developer-token-style value that it labels "Developer Token" and sends as a request header on every API call** — so a run that produces only a client ID and secret is incomplete for this connector even though Google now ignores that header. Confirm all of this with the connector's owner before relying on it; it is a snapshot of one day's configuration, not a contract. ## 5. The access level and the API access application This section is the reason Google Ads takes days instead of an afternoon. **What a developer token was.** A 22-character alphanumeric string issued from the **API Center** page of a Google Ads manager account, sent as the `developer-token` HTTP header on every call, and carrying an access level that decided how many operations you could run per day and whether you could touch production accounts at all. **What replaced it.** Since 9 September 2026 the access level lives on the **Google Cloud project** that owns your OAuth credentials. You apply, check status and upgrade from the **Google Ads API Overview** page in the Cloud Console: `https://console.cloud.google.com/google/ads-apis/overview`. The header is now optional and ignored. If you already hold an approved token, you still want to open that page and confirm your project inherited the level you expect — the migration only covered projects that had actually made calls with the token in the preceding 90 days. **The four levels**, in order. You climb them one at a time; each application asks you to confirm the *next* level is what is offered before you click: | Level | Can call | Daily operation limit | How you get it | Review | | --- | --- | --- | --- | --- | | **Test** | Test accounts only | 15,000/day | Automatic when you enable the API | none | | **Explorer** | Test **and** production | 2,880/day production, 15,000/day test | Apply from the Overview page | usually automatic | | **Basic** | Test and production | 15,000/day both | Brand verification, then apply | automated, minutes | | **Standard** | Test and production | Unlimited | Apply from the Overview page | manual audit, ~10 business days | "Per day" is a sliding 24-hour window over the requests the project made, and all levels remain subject to per-service rate limits and quotas on top of these numbers. **Explorer is restricted, not merely small.** Even with production access, an Explorer project cannot call account creation (`CustomerService.CreateCustomerClient`), user management (`CustomerUserAccessService`, `CustomerUserAccessInvitationService`), planning (`KeywordPlanService`, `KeywordPlanIdeaService`, the other `KeywordPlan*` services, `AudienceInsightsService`, `ReachPlanService`), or billing and payments (`PaymentsAccountService`, `BillingSetupService`, `AccountBudgetProposalService`, `InvoiceService`). If your connector exposes keyword planning or invoices, Explorer will not do and you need Basic or Standard. **Brand verification** is the prerequisite for Basic, and it is a lighter-weight cousin of full OAuth app verification. Having done §3 — External user type, In production publishing status, complete Branding tab — go back to **Branding**, click **Verify Branding**, wait the few minutes it takes, fix anything it flags, then click **Publish branding**. If you already completed brand verification for this project as part of OAuth app verification, you can skip it. **The Standard application** is a manual compliance audit, not a form. Expect questions about what your tool does and who uses it, and expect to provide **demo sign-in access** if external users use it. Your tool must comply with **Required Minimum Functionality** (RMF) — a published list of features a tool must offer, scoped by whether you are a full-service tool, reporting-only, or internal-use-only. RMF applies only to projects at Standard. Standard also carries a **permissible use** designation that decides which parts of the API you may call: *Ad creation / management* (everything), *Reporting* (only `GoogleAdsService.Search` / `SearchStream` and other read-only calls), or *Researching keywords and recommendations*. Google's Compliance team runs the review over email; slow replies stall it. **Do not answer compliance, legal or volume questions on the user's behalf** — draft them, hand them over, and say so. **Test access cannot touch production, ever.** On v25 the error is `CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION`; on older versions it surfaces as `ACTION_NOT_PERMITTED`. There is no header, flag or retry that works around it. Two related traps: access is **revoked after 90 days of inactivity**, and an approved-but-never-used legacy token transferred no access to anything, so its owner is now at Test and must apply fresh. Finally: historically a project could use only **one** developer token, while one token could serve many projects. Post-sunset the practical shape is the reverse — you can hold several Cloud projects at **different** access levels at once, for example one at Basic and one at Standard, and pick per deployment tier. ## 6. Capture the credentials — all three The base skill covers the client ID and the one-time client secret, including rotation. Google Ads needs a third value and a set of Ads-specific facts recorded alongside them: **Developer token**, if the platform still requires one as a configured credential — the 22-character string from `https://ads.google.com/aw/apicenter` on the manager account. Google ignores it now, but a connector that treats it as a required field still needs a value. **If no token exists and none can be issued**, that is a finding to report, not something to invent: say so and ask whether the field can be made optional. Also record, because the run is not reproducible without them: - The **Google Cloud project ID** that owns the client, and its **current API access level** and permissible use. - The **manager account's 10-digit customer ID**, hyphens stripped — this is the `login-customer-id` value. - The API base `https://googleads.googleapis.com/v25/…` and that version's sunset date. The `v3` in some older Google token-endpoint examples is an OAuth endpoint version and has nothing to do with the Google Ads API version. - Whether this is a production or test hierarchy. ## 7. Verify end-to-end The base skill's round trip applies. These are the Ads-specific steps layered on it: 1. If the project is at **Test**, verify against a **test client account** under the test manager first. Use the production project's OAuth credentials — a Test-level project can still call test accounts. 2. Call `CustomerService.ListAccessibleCustomers` — `GET /v25/customers:listAccessibleCustomers` — to enumerate the accounts that user reaches directly. Those IDs are the valid `login-customer-id` values. 3. Run one real read against a client account under the manager, sending `login-customer-id` as the manager's CID with hyphens stripped, and `Authorization: Bearer `. Repeat it with a refreshed access token. 4. Only then repeat against a **production** account, which is also the first moment the access level is tested for real. | Symptom | Cause | | --- | --- | | `CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION` | The project owning the OAuth client is at Test access — apply for Explorer or above (§5) | | `ACTION_NOT_PERMITTED` on an older API version | Same as above, under the pre-v25 error name (§5) | | `DEVELOPER_TOKEN_NOT_APPROVED` | Legacy form of the same problem: the credential is approved for test accounts only | | `DEVELOPER_TOKEN_PROHIBITED` | The token is not allowed with the Cloud project in the request — the pairing the sunset removed | | `USER_PERMISSION_DENIED` | The authorizing user has no access to that customer, **or** you are calling a client account without setting `login-customer-id` to the manager's CID (§2, §7) | | `INVALID_LOGIN_CUSTOMER_ID_SERVING_CUSTOMER_ID_COMBINATION` | The manager named in `login-customer-id` does not reach the target account | | `TWO_STEP_VERIFICATION_NOT_ENROLLED` | The authorizing Google account has no 2SV, or an admin mandated it on the hierarchy (§4) | | `MISSING_TOS` | The Google Ads API Terms of Service have not been signed on the manager account at API Center (§2) | | `NOT_ADS_USER` | The Google account that authorized has no Google Ads account attached at all | | `CUSTOMER_NOT_ENABLED` | The target ad account is not activated or has been deactivated — customer-side | | `METRIC_ACCESS_DENIED` | Permissible use restricts the metrics queried (Standard access only) (§5) | | `AUTHORIZATION_ERROR` on production only, right after an upgrade | Known Google-side defect for projects upgraded after 9 Sept 2026 (see **Platform state**) | | Basic/Explorer application rejected despite brand verification | Cloud Free Trial or suspended billing on the project (see **Platform state**) | | Auth works, every call 404s or errors on version | The API version in the URL path has been sunset; upgrade (see **Platform state**) | | Works for one customer, fails for another with identical config | Almost always the customer's Google Ads user role, account hierarchy, or 2SV state — not the app | ## 8. Hand off Follow the base skill's handoff rules, with one addition: the **developer token is a secret on the same terms as the client secret** and never goes into source control, a ticket, a PR body or a chat channel. Close with everything recorded in §6 — project ID, access level and permissible use, developer-token status, manager CID, hierarchy type, API version and its sunset date — plus any access application still pending and what it is waiting on, and the customer-facing **2SV and passkey** requirements, which the user must put into their onboarding instructions. ## Stop and ask The base skill's stop-and-ask list applies. Additionally, hand back to a human when: a Standard access application asks for compliance, legal, volume or RMF commitments, or wants demo sign-in credentials for the product; the access level needed depends on a business decision about what the connector will offer (keyword planning and invoices push you past Explorer); a new Cloud project would be required and you would lose an existing approval; the project is blocked by the Free Trial billing defect and removing billing would affect other services; a customer cannot connect because of 2SV, passkey or Google Ads user-role requirements on their side; the platform requires a developer token field that can no longer be populated; or either console does not match the **Platform state** section above. ## References Google Ads specific. The generic Google OAuth, consent-screen and verification references are in the base skill. Every link below verified to return 200 on 2026-09-20. - Developer token sunset, migration and FAQ — https://developers.google.com/google-ads/api/docs/api-policy/developer-token - Access levels and permissible use — https://developers.google.com/google-ads/api/docs/api-policy/access-levels - Brand verification — https://developers.google.com/google-ads/api/docs/api-policy/brand-verification - Required Minimum Functionality — https://developers.google.com/google-ads/api/docs/api-policy/rmf - Google Ads API Terms and Conditions — https://developers.google.com/google-ads/api/docs/api-policy/terms - OAuth 2.0 overview for the Google Ads API — https://developers.google.com/google-ads/api/docs/oauth/overview - Multi-user authentication workflow — https://developers.google.com/google-ads/api/docs/oauth/multi-user-authentication - Set up a Cloud project for authorization — https://developers.google.com/google-ads/api/docs/oauth/cloud-project - OAuth 2.0 internals (scope, offline access, headers) — https://developers.google.com/google-ads/api/docs/oauth/internals - Security requirements (2SV and passkeys) — https://developers.google.com/google-ads/api/docs/oauth/security-requirements - Google Ads access model and `login-customer-id` — https://developers.google.com/google-ads/api/docs/oauth/access-model - Credential management — https://developers.google.com/google-ads/api/docs/oauth/credential-management - REST auth and request headers — https://developers.google.com/google-ads/api/docs/rest/auth - API call structure — https://developers.google.com/google-ads/api/docs/concepts/call-structure - Test accounts — https://developers.google.com/google-ads/api/docs/best-practices/test-accounts - Make your first API call — https://developers.google.com/google-ads/api/docs/get-started/make-first-call - API versions, deprecation and sunset dates — https://developers.google.com/google-ads/api/docs/sunset-dates - `AuthorizationError` enum — https://developers.google.com/google-ads/api/reference/rpc/v25/AuthorizationErrorEnum.AuthorizationError - `AuthenticationError` enum — https://developers.google.com/google-ads/api/reference/rpc/v25/AuthenticationErrorEnum.AuthenticationError - Google Ads API Overview page (Cloud Console) — https://console.cloud.google.com/google/ads-apis/overview - Enable the Google Ads API — https://console.cloud.google.com/flows/enableapi?apiid=googleads.googleapis.com - Create a Google Ads manager account — https://ads.google.com/home/tools/manager-accounts/ - Create a test manager account — https://ads.google.com/nav/selectaccount?sf=mt - Google Ads API Center (historical developer token lookup) — https://ads.google.com/aw/apicenter ## google-analytics-oauth-app URL: https://docs.unified.to/skills/google-analytics-oauth-app/SKILL.md # Google Analytics (GA4) OAuth2 App Registration The registration itself is unremarkable: no Analytics scope is restricted, so there is no security assessment and no permitted-application-type gate. What costs time here is everything *after* the consent screen. Analytics is **two APIs behind one scope family**, and picking the wrong member of that family gets a token that authorizes half the product. Its quota is metered **per Analytics property**, so a connector reading a hundred customers hits the ceiling a hundred separate times and no amount of project-level headroom helps. And the API answers a great many questions with a `200` that is quietly approximate — sampled, condensed into an `(other)` row, or thresholded away. This file covers those. Everything about the console is shared. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns all the console mechanics, and this file does not repeat them: - Choosing/creating the Cloud project and organization, enabling APIs, and who may administer it. - Configuring the app on the Google Auth Platform (Branding, Audience, Data Access, Clients) and creating a **Web application** client — the only type that yields a usable client secret. - Redirect-URI rules (exact matching, HTTPS-only, propagation delay) and the platform's per-data-center callback list. - The generic scope model: declaring every scope on Data Access, the non-sensitive / sensitive / restricted tiers, and the rule that the app's tier is its most sensitive scope. - The client secret being shown and downloadable **only once at creation**, add-then-disable rotation, `Testing` vs `In production` (including the seven-day refresh-token expiry), the unverified-app screen, the 100-user caps, brand and scope verification, and the `access_type=offline` / `prompt=consent` rules for getting a refresh token at all. If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Analytics-specific ones: | Input | Notes | | --- | --- | | **Reporting, configuration, or both?** | Decides which of the two APIs to enable and which scope to request (§1, §3) | | **Read-only, or does the app change property settings?** | `analytics.readonly` vs `analytics.edit` — not `analytics` for config (§1) | | **Does the app need to read or change user access?** | A separate scope family member and an alpha-only surface (§1) | | **How many customer properties, and how often will each be polled?** | Quota is per property; this is the sizing question (§5) | | **Has anyone specified Universal Analytics endpoints or `UA-` IDs?** | They are gone; the spec needs rewriting before the work starts (§2) | | **Standard properties or Analytics 360?** | Ten-times quotas and much higher sampling limits (§5, §6) | | **Will the app join Analytics data to anything else?** | Sampling, `(other)` and thresholding make joins lossy (§6) | ## Quick Start 1. Work the base skill's console steps. **Enable each Analytics API you actually call — they are separate** enablements on the same project (§3). 2. Settle the scope down to the exact member of the family, per API and per direction (§1). 3. Check that nothing in the requirements still assumes Universal Analytics endpoints, `UA-` IDs, views, or the legacy user-deletion or management APIs (§2). 4. Decide how the app discovers which properties a user can see, and store the right identifier (§4). 5. Size the polling against **per-property** quota before promising a refresh interval (§5). 6. Decide what the app does when a report comes back sampled, condensed or thresholded (§6). 7. Finish the base skill's capture, round-trip and handoff steps, adding the Analytics checks in §8. ## Analytics platform state (verified 2026-09-20 — re-verify before trusting) - **No Analytics scope appears on Google's restricted-scope list.** There is no CASA security assessment and no permitted-application-type gate for Analytics, which is the main way this product is cheaper than Drive or Gmail. The `analytics.*` scopes are not identity-only either, so expect the **sensitive**-scope path: app verification with a justification per scope and a demo video. Read each scope's tier off the console's Data Access picker rather than trusting this line, and expect `External` + `Testing` to bite exactly as the base skill describes. - **Universal Analytics sunset on 1 July 2024** and its API surface went with it. Standard UA properties stopped processing data on 1 July 2023; UA 360 properties with a current order got a one-time extension to 1 July 2024. - **Reporting and configuration are different APIs**: the Data API (`analyticsdata.googleapis.com`) and the Admin API (`analyticsadmin.googleapis.com`), enabled separately, with partly different scopes. - **Data API quota is charged against the Analytics property**, in tokens, with separate per-property and per-project-per-property ceilings, plus concurrency and server-error ceilings. - **The Admin API's stable surface is v1beta**; access bindings (user management) and user-deletion live in **v1alpha**, which Google may change without the usual stability promise. If the docs do not look like this, stop and report what you actually see. ## 1. The `analytics.*` scope family — and which API accepts which There is one family, but the two APIs accept different members. This is the single most common way to end up with a token that authorizes half the product. | Scope | Accepted by | What it does | | --- | --- | --- | | `.../auth/analytics.readonly` | **Both** Data API and Admin API | Read reports; read accounts, properties, data streams, custom dimensions and metrics | | `.../auth/analytics` | **Data API only** | "View and manage" — but the Data API is a reporting API, so in practice this is `analytics.readonly` with a scarier consent string | | `.../auth/analytics.edit` | **Admin API only** | Create/patch/delete properties, data streams, custom dimensions, custom metrics, key events; provision accounts; submit user deletions | | `.../auth/analytics.manage.users` | Admin API **v1alpha** only | Create, update and delete access bindings — who can see the property | | `.../auth/analytics.manage.users.readonly` | Admin API **v1alpha** only | Read access bindings without being able to change them | Four traps live in that table: - **`analytics` is not the superset it reads like.** The Admin API's own method reference lists `analytics.readonly` and `analytics.edit` as the acceptable scopes; the broad `analytics` scope is *not* among them. An app that requests `analytics` expecting "everything" gets a token that reads reports fine and cannot write a single configuration object. If you want configuration writes, the string you need is `analytics.edit`. - **`analytics.edit` is a write scope that also reads.** Every Admin API read method accepts either `analytics.readonly` or `analytics.edit`, so a config-writing app does not need both. Requesting both anyway widens the consent screen and the verification review for nothing. - **User management is a third axis.** Neither `analytics.edit` nor `analytics` grants it: changing who can see a property needs `analytics.manage.users`, and even reading the access list needs `analytics.manage.users.readonly`. Both are **v1alpha**-only surfaces. Requesting them raises obvious questions in a verification review — a connector that only reports on analytics data has no business asking for them. - **The Data API is read-only in practice.** There is no scope that lets an app write *hits* through these APIs. Data goes in through the Measurement Protocol with its own per-stream secret, which is not OAuth and not covered here. ## 2. Universal Analytics: what died, what survives, what to do with a UA-shaped request **Universal Analytics sunset on 1 July 2024** and this is not a deprecation with a grace period — the properties were queued for deletion and the data is gone. If a ticket, a customer spec or an older runbook tells you to call the Analytics Reporting API v4, the Analytics Management API v3 or the User Deletion API v3, the work cannot be done as written and no OAuth scope changes that. The timeline, because people quote the wrong date: | Date | What happened | | --- | --- | | March 2023 | Google began auto-creating GA4 properties for un-migrated standard UA properties | | **1 July 2023** | Standard UA properties **stopped processing hits** | | **1 July 2024** | **Full shutdown.** Interface *and API* access to current and historical UA data ended for most users; properties queued for deletion; BigQuery backfills could only be *requested* up to 30 June 2024 | **What survives, and where it hides.** The family did not shrink as cleanly as the product did, which is why this is worth reading rather than assuming: - **The scope strings survive, listed under the legacy API.** Google's OAuth scopes page still lists `analytics.manage.users`, `analytics.manage.users.readonly`, `analytics.provision` and `analytics.user.deletion` under "Google Analytics API, v3" — the dead one. Two of those four were re-homed onto the Admin API's v1alpha access bindings (above); the other two were **not**. Account provisioning and user deletion both moved to the Admin API and both now take **`analytics.edit`**, so `analytics.provision` and `analytics.user.deletion` are orphans: still spelled out on a live Google page, no longer the scope for the job. Requesting one buys a wider consent screen and a harder verification conversation in exchange for nothing. - **The User Deletion API v3 is gone**, replaced by a user-deletion method on the Admin API's v1alpha property surface — which is why a privacy/GDPR requirement written before 2024 will name an endpoint that no longer exists. - **Legacy developer docs answer `200` with a tombstone.** The old UA devguide URLs still resolve, with an HTTP 200 and a page body that says the documentation is for Universal Analytics and it sunset on 1 July 2024. A link checker passes them; a reader following them gets nothing. Do not treat "the docs page loads" as evidence the API exists. - **Live GA4 docs still point at the dead API.** The Data API's own property-identifier page tells anyone holding a `UA-` tracking ID to use the Reporting API v4 instead. That advice has been wrong since July 2024. Google's current pages are not uniformly current, and this is the clearest example. Practically: a `UA-123456-1`-shaped identifier in a requirement is a signal to stop and go back to whoever wrote it. There is no GA4 property ID derivable from it and no archive to read. ## 3. Two APIs, two enablements The base skill's rule — enable the APIs the scopes belong to — needs saying concretely here, because "Google Analytics" is not one API and the scopes do not tell you which one you missed. | API | Host | What it is for | | --- | --- | --- | | **Data API v1** | `analyticsdata.googleapis.com` | Reporting. Standard and pivot reports, batch reports, realtime, funnels, metadata, compatibility checks, audience exports | | **Admin API v1** | `analyticsadmin.googleapis.com` | Configuration. Accounts, account summaries, properties, data streams, custom dimensions, custom metrics, key events, data-retention settings, access reports, change history | Enable each one you call, on the same Cloud project, from the console's API library or the Enable button on the corresponding quickstart page. A correct client with correct scopes against a project where only one is enabled authorizes cleanly and then fails on the first call to the other — with `accessNotConfigured`, not with anything that mentions scopes. An app that lists properties *and* reports on them needs both, always. One client, one consent screen and one declared scope list serve both APIs. The split is about enablement and about which scope each API accepts (§1), not about registering twice. ## 4. Property IDs, measurement IDs, stream IDs — and discovering what a user can see Three identifiers, all called "the Analytics ID" by somebody, and only one of them works in an API path. | Identifier | Looks like | What it is | Use in the APIs | | --- | --- | --- | --- | | **Property ID** | `123456789` (numeric) | The GA4 property — the reporting unit | **Yes.** Every Data API call is `properties/:`; every Admin API property call hangs off the same name | | **Measurement ID** (Google tag ID) | `G-1A2BCD345E` | Identifies a *web data stream* to the tag on the page | **No.** Output-only on the data-stream resource. Not a report target, not convertible to a property ID | | **Stream ID** | numeric, per data stream | The data stream within a property | Only as a **report dimension** and in data-stream config calls — never as the report target | | **Firebase app ID** | per app | The linked Firebase app | Only a cross-reference; it can change if the app is deleted and recreated | A property has many streams, so a measurement ID identifies at most one stream of one property and cannot address the property at all. Customers hand over `G-…` constantly because it is the one they have seen; treat it as a prompt to go find the numeric property ID, not as input. **How a caller discovers which properties a user can see: account summaries.** This is the one call that answers "what does this token have access to" in a single sweep, and it is the right first call after connecting an account: - `GET https://analyticsadmin.googleapis.com/v1beta/accountSummaries` returns every account the caller can reach, each with summaries of its child properties — property resource name, display name and parent. - It takes `pageSize` (default 50, **maximum 200**, higher values are silently coerced down) and `pageToken`. Paginate; do not assume one page. - It accepts `analytics.readonly` or `analytics.edit`, so read-only connectors can call it. **The obvious alternative does not work.** The Admin API's property-listing method takes a **required** filter naming a parent account, ancestor account or linked Firebase project. There is no "list every property I can see" form of it. Without an account list first, there is nothing to filter by — which is precisely why account summaries exists and why a connector that skips it ends up asking customers to type property IDs by hand. Store the **numeric property ID** as the connection's key, alongside the account it belongs to. Display names change, measurement IDs belong to streams, and a property can be moved between accounts. ## 5. Quota is per property — so a multi-tenant reader pays per customer This is the Analytics constraint most likely to break a connector in production, and it is invisible in a single-tenant test. **Data API quota is charged in *tokens* against the Analytics property**, not against the Cloud project. Requests fall into categories — Core (standard, pivot, batch, access reports, metadata, compatibility, audience exports), Realtime, Funnel and Chat — each with its own bucket. Per-property Core limits, verified 2026-09-20: | Quota | Standard property | Analytics 360 property | | --- | --- | --- | | Core tokens per property per **day** | 200,000 | 2,000,000 | | Core tokens per property per **hour** | 40,000 | 400,000 | | Core tokens **per project** per property per hour | 14,000 | 140,000 | | Core **concurrent requests** per property | 10 | 50 | | Core **server errors** per project per property per hour | 10 | 50 | Realtime and Funnel carry the same numbers in their own buckets. Daily quotas reset at midnight Pacific; hourly ones refresh within the hour but not on the hour boundary. What follows from "per property", and what people get wrong: - **Headroom does not pool across customers.** A hundred connected properties do not share one allowance; each one has its own, and each one can be exhausted on its own. Conversely, one customer running a heavy backfill cannot starve another — the isolation cuts both ways. - **The per-project-per-property ceiling is the one that binds you.** At 14,000 of the property's 40,000 hourly tokens, a single calling project can only ever use about **35%** of a standard property's hourly allowance. Google states the arithmetic plainly: a property must be read by **more than three projects** before the property-wide hourly quota can be exhausted before the per-project one. For a connector, the practical hourly budget per customer is 14,000 tokens, not 40,000 — size the polling interval against that number. - **Ten concurrent requests per property.** Fanning out a customer's report set in parallel is the easiest way to trip this, and it is per property, so per-customer concurrency control is what is needed, not a global worker pool. - **Server errors have their own quota, and exhausting it blocks you.** Ten 500/503 responses per project per property per hour and *all* requests from that project to that property are refused. A tight retry loop against a flapping property converts a transient fault into an hour-long outage of your own making. Exponential backoff is not optional. - **Token cost is not knowable in advance.** It rises with rows requested, number of dimensions and metrics, filter complexity, date-range length, dimension cardinality and the property's event volume — so the same query costs more against a big customer. The only honest way to know is to send `"returnPropertyQuota": true` and read the quota object off the response. Do that from the first day and log it per property; it is the difference between diagnosing a quota problem and guessing at one. - **The non-Data APIs are metered differently.** The Admin API allows 1,200 requests per minute (600 per user) and 600 writes per minute (180 per user), plus a shared Analytics-wide ceiling of 50,000 requests per project per day and 10 QPS per IP for everything except the Data API. Property discovery and reporting therefore exhaust on completely different axes. - Exhaustion surfaces as **`429 RESOURCE_EXHAUSTED`** (sometimes `403`), which is not an authorization problem. Do not respond to it by re-authorizing the customer. - Separately, a property allows **120 potentially-thresholded requests per hour** — see §6. ## 6. Sampling, cardinality and thresholding — a `200` that is not the whole answer Three independent mechanisms make a GA4 report approximate or incomplete, none of which changes the status code. A connector that ignores all three will produce numbers that do not reconcile with the customer's own UI, and that is what the support ticket will say. **Sampling.** When a query needs more events than the property's limit, Analytics reads a subset and scales up. The event-level query limit is **10 million events for standard properties** and up to **1 billion for Analytics 360** (360 defaults to 100 million per query for speed, with the higher limit available on request). The response's metadata carries sampling information **per date range** when — and only when — the result was sampled; the field is absent otherwise. So the presence of that block is the signal, and the same query can be sampled for one customer and exact for another purely because of traffic volume. **Cardinality and the `(other)` row.** When a table needs more rows than its limit, Analytics keeps the most common dimension values and rolls the rest into a single `(other)` row. Google's guidance: treat **any dimension with more than 500 distinct values as high-cardinality** — page path and most custom dimensions qualify immediately. Adding secondary dimensions, filters or comparisons multiplies the row count and makes it far likelier. The response metadata carries a data-loss flag, and it is populated from the underlying aggregate table **regardless of the filters and limits in your request** — so it can be true even when no `(other)` row appears in the rows you got back. Treat the flag, not the visible rows, as the truth. **Thresholding.** Reports touching demographics, interests, audience membership or search queries withhold rows below a minimum aggregation threshold, to stop anyone inferring an individual. It is system-defined and cannot be turned off. The response metadata flags that a report was *subject* to thresholding — which is not the same as data having been withheld, since everything may have been above the threshold. Widening the date range is the usual mitigation. And recall the per-property cap of **120 potentially-thresholded requests per hour**, charged on the dimensions that trigger it. **A fourth, non-statistical one: data restrictions.** Analytics roles carry optional *No Cost Metrics* and *No Revenue Metrics* restrictions. A user with them authenticates perfectly, reads the report, and gets it back with those metrics stripped and a schema-restriction block in the metadata explaining why. It looks exactly like a scope problem and is not one — it is that user's role. The design consequence: **read the response metadata on every report and carry it forward**. If the app caches, aggregates, or presents these numbers as authoritative, sampling and `(other)` must be surfaced to whoever reads them, because two runs of the same query can legitimately disagree. ## 7. Organization, account and Workspace-level restrictions The base skill covers the Workspace admin's OAuth API controls. Analytics adds three more places a connection can be blocked or narrowed, none of which is fixed by re-registering the client: - **Analytics is an *additional* Google service, not a core Workspace one.** A Workspace administrator can turn it off for an organizational unit, and a user in that OU then cannot use Analytics at all — the consent flow is not where this surfaces helpfully. When one company's users can connect and another's cannot, ask their admin about additional services before touching anything on your side. - **Analytics has its own permission hierarchy, independent of the Google account.** Roles (Administrator, Editor, Analyst, Viewer and a no-access state) are assigned at organization, account and property level, directly or through Google Groups, and the *effective* permission is the inherited set plus the direct one. OAuth grants your app the user's access — never more. An `analytics.edit` token held by a user who is only an Analyst on a property still cannot write to it, and the failure is `403 PERMISSION_DENIED` at call time, not at consent. - **Data restrictions are assigned alongside roles** (§6) and silently narrow what a perfectly scoped token returns. None of this is visible from the token, so a connector should verify the account's actual reach with an account-summary call (§4) immediately after connecting, rather than discovering it on the first report. ## Product fact — what the Analytics connectors ask for > **As of 2026-09-20, Unified.to ships *two* separate Google Analytics connectors**, against the two APIs in §3: > > **The reporting connector** (Google Analytics, added mid-2026) targets the **Data API** host and covers property > information, events and reports. It requests: > > - **Login/identity only:** `openid`, `profile`, `email` > - **Read:** `https://www.googleapis.com/auth/analytics.readonly` > - **Write:** `https://www.googleapis.com/auth/analytics.edit` > > **The admin connector** (Google Analytics (Admin), added mid-2025) targets the **Admin API** host. It requests: > > - **Login/identity only:** `openid`, `profile`, `email` > - **Read:** `https://www.googleapis.com/auth/analytics.readonly` > - **Write:** `https://www.googleapis.com/auth/analytics` > > Four things to take from that, all dated rather than live: > > 1. **Both connectors point at the same Google OAuth endpoints and the same registration guidance**, so a single Cloud > project and a single Web application client can serve both. **Both Analytics APIs must be enabled on it** (§3). > 2. **The admin connector's write scope looks wrong against Google's current reference.** Admin API write methods > document `analytics.edit` as the required scope, and `analytics` is not listed as acceptable for Admin API > methods at all. If configuration writes through that connector fail with a permission error on a token that > consented cleanly, this is the first thing to check — and it is a connector-side question, not something a > different client registration fixes. > 3. **Neither connector requests `analytics.manage.users*`**, so user-access management is out of scope for both as > shipped — which is the right default, and one less thing to justify in a verification review. > 4. Both are configured to send `access_type` and `prompt` on the authorize request, which is what makes the base > skill's offline/refresh-token rules work; confirm the deployed values rather than assuming. > > Scope sets change. This note is dated, not live; **confirm with the connector's owner before submitting anything**, > and in particular confirm which of the two connectors a given customer is being onboarded to. ## 8. Analytics-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Call **account summaries** first and confirm it returns at least one account with at least one property. An empty list is a real and common outcome: the Google account authenticated fine and simply has no Analytics access. 2. Run one real report against a numeric property ID from that list, with **`"returnPropertyQuota": true`**, and log the token cost. Do this against the biggest property you can reach, not a test property with no traffic. 3. Inspect the response **metadata** on that report for sampling, the data-loss/`(other)` flag, thresholding and any schema restriction — before concluding the numbers are right (§6). 4. If the app writes configuration, make one real Admin API write. This is the check that catches an `analytics` scope where `analytics.edit` was needed (§1), and it fails at call time, never at consent. 5. Test with a **non-administrator** Analytics user — an Analyst, ideally one carrying a data restriction. Testing only as the account owner proves nothing about what customers will see. 6. Confirm both APIs are enabled by calling **both** in the same run. 7. Confirm the granted `scope` in the token response matches what was requested; granular consent lets a user drop one. | Symptom | Cause | | --- | --- | | Reports work, configuration writes get `403` | `analytics` requested where the Admin API wants `analytics.edit` (§1) | | `403 accessNotConfigured` on one API only | That API not enabled on the project — they are separate enablements (§3) | | `403 PERMISSION_DENIED` on a property, token looks perfect | The Analytics user's own role does not reach that property (§7) | | Cost or revenue metrics missing, everything else fine | A data restriction on that user's role, not a scope problem (§6) | | One customer's whole org cannot connect | Analytics turned off for their OU as an additional Google service (§7) | | `429 RESOURCE_EXHAUSTED` on a busy customer while others are fine | Per-property token quota, likely the per-project-per-property hourly ceiling (§5) | | Every request to one property refused for an hour | Server-error quota exhausted by retrying into a failing property (§5) | | Numbers disagree with the customer's Analytics UI | Sampling, `(other)` condensation, or thresholding — check response metadata (§6) | | The same query is exact for one customer and sampled for another | Event volume against the per-query sampling limit (§6) | | A caller supplies `G-…` and nothing accepts it | Measurement ID identifies a stream, not a property (§4) | | A requirement names Reporting API v4 or Management API v3 | Universal Analytics; sunset 1 July 2024 (§2) | | A legacy docs URL loads fine but the API 404s | UA devguide tombstone pages return HTTP 200 (§2) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - A requirement depends on **Universal Analytics data or endpoints** (§2). That is a scope change for the project, not a console setting, and the historical data no longer exists. - Someone wants **`analytics.manage.users`** or its read-only twin. Changing who can see a customer's Analytics property is a product decision with an obvious verification conversation attached, and it is an **alpha** surface. - A polling interval or customer count has been promised without anyone checking it against **per-property** quota (§5) — the number that binds is the per-project-per-property hourly one, and it is about a third of the headline. - The app will present GA4 numbers as authoritative (billing, SLAs, reconciliation) without handling **sampling, `(other)` and thresholding** (§6). - The deployed connector's scope set does not match the **Product fact** section — confirm with its owner rather than registering around it. - The scope-to-API mapping in §1 does not match Google's current method reference. ## References Analytics-specific only; the base skill carries the generic Google OAuth references. Every URL verified to return 200 on 2026-09-20. Legacy Universal Analytics devguide URLs are deliberately **not** listed: they return 200 with a sunset notice rather than documentation (§2). - Data API v1 overview — https://developers.google.com/analytics/devguides/reporting/data/v1 - Data API limits and quotas (token model, per-property ceilings, `returnPropertyQuota`) — https://developers.google.com/analytics/devguides/reporting/data/v1/quotas - Data API property ID (and its stale UA advice) — https://developers.google.com/analytics/devguides/reporting/data/v1/property-id - Data API response metadata (sampling, `(other)`, thresholding, schema restrictions) — https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/ResponseMetaData - Data API `runReport` (authorization scopes) — https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/runReport - Data API error responses — https://developers.google.com/analytics/devguides/reporting/data/v1/errors - Data API quickstart / enable the Data API — https://developers.google.com/analytics/devguides/reporting/data/v1/quickstart-client-libraries - Admin API REST reference — https://developers.google.com/analytics/devguides/config/admin/v1/rest - Admin API `accountSummaries.list` (property discovery, page size 200) — https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1beta/accountSummaries/list - Admin API `properties.list` (the required filter) — https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1beta/properties/list - Admin API data streams (measurement ID is output-only) — https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1beta/properties.dataStreams - Admin API `properties.create` (`analytics.edit` required) — https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1beta/properties/create - Admin API access bindings, v1alpha (`analytics.manage.users`) — https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1alpha/accounts.accessBindings/list - Admin API user deletion, v1alpha (replaces the sunset User Deletion API) — https://developers.google.com/analytics/devguides/config/admin/v1/rest/v1alpha/properties/submitUserDeletion - Admin API limits and quotas — https://developers.google.com/analytics/devguides/config/admin/v1/quotas - Admin API quickstart / enable the Admin API — https://developers.google.com/analytics/devguides/config/admin/v1/quickstart-client-libraries - Shared Google Analytics API limits and quotas — https://developers.google.com/analytics/devguides/limits-and-quotas - User Deletion API v3 migration notice — https://developers.google.com/analytics/devguides/config/userdeletion/v3 - GA4 has replaced Universal Analytics (sunset timeline) — https://support.google.com/analytics/answer/11583528 - About data sampling (10M / 1B event limits) — https://support.google.com/analytics/answer/13331292 - About the `(other)` row (cardinality guidance) — https://support.google.com/analytics/answer/13208658 - About data thresholds — https://support.google.com/analytics/answer/9383630 - Access and data-restriction management (roles, No Cost / No Revenue Metrics) — https://support.google.com/analytics/answer/10851388 - Add, edit and delete Analytics users and user groups — https://support.google.com/analytics/answer/9305788 - Stream ID — https://support.google.com/analytics/answer/12332343 - Find your Google tag ID (the `G-` measurement ID) — https://support.google.com/analytics/answer/9539598 - Turn additional Google services on or off (Analytics is one) — https://support.google.com/a/answer/181865 ## google-business-profile-oauth-app URL: https://docs.unified.to/skills/google-business-profile-oauth-app/SKILL.md # Google Business Profile — API access and OAuth specifics What the **Business Profile APIs** (formerly Google My Business) add on top of an ordinary Google OAuth registration. The expensive part of this vendor is not the OAuth client. It is that **the Business Profile APIs do not exist for your project until a human at Google approves an access request form** (§1). Before approval your quota is **0 QPM**, the legacy Google My Business API is not even *visible* in the Cloud console API Library, and every call fails on quota — which looks exactly like a rate-limit bug and gets debugged as one for days. Reviews take up to **14 days**, and the form has eligibility bars (a Business Profile verified and active 60+ days, a real website) that must be true *before* you submit. Two more things cost a full cycle each: enabling only some of the required APIs (§2), and filing a *quota increase* when what you actually need is *access* — Google's docs explicitly tell you not to do that (§5). ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first.** It is the base skill and owns every mechanic this file does not repeat. Without it you will be missing: - **Project and Auth Platform setup** — creating the project, organization placement, Branding and Audience pages, console permissions, and the no-browser-automation click-path protocol. - **The OAuth client** — client types, why **Web application** is the only usable one, why a service account is not a substitute for user consent, and reuse-vs-new-client (a new client ID re-authorizes every customer). - **Redirect URIs** — the Unified.to callback list, exact-match / HTTPS / no-wildcard rules, `redirect_uri_mismatch`, propagation delay. - **Scopes and refresh tokens** — declaring every scope on **Data Access**, the tiers, `access_type=offline` and `prompt=consent`, the 100-tokens-per-account cap, six-month idle expiry. - **The one-time client secret and rotation** — stored hashed, shown in full **only at creation**; rotation is add-then-disable, never replace. - **Testing vs In production, verification and hand-off** — the 7-day grant expiry in `Testing`, the user caps, brand/sensitive/restricted review, CASA **AL1/AL2**, and never committing the secret. Everything below is only what Business Profile adds. ## Inputs to collect before you start Ask in one batch, alongside the base skill's inputs. Never invent. | Input | Notes | | --- | --- | | **Google account** that will own the project | Must be an owner or manager on the Business Profile cited in the request (§1) | | **Existing Cloud project, or a new one** | Approval binds to a **project number**; a new project restarts the wait (§1) | | **The Business Profile to cite in the request** | Verified and active 60+ days, with a website (§1) | | **Company name, contact email, business website** | The access form asks for these (§1) | | **Which Business Profile data you need** | Decides which APIs to enable (§2); the scope does not change (§3) | | **Expected request volume** | Feeds the quota conversation (§5) | ## Quick Start 1. Pick the Cloud project and note its **project number** — a *new* project restarts the 14-day review (§1). 2. **Submit the access request and wait.** Nothing else works until it lands; read the quota to check status (§1). 3. Enable the seven Business Profile APIs, plus the Performance API if you need metrics (§2). 4. Create the client, redirect URIs, branding and audience — **all base skill**. 5. Declare and request the single `business.manage` scope (§3); confirm the authorizing user's role (§4). 6. Verify a real authorize → callback → refresh → read round trip against a live quota (§6). ## Platform state (verified 2026-09-20 — re-verify before trusting) Business Profile only; the base skill carries the console's own state. - **The Business Profile APIs are not open to the public.** Google: "GBP APIs aren't open to the public. Users have to request access." Approval is per **Cloud project**, granted to a Google Account that manages a qualifying Business Profile. - **There is no sandbox.** Google: "There's no Sandbox environment for the Business Profile APIs." Non-mutating dry runs use the `validateOnly` request parameter instead. Fake or test listings are not permitted in production. - **v4.9 is deprecated but not replaced for everything.** `localPosts`, `reviews` and `media` still live on the v4 host; the v1 APIs (Account Management, Business Information, Performance, Verifications, Place Actions, Notifications, Lodging) cover the rest. Re-check the deprecation schedule before you build on a v4 endpoint — this family has a real sunset history (Q&A API discontinued 2025-11-03; Business Calls API and `locations.associate` / `locations.clearLocationAssociation` discontinued 2023-05-30; `HealthProviderAttributes` and `InsuranceNetworks` discontinued 2024-07-01; v4 `reportInsights` replaced by the Performance API). ## 1. Request API access — the gate that blocks everything else **Do this first, and expect to wait.** No amount of correct OAuth configuration substitutes for it. **The approval attaches to a Cloud project number, not to you.** So a "separate app in a different project" is not a console decision, it is another 14-day wait at 0 QPM. Reuse the approved project unless the user explicitly accepts restarting the review. **Eligibility (must be true before you submit):** - You **manage a Google Business Profile that is verified and active for 60+ days**. It may be your own office or headquarters, or one belonging to a client you manage. - That business **has a website**, listed on the profile. Google wants the profile complete and current, official website included — an incomplete profile is a common rejection reason. **Submitting:** 1. Copy the **project number** from the Dashboard's Project info card — not the project *ID*; the forms want the number. 2. Open the GBP API contact form: `https://support.google.com/business/contact/api_default`. 3. From the drop-down, choose **Application for Basic API Access** — *not* Quota Increase Request (§5). 4. Fill in company name, contact email, business website and the **project number**. 5. Submit from an email address that is listed as an **owner or manager** on that Business Profile. A request sent from an unrelated account is the most common silent rejection. **Turnaround:** reviewed **within 14 days**, with a follow-up email. To check status without waiting for it, read the Cloud console quota for the Business Profile APIs on that project — this is the documented way: | Quota shows | Means | | --- | --- | | **0 QPM** | Not approved yet (or rejected) | | **300 QPM** | Approved | **What calling the API before approval looks like.** Not "access denied," not a `403` — a rate limit: HTTP **429 Too Many Requests** (`RESOURCE_EXHAUSTED` / `RATE_LIMIT_EXCEEDED`), naming the metric `Requests`, the limit `Requests per minute`, the service (e.g. `mybusinessaccountmanagement.googleapis.com`) and `consumer 'project_number:...'`. A retry/backoff layer will chew on that forever. **Before debugging a 429 here, read the project's quota number.** If it is 0, the project is not approved and no code change will help. If rejected, re-check the eligibility bars above before re-applying — Google's guidance is to fix the requirements and re-apply, not to escalate. ## 2. Enable the APIs After approval, enable, in the Cloud console API Library for that project: 1. **Google My Business API** (v4.9 — the legacy host; still carries `localPosts`, `reviews`, `media`) 2. **My Business Account Management API** (`mybusinessaccountmanagement.googleapis.com`) 3. **My Business Business Information API** (`mybusinessbusinessinformation.googleapis.com`) 4. **My Business Lodging API** 5. **My Business Place Actions API** 6. **My Business Notifications API** 7. **My Business Verifications API** Notes that bite: - **Google My Business API will not appear in the Library until your account is approved.** If search does not find it, you are still at §1. - **The Performance API is not in that canonical list of seven.** If you need insights/metrics, also enable **Business Profile Performance API** (`businessprofileperformance.googleapis.com`). Confirm its presence in the console rather than assuming — the setup doc and the API reference do not agree on this, and the console is the authority. - Each API has its **own** quota counter. Approval grants the standard quota "for all seven APIs", but one API can still be the one you saturate — usually Account Management, since every connection lists accounts on setup. - You may be prompted to enable billing and to accept per-API terms. ## 3. The single `business.manage` scope The Business Profile APIs use **one** scope for everything: ``` https://www.googleapis.com/auth/business.manage ``` It covers read **and** write across accounts, locations, posts, reviews, media and performance — there is no read-only variant and no per-object narrowing. Add identity scopes only if you also need to know who authorized: `openid`, `profile`, `email`. Two consequences worth stating to the user up front: 1. **You cannot offer a "read-only" Business Profile connection at the scope level.** Least privilege has to come from your own code and from the role the authorizing user holds on the profile (§4). 2. **A single broad read-and-write scope is what verification scrutinizes.** Read the tier label the console puts on `business.manage` in *your* project rather than assuming, and have the justification ready: which endpoints you call, and that nothing narrower is published. There is no API-key path for this family — every request touching private user data needs an OAuth 2.0 token. **Product fact — as of 2026-09-20, Unified.to's Google Business Profile connector requests `openid`, `profile`, `email` and `https://www.googleapis.com/auth/business.manage` for every supported operation** (posts read and write, profiles read, reviews read and write, insights read) — one scope set, identical across objects. It spans the v4 host plus the Account Management, Business Information and Performance v1 hosts, so all four must be enabled on whichever project's client ID is in play. It also supports a Google **service-account** token path as an alternative to the user OAuth flow, and **ships with a shared, platform-owned Google OAuth client by default** — so unless a customer supplies their own client, calls hit the *platform's* project quota and a quota increase on the customer's own project changes nothing (§5). Confirm this with the connector's owner; connector configuration changes without notice. ## 4. Who is allowed to authorize Scope is not the limit here — the authorizing person's role on the profile is. - The account must be an **owner or manager** of the Business Profile. Managers have nearly the same access as owners but cannot add or remove users. The authorizing end user's role caps what their token can do, and **no scope raises it**. A `403` affecting exactly one customer is usually this. - Organizations / agency accounts are created from the Business Profile help center, not from Cloud. - **Google Workspace accounts** — Business Profile must be *turned on* for the account in the Workspace organization. If it is off, the APIs return **`403 PERMISSION_DENIED`** no matter how correct the project, scope and client are. This is distinct from the base skill's Workspace admin API-controls case, and the fix is a different admin toggle. ## 5. Quota — and the request that is *not* the access request Once approved, the documented standard limits are: | Limit | Value | | --- | --- | | Default requests | **300 QPM** per API | | Create Location | 300 QPD | | SearchGoogleLocation | 300 QPD | | Update Location | 10,000 QPD | | Edits | **10 per minute per Business Profile — cannot be increased** | Exceeding a quota returns **429 Too Many Requests** (`RESOURCE_EXHAUSTED` for gRPC) — the same shape as the pre-approval failure in §1, which is why the quota number is the first thing to read. **If your quota reads 0, do not file a quota increase.** Google says so explicitly: quota 0 means access was never granted, and the fix is the access request form in §1. The two requests go through the *same* contact form under different drop-down options, so picking the wrong one is easy — and it costs another full review cycle, because the quota team will simply tell you to go and apply for access. To request a genuine increase, use that contact form with **Quota Increase Request** selected, and supply company name, contact email and project number. Increases are typically **denied** when: the app does not consistently reach its current QPM, average usage is under **50%** of the current limit, or traffic is **spiky** rather than smoothly distributed. That last one is a design instruction — smooth and cache before you ask. Quota is **per project**. On a multi-tenant platform using one shared OAuth client, every customer's calls land on the *platform's* counter, and Account Management (hit by every connection to list accounts) saturates first. Levers in order: drop redundant account-listing calls, cache account and location resolution, back off honoring `retryDelay` / `Retry-After`, and only then ask for more. A customer needing more headroom than the shared project can give needs their own approved project and client — meaning they go through §1 themselves, 14-day wait included. ## 6. Verify end-to-end On top of the base skill's round-trip checks: 1. Confirm the quota reads **300 QPM**, not 0, before concluding anything about a failed call. 2. Authorize from a Google account that manages a **different** Business Profile than the one you cited in the access request — approval is per project, not per profile, and this proves it. 3. Make a read call — listing accounts on the Account Management host is the natural smoke test. 4. Exercise a mutating path with **`validateOnly`** rather than looking for a sandbox; there isn't one, and test listings are not permitted in production. 5. If the app is still in `Testing`, re-test after **8 days** or publish first — the base skill's 7-day grant expiry presents here as a Business Profile connector with a mysterious weekly outage. | Symptom | Cause | | --- | --- | | Google My Business API missing from the API Library | Project/account not approved — §1, not a console bug | | 429 `RATE_LIMIT_EXCEEDED` on the very first call, quota shows 0 QPM | Not approved. Submit the access form; do **not** file a quota increase (§5) | | 429 later, quota shows 300 QPM | Real quota exhaustion — usually shared-project Account Management calls (§5) | | `403 PERMISSION_DENIED` on a Workspace account | Business Profile turned off for that account in the Workspace org (§4) | | `403` on one customer only | The authorizing user is not an owner/manager of that profile (§4) | | Edits throttle at 10/minute on one profile and never improve | Per-profile edit limit; cannot be raised (§5) | | A previously working endpoint 404s | v4 endpoint sunset — check the deprecation schedule (References) | **Add to the base skill's hand-off:** the Cloud project **number**, the access-approval state with the quota reading that proves it, which APIs are enabled (including whether the Performance API is on), and the date of anything still under review. ## Stop and ask Beyond the base skill's list, hand back to a human when: the access request is pending or rejected (there is nothing to configure around it — report the state and the date); the eligibility bars are not met and someone suggests citing a profile they do not manage; the form asks for company, volume, compliance or business-justification claims you cannot source from the user; the fix requires a customer to create and get approval for their *own* Cloud project; a change would move the integration to a different Cloud project and restart the 14-day review; or a needed endpoint exists only on the deprecated v4 host and the sunset schedule is unclear. ## References Business Profile only — the base skill carries the generic Google OAuth references. Every URL verified to resolve on 2026-09-20. - Business Profile APIs overview — https://developers.google.com/my-business/content/overview - Prerequisites and access request — https://developers.google.com/my-business/content/prereqs - Basic setup (enable APIs, OAuth client, scope) — https://developers.google.com/my-business/content/basic-setup - Quota limits — https://developers.google.com/my-business/content/limits - FAQ (14-day review, seven APIs, quota, no sandbox) — https://developers.google.com/my-business/content/faq - API policies — https://developers.google.com/my-business/content/policies - Deprecation schedule — https://developers.google.com/my-business/content/sunset-dates - Change log — https://developers.google.com/my-business/content/change-log - Latest updates — https://developers.google.com/my-business/content/latest-updates - API reference overview — https://developers.google.com/my-business/ref_overview - Google My Business API v4.9 reference (legacy) — https://developers.google.com/my-business/reference/rest - `accounts.locations.localPosts` (v4) — https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts - `accounts.locations.reviews` (v4) — https://developers.google.com/my-business/reference/rest/v4/accounts.locations.reviews - `accounts.locations.media` (v4) — https://developers.google.com/my-business/reference/rest/v4/accounts.locations.media - Account Management API — https://developers.google.com/my-business/reference/accountmanagement/rest - Business Information API — https://developers.google.com/my-business/reference/businessinformation/rest - Performance API — https://developers.google.com/my-business/reference/performance/rest - GBP API contact form (access *and* quota requests) — https://support.google.com/business/contact/api_default - Verify your business on Google — https://support.google.com/business/answer/7107242 - Add or claim your Business Profile — https://support.google.com/business/answer/2911778 - Business Profile owners and managers — https://support.google.com/business/answer/3403100 - Manage agency organizations — https://support.google.com/business/answer/7663063 ## google-calendar-oauth-app URL: https://docs.unified.to/skills/google-calendar-oauth-app/SKILL.md # Google Calendar OAuth2 App Registration Calendar is the cheap one. Every Google Calendar scope is **sensitive, not restricted** — so an external app needs brand and scope verification, but **no CASA security assessment and no annual re-assessment**. That single fact is the most useful thing to know when planning a Calendar integration, because it is what separates a roughly week-long review from the funded external engagement a Gmail or whole-Drive app signs up for. What Calendar spends that saving on is *reach*. A single `calendar` or `calendar.readonly` grant exposes every calendar in the user's list — their own, their colleagues', the team rooms, anything shared with them — at whatever access level the calendar's ACL gives them. Getting the scope narrow enough that the review is easy to justify, and knowing what the token can and cannot see once granted, is what this file is for. Everything else is shared. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns all the console mechanics, and this file does not repeat them: - Choosing/creating the Cloud project and organization, enabling APIs, and who may administer it. - Configuring the app on the Google Auth Platform (Branding, Audience, Data Access, Clients) and creating a **Web application** client — the only type that yields a usable client secret. - Redirect-URI rules (exact matching, HTTPS-only, propagation delay) and the platform's per-data-center callback list. - The generic scope model: declaring every scope on Data Access, the non-sensitive / sensitive / restricted tiers, the rule that the app's tier is its most sensitive scope, and `access_type=offline` + `prompt=consent` as the only way to get a refresh token back. - The client secret being shown and downloadable **only once at creation**, add-then-disable rotation, `Testing` vs `In production` and the seven-day refresh-token expiry, the unverified-app screen, the 100-user caps, and the Workspace admin API controls in their generic form. If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Calendar-specific ones: | Input | Notes | | --- | --- | | **Read-only or read-write?** | `calendar.readonly` / `calendar.events.readonly` vs `calendar` / `calendar.events` (§1) | | **Events only, or calendar metadata, ACLs and settings too?** | Decides whether `calendar.events*` alone is enough (§1) | | **Is availability all that's needed?** | `freebusy` is dramatically narrower and easy to justify at review (§5) | | **Only calendars the app itself creates?** | `calendar.app.created` scopes the app to its own secondary calendars (§1) | | **Do shared, team or delegated calendars matter?** | Changes what "it can't see that calendar" means (§4) | | **Is Google Meet / conferencing in scope?** | Meet links vs Meet space metadata vs recording *files* are three different tiers (§3) | | **Webhooks or polling?** | Watch channels have their own delivery and renewal constraints (§6) | | **Consumer accounts, one Workspace customer, or multi-tenant?** | Decides whether `Internal` removes verification (base §7) | ## Quick Start 1. Work the base skill's console steps, and **enable the Google Calendar API** on the project — plus the Google Meet API if any Meet scope is requested (§2). A perfectly configured client against a project with the API disabled fails at the first API call, not at authorization. 2. Settle the scope set before touching Data Access: §1 (the family and its tier), §5 (`freebusy` as the floor). 3. Confirm no Meet, Drive or Gmail scope sneaks the app into the restricted tier (§3). 4. Declare exactly those scopes, request exactly those scopes, and expect sensitive-scope verification — demo video and per-scope justification, no security assessment (§1). 5. Finish the base skill's capture, round-trip and handoff steps, adding the Calendar checks in §9. ## Calendar platform state (verified 2026-09-20 — re-verify before trusting) - **No Calendar scope is restricted.** Google's authoritative restricted-scope list covers Gmail, Drive, Fit, Chat, Data Portability, Photos Ambient and Health. Calendar appears nowhere on it. The core Calendar scopes are **sensitive** — Google's own verification material uses "reading events stored in Google Calendar" as its canonical example of a sensitive scope. - **Google does not publish a per-scope tier table for Calendar** the way it does for Drive and Gmail. The authoritative label is the one the Cloud console's Data Access picker shows next to each scope. Read it there and record what it says rather than asserting a tier for the narrower scopes from memory. - **The granular Calendar scopes exist and are current**: `calendar.events.owned`, `calendar.events.owned.readonly`, `calendar.events.freebusy`, `calendar.events.public.readonly`, `calendar.app.created`, `calendar.calendars`, `calendar.calendars.readonly`, `calendar.calendarlist`, `calendar.calendarlist.readonly`, `calendar.acls`, `calendar.acls.readonly`, alongside the long-standing `calendar`, `calendar.readonly`, `calendar.events`, `calendar.events.readonly`, `calendar.freebusy` and `calendar.settings.readonly`. - **Domain verification for webhooks is no longer required.** Google's API Console help now states plainly that domain verification in the API Console is no longer needed for push notifications to work. Runbooks that send you to Search Console before calling `watch` are describing a retired step (§6). - **Secondary calendars now have a single `dataOwner`** (rolled out from November 2025), only that owner can delete them, and a Calendar API ownership-transfer endpoint plus an organization-owned filter on the calendar list rolled out mid-2026. An app that creates calendars on a user's behalf inherits that ownership model (§4). If the scope picker or the restricted list does not look like this, stop and report what you actually see. ## 1. The Calendar scope family Every scope below is a `https://www.googleapis.com/auth/` URL; the table drops the prefix for width. | Scope | Grants | | --- | --- | | `calendar` | See, edit, share and permanently delete **all** calendars the user can access | | `calendar.readonly` | See and download any calendar the user can access | | `calendar.events` | View and edit events on **all** the user's calendars | | `calendar.events.readonly` | View events on all the user's calendars | | `calendar.events.owned` | Create/change/delete events only on calendars the user **owns** | | `calendar.events.owned.readonly` | See events only on calendars the user owns | | `calendar.events.public.readonly` | See events on **public** calendars | | `calendar.app.created` | Make secondary calendars, and manage events **only on those** | | `calendar.calendars` / `.readonly` | Calendar properties (title, description, default time zone); the writable form also creates secondary calendars | | `calendar.calendarlist` / `.readonly` | The user's subscription list — which calendars they see, and adding/removing entries | | `calendar.acls` / `.readonly` | Sharing permissions on calendars the user owns | | `calendar.settings.readonly` | The user's Calendar settings (time zone, locale, working-hours-style preferences) | | `calendar.freebusy` | The user's own availability | | `calendar.events.freebusy` | Availability on calendars the user has access to | | `calendar.addons.*` | Calendar add-on execution and current-event access — for Workspace add-ons, not a server-side connector | **Tier: sensitive, not restricted.** Concretely, for an `External` app this means: - **Yes:** brand verification, a per-scope written justification, and an unlisted English demo video showing the end-to-end grant. Google quotes roughly **3–5 business days** when nothing needs clarification. - **No:** no CASA assessment, no assurance level, no external assessor, no Letter of Validation, **no annual recertification**, and no permitted-application-type gate of the kind that blocks restricted Drive scopes outright. That asymmetry is worth stating to whoever is planning the work, because Calendar is routinely budgeted as if it were Gmail. It is not. The expensive Google reviews are Gmail and whole-Drive; Calendar is a form and a video. **Choose the narrowest scope that does the job, and say why in the justification.** The reviewer is reading exactly that. Three substitutions carry real weight and cost nothing to make: - Only touching events? `calendar.events*` instead of `calendar` — Google's own scope guidance pushes this, and it drops calendar metadata, ACLs and settings out of the grant. - Only the user's own calendars? `calendar.events.owned*` instead of `calendar.events` — this is the difference between "the events you own" and "every event on every calendar shared with you, including your CEO's." - Only the app's own calendars? `calendar.app.created` — the app creates secondary calendars and can never read the user's existing ones. This is Calendar's nearest equivalent to Drive's `drive.file`, and unlike `drive.file` it does not need a separate picker UI. It cannot enumerate anything the app did not create, so it fits "write our bookings into a calendar" and does not fit "sync the user's schedule." **The tier of the app is still the tier of its most sensitive scope** (base §5). A Calendar app is a cheap Calendar app only until someone adds a Gmail or whole-Drive scope to the same project — see §3, which is exactly how it happens. ## 2. Which APIs to enable Enable on the Cloud project, before the first API call: - **Google Calendar API** — for everything in §1. Without it, authorization succeeds and the first call returns `403` with `accessNotConfigured`. - **Google Meet API** — separately, and only if a `meetings.space.*` scope is requested (§3). - **Admin SDK** — only if the integration also enumerates users or transfers calendar ownership org-wide. The CalDAV API is a separate, older interface to the same data with its own authorization story. If someone proposes it, that is a design conversation, not a console setting. ## 3. Google Meet, conferencing, and the tier trap Three different things get called "Meet support," and they sit in three different tiers. Getting this wrong is the single most common way a cheap Calendar app turns into a restricted-tier app: | What you want | What it needs | Tier | | --- | --- | --- | | Create or read a **Meet link on an event** | Just the Calendar event scope — send `conferenceDataVersion=1` and a `createRequest` | Sensitive (Calendar only) | | **Meet space metadata**, conference records, participants | `meetings.space.created` (app's own spaces) or `meetings.space.readonly` (any space the user can reach) | Sensitive | | Configure auto-artifacts on spaces, including ones Calendar created | `meetings.space.settings` | **Non-sensitive** | | **Download the recording or transcript file** | `drive.readonly` or `drive.meet.readonly` — the files live in Drive | **Restricted** — CASA territory | So conferencing *links* are free with the scope you already have. Meet *metadata* costs one more sensitive scope and one more enabled API. Recording and transcript **files** cost the entire restricted-tier regime, because fetching them is a Drive read, not a Meet read. If a requirement says "pull the meeting recording," stop and read the `google-drive-oauth-app` skill before promising anything. Discovery detail worth knowing: a calendar advertises which conference types it supports via `conferenceProperties.allowedConferenceSolutionTypes` on the calendars and calendar-list resources, so "Meet links don't get created" is often a calendar or Workspace-edition property rather than a scope problem. And before turning conference support on in an app that already writes events, run a full sync — Google warns that an app unaware of `conferenceData` can silently strip existing conferences off users' events on update. ## 4. What the token can actually see: primary, secondary, shared and delegated calendars Calendar's access model is two layers deep, and the OAuth scope is only the outer one. **The scope says what kinds of Calendar data the app may touch; the calendar's ACL says which calendars the authorizing user can reach at all.** A perfectly granted `calendar` token sees nothing the user themselves could not see. - **Primary calendar** — created with the account, addressed as the calendar ID `primary` (and usually also by the user's email address), cannot be deleted or un-owned. - **Secondary calendars** — user-created, deletable, shareable, and since late 2025 each has exactly one `dataOwner`; only that owner may delete it. An app that creates calendars for a user is creating them under that model, so authenticate as the user rather than as a service identity if the user should own the result. - **Calendars vs CalendarList** — two different collections that people conflate constantly. `Calendars` is the calendar itself and its global properties; `CalendarList` is *this user's subscription list*, with their colours and reminder overrides. Inserting into `Calendars` **creates** a calendar; inserting into `CalendarList` merely **subscribes** the user to one that already exists. - **Sharing a calendar does not add it to anyone's list.** This is the number-one cause of "the app can't see our team calendar": the calendar was shared, the user never added it, so it is absent from `CalendarList` and the app never enumerates it. The fix is a `CalendarList` insert (or the user adding it in the UI) — not a new scope and certainly not a new client. ACL roles, in ascending order, decide what a token reaching a calendar can do: `freeBusyReader` (busy blocks only, no details), `reader`, `writerWithoutPrivateAccess` (added mid-2026 — read/write non-private events, private ones appear as busy), `writer` (also sees ACLs and labels), `owner`. A calendar holds up to 6,000 ACL entries, and grantees can be individual users, groups, a whole domain, or the public. Two consequences to put in the handoff: - **"Delegated access" in Calendar is an ACL grant, not an OAuth concept.** If a customer wants the app to manage an executive's calendar, the executive grants the connecting user `writer` or `owner` on it and the user subscribes to it. Nothing changes in the registration. - **Domain-wide delegation is the other route**, and a genuinely different posture: a Workspace super administrator pre-authorizes a service account for chosen scopes and the app impersonates users without any individual consent screen. It is an admin-side decision with its own review inside the customer, not a fallback for a user consent that is failing. ## 5. `freebusy` as the minimal-access alternative If the product only needs to know *when someone is busy* — scheduling, routing, availability widgets, conflict detection — `freebusy` is the answer and it is a materially easier conversation at review than `calendar.readonly`. - `freebusy.query` is authorized by any of `calendar`, `calendar.readonly`, `calendar.freebusy` or `calendar.events.freebusy`. Requesting only a `freebusy` scope keeps the grant to availability. - The response is **busy time ranges only** — no titles, no attendees, no locations, no descriptions. That is exactly the sentence to put in a scope justification, and exactly the sentence that reassures a customer's security team. - It can be asked about calendars the caller cannot fully read: a `freeBusyReader` grant is enough to get busy blocks back. Calendars the caller genuinely cannot reach come back with a per-calendar `error` object (typically `notFound`) inside an otherwise successful response — a partial result, not a failed request. Handle per-calendar errors, or an app will silently treat an unreachable colleague as free all week. - The trade-off is real and worth stating: `freebusy` **cannot create, move or annotate events**, and cannot tell you *what* a meeting is. A booking product needs `calendar.events` (or `calendar.app.created`) to write. Ask *is availability all we need?* before registering. Going from `calendar.readonly` down to `freebusy` later is a re-consent event for every customer. ## 6. Push notifications and watch channels Calendar supports `watch` on four resources: **Acl, CalendarList, Events and Settings**. Events and ACLs are watched per calendar, so a multi-calendar sync means a channel per calendar; settings and the calendar list are per user. The constraints that actually bite: - **The receiving URL must be HTTPS with a genuinely valid certificate.** Self-signed, untrusted-issuer, revoked, or hostname-mismatched certificates are all rejected. - **Domain verification is no longer required.** Google's API Console help now states that API Console domain verification is not needed for push notifications to work. Older guidance — and much of the community advice around the `Unauthorized WebHook callback channel` error — sends you to Search Console and the *Domain verification* page first. If a `watch` call is rejected on the callback URL today, verify the certificate and the exact URL before spending time on a step Google says it retired; if verifying the domain is the only thing that fixes it, record that as a live discrepancy against the current doc rather than assuming either source is right. - **Channels expire and nothing renews them.** The expiry comes back on the `watch` response and in the `X-Goog-Channel-Expiration` header on every notification; the app must create a replacement channel with a fresh unique `id` before then, and tolerate an overlap window where two channels are live for one resource. - **Notifications carry no body.** They tell you *something* changed, not what. Pair every channel with a `syncToken` incremental sync and fetch the delta on each ping. - **Delivery is explicitly not 100% reliable.** Google says messages can be dropped; a correct integration re-syncs periodically regardless of whether a notification arrived. - **A `410 GONE` on a `syncToken`** means the token was invalidated — expiry, or an ACL change on the calendar. The only recovery is to wipe the local copy and run a full sync. Design for it; it is routine, not exceptional. ## 7. Quotas and rate limits Calendar's limits are per-minute and generous, and they are *not* an authorization problem — re-authorizing a rate-limited connection fixes nothing and burns a consent. - **10,000 requests per minute per project**, and **600 requests per minute per user per project**, on a sliding window, so a burst inside one minute throttles the next. - Exceeding them returns **`403` with `usageLimits`**, or **`429`**, for the same condition. Any client that treats `403` as "permissions are wrong" will misdiagnose a throttle as a broken grant — map the rate-limit reasons to a retry path explicitly. - Retry with truncated exponential backoff plus jitter — Google's own formula is `min(((2^n) + random_milliseconds), maximum_backoff)`, with the random part under 1,000 ms and the ceiling around 32–64 seconds. - **The per-minute quotas can be raised** from the Cloud console's Quotas page (approval is not guaranteed). The **daily ceiling of 1,000,000 requests per project cannot be increased.** - A separate and easily-confused failure: **`403 Calendar usage limits exceeded`** is Calendar's own abuse-prevention limit hitting the *end user*, not your project quota. Backing off harder does not fix it; the user or their admin does. ## 8. What a Workspace admin can do to a Calendar app Beyond the generic API controls in base §7, two Calendar-specific admin settings explain most "it works for everyone except this customer" reports: - **API controls scoped to Calendar** (*Admin console → Security → Access and data control → API controls*). An admin can set Calendar data access to **Restricted**, after which only apps explicitly marked Trusted — or given a specific Google-data grant — can touch Calendar at all; apps on Limited access are shut out of Calendar even if they work against that customer's other Google services. The remedy is theirs: allowlist the client ID for Calendar. Nothing in the registration changes. - **Calendar external sharing settings** (*Admin console → Apps → Google Workspace → Calendar → Sharing settings*). An admin can cap what people outside the organization see at **"Only free/busy information (hide event details)."** When that is on, an app authorized by an outside user gets busy blocks where it expects titles and attendees — with a scope that is entirely correct and a token that is entirely valid. It looks like a mapping bug and it is a policy. - **The Calendar service can be switched off** for an org or an OU altogether, which fails calls no allowlist can rescue. ## Product fact — what the Calendar connector asks for > **As of 2026-09-20, Unified.to's Google Calendar connector requests**, per object and operation: > > - **Calendars, read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/calendar.readonly` > - **Calendars, write:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/calendar` > - **Events, read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/calendar.events.readonly` > - **Events, write:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/calendar.events` > - **Availability (busy), read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/calendar.freebusy`, > `https://www.googleapis.com/auth/calendar.events.freebusy` > - **Recordings, read:** `https://www.googleapis.com/auth/meetings.space.readonly` — and **only** that; no identity > scopes accompany it > - **Login/identity only:** `openid`, `profile`, `email` > > Everything here is **sensitive at worst** — nothing restricted, so verification without a security assessment. Note > the shape of it, though: the connector asks for the *broad* members of each family (`calendar.readonly`, > `calendar`, `calendar.events`), not the narrower `*.owned` or `calendar.app.created` variants, so a grant reaches > every calendar in the user's list. If the customer's use case is narrower, that is a conversation with the > connector's owner, not a console setting. > > Auth quirks worth knowing: it uses the standard Google authorize and token endpoints, asks for offline access and > forces the consent prompt (so a refresh token comes back on re-authorization, per base §5); it remaps Google's > `403` + `rateLimitExceeded` / `Quota exceeded` responses to a `429`-style rate-limit error, which matches §7; and > alongside user OAuth it supports a **Google service-account credential** path scoped to full calendar access — the > domain-wide-delegation route in §4. > > Because every configuration pairs identity scopes with at least one Calendar scope, **the granular consent screen > applies**: Google shows per-scope checkboxes whenever sign-in and non-sign-in scopes — or more than one > non-sign-in scope — are requested together, and the user can approve identity while declining Calendar. Always > read the granted `scope` back from the token response rather than assuming (§9). > > Scope sets change. This note is dated, not live; confirm with the connector's owner before submitting anything. ## 9. Calendar-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Make one real Calendar read — list the calendar list, then list events on `primary` — and confirm the Google Calendar API is enabled and returning data. 2. **Compare the granted `scope` in the token response against what was requested.** With identity plus Calendar scopes the consent screen is granular, and a user who unticks Calendar produces a token that authenticates perfectly and 403s on every Calendar call. 3. Test with a user who has a **shared or team calendar they have not subscribed to**, and confirm you understand why it is absent from the calendar list (§4) before anyone reports it as a bug. 4. If availability is the use case, run a `freebusy.query` spanning a calendar the user can reach and one they cannot, and confirm the per-calendar `error` object is handled rather than read as "free". 5. If webhooks are in scope, create a channel, receive one notification, and confirm the app re-fetches with a `syncToken` — then force a `410` and confirm it full-syncs instead of stalling. 6. If Meet matters, create an event with `conferenceDataVersion=1` and confirm a conference is actually generated (the create request completes asynchronously — the status starts as `pending`). | Symptom | Cause | | --- | --- | | Auth succeeds, every Calendar call `403 accessNotConfigured` | Google Calendar API not enabled on the project (§2) | | Auth succeeds, Calendar calls 403 but identity works | User declined the Calendar scope on the granular consent screen (§9.2) | | App sees the user's own calendar but not the team's | Calendar shared but never added to the user's list — `CalendarList`, not a scope (§4) | | Events come back as untitled busy blocks | `freeBusyReader` ACL role, or the admin's external-sharing cap (§4, §8) | | `freebusy` returns "free" for someone who is clearly busy | Per-calendar `error` inside a 200 response, unhandled (§5) | | `403` / `429` with `usageLimits` under load | Per-minute quota, not authorization — back off, do not re-authorize (§7) | | `403 Calendar usage limits exceeded` for one user only | Calendar's end-user abuse limit; the user or their admin must resolve it (§7) | | `410` on an incremental sync | `syncToken` invalidated (expiry or an ACL change) — full resync (§6) | | Webhooks stop after a while, silently | Channel expired; nothing auto-renews (§6) | | `watch` rejected on the callback URL | Certificate or URL, not a missing domain verification — that step was retired (§6) | | Conferences vanish from events the app updates | Updating events without `conferenceData` awareness; full-sync before enabling (§3) | | One customer's users all fail, everyone else is fine | Their admin set Calendar API access to Restricted, or Calendar is off for the OU (§8) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - Someone wants recordings or transcripts **as files** — that is a restricted Drive scope and a CASA assessment, not a Calendar feature (§3). - The requirement could be met by `freebusy` or `calendar.app.created` but a broad `calendar` / `calendar.readonly` scope is being requested anyway. That is a product and review-burden decision, not a console choice (§1, §5). - Anyone proposes narrowing or widening scopes on a live integration — every customer re-consents. - Domain-wide delegation is on the table: it needs a customer's super administrator and their own internal review, and it changes the trust model, not just the plumbing (§4). - A customer's admin has restricted Calendar API access or capped external sharing — the fix is theirs (§8). - The Cloud console labels a Calendar scope as anything other than non-sensitive or sensitive, or the restricted list has grown to include Calendar. That would contradict the **Calendar platform state** section above. ## References Calendar-specific only; the base skill carries the generic Google OAuth references. Verified to resolve on 2026-09-20. - Choose Google Calendar API scopes (the full scope family) — https://developers.google.com/workspace/calendar/api/auth - Calendar API overview — https://developers.google.com/workspace/calendar/api/guides/overview - Events and calendars concepts (primary vs secondary, Calendars vs CalendarList) — https://developers.google.com/workspace/calendar/api/concepts/events-calendars - Sharing and ACL concepts (access roles, grantees, limits) — https://developers.google.com/workspace/calendar/api/concepts/sharing - `freebusy.query` reference (authorizing scopes, per-calendar errors) — https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query - Calendar list reference — https://developers.google.com/workspace/calendar/api/v3/reference/calendarList - ACL reference — https://developers.google.com/workspace/calendar/api/v3/reference/acl - Push notifications (watch channels, HTTPS/cert rules, expiry) — https://developers.google.com/workspace/calendar/api/guides/push - `Events: watch` reference — https://developers.google.com/workspace/calendar/api/v3/reference/events/watch - Incremental sync with `syncToken` (and the 410) — https://developers.google.com/workspace/calendar/api/guides/sync - Create events, including Meet conferencing — https://developers.google.com/workspace/calendar/api/guides/create-events - Usage limits and quotas — https://developers.google.com/workspace/calendar/api/guides/quota - Calendar API errors — https://developers.google.com/workspace/calendar/api/guides/errors - Calendar API release notes — https://developers.google.com/workspace/calendar/release-notes - Improved management of secondary calendars via the Calendar API (2026) — https://workspaceupdates.googleblog.com/2026/06/secondary-calendar-management-API.html - Meet REST API: authenticate and authorize (Meet scope tiers) — https://developers.google.com/workspace/meet/api/guides/authenticate-authorize - Meet REST API overview — https://developers.google.com/workspace/meet/api/guides/overview - How to handle granular permissions — https://developers.google.com/identity/protocols/oauth2/resources/granular-permissions - Granular OAuth consent in web apps and Workspace add-ons — https://workspaceupdates.googleblog.com/2025/11/granular-oauth-consent-in-webapps.html - Restricted scopes list (Calendar is absent) — https://support.google.com/cloud/answer/13464325?hl=en - Verifying domains for push notifications (now states it is no longer required) — https://support.google.com/googleapi/answer/7072069?hl=en - Set Google Calendar sharing options (admin external-sharing cap) — https://support.google.com/a/answer/60765?hl=en - Control which apps access Google Workspace data (per-service API controls) — https://support.google.com/a/answer/7281227?hl=en ## google-cloud-console-oauth URL: https://docs.unified.to/skills/google-cloud-console-oauth/SKILL.md # Google Cloud Console — shared OAuth2 registration mechanics Everything common to registering an OAuth 2.0 client for **any** Google API: the project, the Google Auth Platform app, the Web application client, redirect URIs, scope tiers, the secret, publishing status, and verification. The product skills — Google APIs, Google Ads, Google Business Profile, Google Drive — assume this file and cover only what their API adds on top (a developer token, an access-request form, a restricted-scope decision). **Read this first, then the product skill.** If you are registering a plain Google OAuth client with no particular product in play, this file is the whole job. **Some products have a second console.** Google Ads (manager account, Terms signature, API Center) and Google Business Profile (an access-request form) gate access outside the Cloud Console entirely. Where the product skill says so, a finished Cloud project is only half the credential. Three things here are expensive to get wrong, and none fail at registration time. **The client secret is shown exactly once.** **An app left in `Testing` issues refresh tokens that expire in seven days**, which looks like a working integration for a week and then a mass outage. And **every scope must be declared on the Data Access page**, because an authorize URL requesting an undeclared scope drops the user onto the unverified-app warning screen even if the app is otherwise verified. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, user support email, developer contact email | Shown on the consent screen and used by the review team (§3) | | **Logo** (PNG, square), homepage URL, privacy policy URL, terms URL | Required for publishing and verification (§3, §7) | | **Which Google account / Cloud organization owns the project** | Decides whether `Internal` is even available (§2) | | **Redirect URIs** | Every callback host the platform serves (§4) | | **Scope set** | Exactly what the caller requests — the product skill supplies this (§5) | | **Audience: internal-only or external customers?** | Changes verification, caps and token lifetime (§3, §7) | | **New app, or an edit to an existing client?** | A new client ID re-authorizes every customer (§1) | ## Quick Start 1. Confirm a **new client** is needed — existing connections are bound to the current client ID (§1). 2. Sign in, pick or create the Cloud project, and enable the APIs the scopes belong to (§2). 3. Configure the app on the **Google Auth Platform**: Branding, then Audience (§3). 4. Create an OAuth client of type **Web application** — the only common type yielding a usable client secret (§3). 5. Add **every** redirect URI, exactly (§4). 6. Declare every scope on **Data Access**, and request no more than that (§5). 7. **Copy the client secret at creation.** It is never shown in full again (§6). 8. Decide Testing vs In production, and start verification if external (§7). 9. Run a real authorize → callback → refresh round trip from an account that is *not* the project owner (§8). 10. Hand the credentials over — never commit them (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The OAuth consent screen is now the "Google Auth Platform."** The console menu item is **Google Auth Platform**, split across six pages: **Overview**, **Branding**, **Audience**, **Clients**, **Data Access**, and **Verification Center**. Runbooks saying "APIs & Services → OAuth consent screen" describe a single page that no longer exists — the settings survived, they moved. Entry point `https://console.cloud.google.com/auth/overview`; clients at `https://console.cloud.google.com/auth/clients`. - **Client secrets are hashed.** The full secret can be viewed and downloaded **only once, at creation**; afterwards the console shows the last four characters for identification only. There is no reveal and no second download — the recovery path is to create an *additional* secret and disable the old one (§6). - **Idle and deleted clients are reclaimed.** A client unused for six months is deleted automatically. A deleted client is recoverable from the deleted-credentials view for 30 days, then permanently gone. - **Config changes propagate slowly.** OAuth settings changes can take five minutes to a few hours to take effect. A redirect URI that "does not work" immediately after being added is often just not live yet. - **Restricted scopes require an annual security assessment** under the App Defense Alliance **CASA** framework (§7). If the console does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing client, or create a new one A new OAuth client means a **new client ID, and every existing customer connection is bound to the old one** — every customer re-authorizes. Reuse the existing client for: adding a redirect URI, adding or removing a scope, publishing the app, submitting for verification, or rotating a compromised secret (rotation keeps the client ID; see §6). Create a **new** client only when the user explicitly wants one: a separate app for a different product, a different audience (internal vs external), or a deliberate replacement of a compromised client. Say which path you are taking before touching anything. Note the asymmetry: **scopes, branding, audience and verification belong to the app/project**, while **redirect URIs and the secret belong to the client**. Several clients in one project share one consent screen, one verification state, and one declared scope list. That is usually what you want; it also means a scope added for one client is requestable by all of them and shows up in the same review. ## 2. Account, organization, and project - **Sign in / console** — `https://console.cloud.google.com`. Any Google Account can create a project; a Workspace account is needed for the `Internal` audience (§3). - **Create or pick a project.** Prefer a dedicated project for the integration. Verification, scopes and the consent screen are project-wide, and a shared project drags unrelated scopes into the review. - **If the owner has a Google Workspace organization, create the project inside that organization resource.** Google recommends this for Workspace developers, and it is what makes `Internal` selectable. - **Enable the APIs the scopes belong to.** Scopes for APIs that are not enabled do not appear in the Data Access picker. Enable them from **APIs & Services → Library**. The product skill says which APIs. - **Permissions.** Project Owner, or Editor plus the OAuth config rights. A user who can see the project but not edit it gets through most of §3 and then fails on Create Client. Hand control back to the user for anything only a human can do: account creation, billing or organization setup, email verification, 2FA, accepting terms, and the identity checks Google sometimes inserts. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste — the redirect URIs from §4 and the scope strings from the product skill, verbatim — and continue once they report back with the client ID and confirm where they stored the secret. ## 3. Configure the app and create the client Work the Google Auth Platform pages in this order; the console asks for the app registration before the client anyway. **Branding** (`console.cloud.google.com/auth/branding`) — app name, user support email, logo, home page, privacy policy link, terms link, authorized domains, developer contact email. This is what a customer reads on the consent screen and what a reviewer checks, so fill it from what the user supplied rather than approximating. Two traps: - The **app name and logo are only shown to users once the brand is verified**; before that users see the app's domain instead. **Branding cannot be edited while a verification is in progress** — finish the text before submitting. - **Authorized domains** must be domains the owner controls and has verified in Google Search Console. Homepage and privacy policy must live on one of them. **Audience** (`console.cloud.google.com/auth/audience`) — two independent settings, both consequential: | Setting | Meaning | | --- | --- | | **User type: Internal** | Only members of the owning Workspace organization can authorize. No verification, no unverified-app screen, no 100-user cap. Only available when the project sits in an organization. Wrong for a multi-tenant connector serving other companies. | | **User type: External** | Any Google Account can authorize. What a customer-facing connector needs — and what brings verification (§7). | | **Publishing status: Testing** | Only listed test users can authorize, **capped at 100 test users**. Authorizations — and their refresh tokens — **expire seven days after consent**, *unless the only scopes requested are a subset of `openid`, `userinfo.email` and `userinfo.profile`* (or their OpenID Connect equivalents). Identity-only apps are exempt; anything requesting one more scope is not. | | **Publishing status: In production** | Open to any Google Account. Refresh tokens are no longer time-boxed to seven days. | The seven-day expiry is the single most misdiagnosed Google OAuth symptom. It is not a client bug, not a rotation policy, and not fixable in code: it is `External` + `Testing`. Publish the app. **Data Access** (`console.cloud.google.com/auth/scopes`) — see §5. Do this before creating the client if you can; the declared list is what verification reviews. **Clients** (`console.cloud.google.com/auth/clients`) — **Create Client**, then pick the application type: | Type | Gives a client secret? | Use it when | | --- | --- | --- | | **Web application** | **Yes** | A server exchanges the code. The type for a hosted connector. | | Desktop app | Issued, but treated as a public client | Native apps doing a loopback redirect | | Android / iOS / Chrome extension / UWP | No | Public clients; PKCE instead of a secret | | TVs and limited-input devices | Device-flow credential | Device code flow only | | *Service account* (under IAM, not here) | Key file, not an OAuth client secret | Server-to-server, or Workspace domain-wide delegation — **not** a user-authorizes-us flow | Pick **Web application**. A service account authenticates as itself, not as a customer's user, and cannot let arbitrary customers connect their own Google accounts. If someone proposes one for a multi-tenant connector, that is a design conversation, not a console setting. ## 4. Redirect URIs On the Web application client: **Authorized redirect URIs → Add URI**, one entry per callback host. Get the list from the platform owner; for Unified.to these are one per data center: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Google's matching is unforgiving, and every failure surfaces at connect time, not at save time: - The `redirect_uri` sent must match a registered URI **exactly** — case-sensitive, including a trailing slash. `.../oauth/code` and `.../oauth/code/` are different URIs. Mismatch returns `redirect_uri_mismatch`. - **No wildcards**, no fragments, no path traversal (`/..`), no userinfo component, no invalid percent-encoding. - **HTTPS only.** `http://` is rejected except for `localhost`; raw IPs rejected except localhost IPs. - The host TLD must be on the public suffix list; `googleusercontent.com` and URL shorteners are rejected. - **Authorized JavaScript origins** are a separate field for browser-side clients. A server-side connector does not need them; leave the field empty rather than guessing. - Changes take **five minutes to a few hours**. Add the URI, then wait before concluding it is broken. - Testing through Google's **OAuth Playground** needs `https://developers.google.com/oauthplayground` registered as its own redirect URI. Remove it before the client goes to production. ## 5. Scope mechanics The product skill supplies the scope strings. This section is how Google treats them. Scopes are **space-delimited** in the authorize URL, and each is a full URL (except `openid`, `profile`, `email`). Two lists must agree: 1. What the **Data Access** page declares. 2. What the authorize URL actually requests. Requesting an **undeclared** scope shows the unverified-app warning screen — Google states this happens even if the app is otherwise verified. Declaring scopes you never request is its own problem: it widens the verification review and invites questions you cannot answer. Keep them equal. Three tiers, and the console labels each scope for you: | Tier | Examples | Cost | | --- | --- | --- | | **Non-sensitive** | `openid`, `profile`, `email`, `.../auth/userinfo.email`, `.../auth/userinfo.profile` | None | | **Sensitive** | Calendar, Contacts/People, Sheets, Docs scopes | App verification: brand check + demo video + justification | | **Restricted** | Gmail scopes that can read a mailbox, and Drive scopes other than `drive.file` and `drive.appdata` | Verification **plus** an annual CASA security assessment (§7) | The restricted list is explicit and worth checking against the exact strings you plan to request — `https://mail.google.com/`, `.../auth/gmail.readonly`, `.../auth/gmail.metadata`, `.../auth/gmail.modify`, `.../auth/gmail.insert`, `.../auth/gmail.compose`, `.../auth/gmail.settings.basic`, `.../auth/gmail.settings.sharing`, and `.../auth/drive`, `.../auth/drive.readonly`, `.../auth/drive.metadata`, `.../auth/drive.metadata.readonly`, `.../auth/drive.activity`, `.../auth/drive.activity.readonly`, `.../auth/drive.scripts`, `.../auth/drive.meet.readonly`. **Not every Drive scope is restricted.** `drive.file` (files the app created or the user explicitly picked) and `drive.appdata` (a hidden app-private folder) sit outside the restricted tier. Where a product can live with per-file access, `drive.file` removes an entire annual assessment from the roadmap — a product decision to raise, not to take. Gmail's equivalent is narrower but real. Every Gmail scope that can **read** a mailbox is restricted — including `gmail.readonly` and `gmail.metadata`, so "metadata only" buys nothing. But **`gmail.send` is sensitive, not restricted**, and `gmail.labels` is non-sensitive: an app that only injects outbound mail escapes the assessment entirely, at the cost of being unable to read, list, search or thread anything. See the Gmail product skill. **Meet spans tiers, and Google publishes them in one place** — the Meet REST API authentication guide, not the restricted-scopes list. `meetings.space.settings` is non-sensitive; `meetings.space.created` and `meetings.space.readonly` are sensitive; **all Meet Media API scopes are restricted**. Adding a Meet link to an event needs only the Calendar event scope, but **downloading a recording or transcript reads the file through Drive**, which pulls in a restricted Drive scope and the assessment with it. See the Meet product skill. **The restricted list below is not exhaustive for every API.** It is Google's Cloud restricted-scopes list, which does not mention Meet at all — yet the Meet Media API scopes are restricted per that API's own page. Check the product API's own auth page as well as this list before concluding a scope is cheap. **A product API's own scope is often cheap; the scope that *finds* the data is what costs.** Docs, Sheets and Slides each address a file by ID and cannot list or search — discovery comes from Drive. So a sensitive-tier product API becomes a restricted-tier project the moment `drive.readonly` or `drive` is added for enumeration, while `drive.file` plus the Picker keeps it sensitive. Check what the product skill says before assuming the product scope sets the tier. **Proving a scope is "sensitive" is a negative test.** Google publishes an exhaustive *restricted* list but no exhaustive sensitive list, so the resolution path is: confirm the scope is absent from the restricted list, then read the tier label the console shows for it on the Data Access page. Do not assert a tier from a doc page's prose. **The tier of the app is the tier of its most sensitive scope.** One restricted scope moves the whole project into restricted verification, and adding one later can force an out-of-cycle re-assessment. ### Getting a refresh token at all Google returns a refresh token **only** when `access_type=offline` was set, and **only on the first authorization** for that user/client pair. A later authorize without `prompt=consent` returns an access token and silently no refresh token, because the user was not re-prompted. A connector that reconnects an already-connected account and stores no refresh token is almost always missing `prompt=consent`. Other refresh-token rules worth knowing before you debug one: **100 live refresh tokens per Google Account per client ID** (the 101st silently invalidates the oldest) and expiry after **six months** of non-use. Individual Google APIs add their own invalidation rules on top — the product skill says which. ## 6. Capture the credentials At **Create**, the console shows the client ID and client secret and offers a JSON download. **This is the only time the full secret exists outside Google.** Copy or download it immediately, into a secret manager. Afterwards the console shows only the last four characters, because the secret is stored hashed. Capture: - **Client ID** (ends in `.apps.googleusercontent.com`) and **client secret** - **Project ID / number** and the owning account or organization - **Authorize URL** `https://accounts.google.com/o/oauth2/v2/auth` and **token URL** `https://oauth2.googleapis.com/token` - The declared scope list, verbatim - Audience (Internal/External) and publishing status (Testing / In production) **Rotation is add-then-disable, not replace.** Because secrets cannot be re-read: create a second secret on the same client, deploy it, confirm every environment exchanges and refreshes successfully, then disable the old one. The client ID and all existing refresh tokens survive — no customer re-authorizes. Disabling the old secret before the deploy lands breaks every exchange and refresh until the new one is live, so never rotate without a cutover plan and explicit go-ahead. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 7. Verification, review, and limits For an `Internal` app none of this applies — that is its whole appeal and its whole limitation. Everything below is about `External`. **The caps.** Two different 100s, often confused: - `Testing` allows at most **100 test users**, each added by email address, and their grants expire after seven days. - A published-but-unverified app that triggers the unverified-app screen is capped at **100 new users over the project's lifetime**. That cap is not reset by unpublishing and republishing; it is why "we'll verify later" becomes a hard stop at customer 101. **The unverified-app screen.** Until brand and scopes are verified, customers see a warning instead of the app name and logo and must click through an *Advanced* link. Enterprise security teams routinely stop there. Treat verification as part of launch, not follow-up. **What the review asks for** — each layer adds to the last: 1. **Brand** — homepage on a domain verified in Google Search Console and owned by the app's owner, a privacy policy on that domain linked from both the homepage and the consent screen, accurate app identity, current contacts. 2. **Sensitive scopes** — a justification for each scope explaining why the narrowest alternative is insufficient, and an **unlisted YouTube demo video** showing the end-to-end OAuth grant in English, with the app name on the consent screen and the client ID visible in the address bar. Google quotes roughly **3–5 business days** when nothing needs clarification. That figure covers this sensitive-scope review only; Google publishes no turnaround for the restricted-scope assessment below. 3. **Restricted scopes** — all of the above, plus a **CASA security assessment** through the App Defense Alliance, performed by an external assessor against the OWASP ASVS. Google assigns an **assurance level (AL1 or AL2)** by a risk-based evaluation of user count, requested scopes and other signals; an app that reaches AL2 stays there in later years, and the required level can rise as the user base grows. **All applications must be revalidated every year.** Passing produces a Letter of Validation. Treat cost and turnaround as unknown until an assessor quotes them. Google does not publish assessor fees or assessment timelines, and figures circulating in third-party write-ups are not on any current Google page. Budget for a real engagement and get the number from the assessor. Older material calls the levels "Tier 2 / Tier 3"; the current vocabulary is AL1 / AL2. **Verification is not the only gate.** Some restricted scopes also carry **eligibility rules**: Google grants them only to apps in particular categories, so an app outside those categories cannot qualify however complete its paperwork. Check the product skill before budgeting an assessment for a scope the app can never be granted. **Exemptions exist beyond `Internal`.** Google documents several cases that do not need the full review — read the current verification-requirements page rather than assuming; the product skill flags the ones affecting its API. **Additional (non-core) Google services are a second Workspace switch.** Analytics, YouTube, Blogger and others are "additional services" a Workspace admin can turn off per organizational unit, independently of the API controls below. The failure has a different owner and a different remedy — the customer enables the service for that OU. The product skill says whether its API is one of these. **Workspace customers can block you regardless.** A customer's Workspace admin controls third-party app access under *Security → Access and data control → API controls*, and can mark an app trusted, limited or blocked, or block unconfigured apps wholesale. When exactly one customer cannot connect and everyone else can, check this first — the fix is theirs: they allowlist the client ID. Note the blast radius when an admin flips that switch the other way: marking a Google service restricted **revokes existing tokens immediately** for apps the domain does not trust, so a whole customer can break overnight with no change on your side. A different Workspace failure looks similar but is not this: when the underlying Google *service* is switched off for the account, calls fail with `403 PERMISSION_DENIED` no matter how the app is allowlisted. The product skill says whether its API has that mode. Domain-wide delegation is an admin-side pre-authorization path, not a workaround for a failing user consent. **Stop and ask** rather than answering on the user's behalf: scope justifications, data-handling and retention claims, privacy-policy wording, Limited Use attestations, security-questionnaire answers, choice of assessor, and anything with a dollar figure. A plausible guess in a verification form is worse than a delay. ## 8. Verify end-to-end Authorizing with the account that owns the project proves very little — it is a test user, an org member, or both. Test the path a customer takes: 1. Authorize from an account that is **not** the project owner, through the platform's real connect flow. In `Testing`, that account must be on the test-user list; if that feels like a blocker, it is the signal to publish. 2. Confirm the token response contains a **`refresh_token`**, not just an `access_token`. 3. Confirm the granted `scope` matches what was requested — Google returns what the user actually approved, which can be less when a granular-consent screen let them uncheck something. 4. **Force a refresh** and make a real API call with the refreshed token. 5. Re-run the whole flow on an **already-connected** account and confirm a refresh token comes back again. That is the test that catches a missing `prompt=consent`. 6. If the app is still in `Testing`, say so explicitly in the handoff: the connection dies in seven days. | Symptom | Cause | | --- | --- | | `redirect_uri_mismatch` | The sent `redirect_uri` is not character-for-character a registered URI (§4) — check the trailing slash, then propagation | | `invalid_client` / `unauthorized_client` | Wrong client ID or secret, wrong client type, or a secret disabled during rotation (§6) | | `access_denied` right after the account picker | App in `Testing` and the account is not a test user, or `Internal` audience and the account is outside the organization (§3) | | `invalid_scope`, or the unverified-app screen on a verified app | A requested scope is not declared on Data Access (§5) | | No `refresh_token` in the token response | Missing `access_type=offline`, or a repeat authorization without `prompt=consent` (§5) | | Every connection dies almost exactly one week after connecting | `External` + `Testing` seven-day grant expiry (§3) | | Connections die after a quiet six months | Refresh token unused for six months and expired (§5) | | Oldest connections for one heavy user drop silently | 100 live refresh tokens per account per client ID (§5) | | Exactly one customer cannot connect | Their Workspace admin's API controls (§7) | | Signups stop at around 100 total customers | Unverified-app lifetime user cap (§7) | | `403` with `accessNotConfigured` on the first API call | The API for that scope is not enabled in the project (§2) | | `403` with `rateLimitExceeded` / `userRateLimitExceeded` | Quota, not authorization. Back off and retry — do not re-authorize. Several Google APIs signal throttling as 403 rather than 429 | ## 9. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for a secret store or console. **The downloaded client JSON is the secret** — it contains it. - If a code change is needed (a redirect host, a scope, `access_type` / `prompt` handling), keep it secret-free and say what the human must set out of band. - Close with: app name, project, and owning account/organization; client ID; client type (Web application); where the secret was delivered and that it cannot be re-read; the authorize and token endpoints; the exact declared scope strings and their tier; audience and publishing status; verification state and what it still needs; and anything left for the user to do — especially "publish before real customers connect" if the app is still in `Testing`. ## Stop and ask Hand back to a human rather than guessing when: the scope set is restricted-tier and nobody has budgeted for a CASA assessment and its annual renewal; someone asks you to write scope justifications, privacy-policy text, data-retention claims or security-questionnaire answers; the app needs a new client ID and existing customers would have to re-authorize; a secret must be rotated on a live integration; `Internal` vs `External` is genuinely open (it changes the product, not just the console); a customer's Workspace admin has to allowlist the client; or the console does not match the **Platform state** section above. ## References - Get started with the Google Auth Platform — https://support.google.com/cloud/answer/15544987?hl=en - Manage OAuth App Branding — https://support.google.com/cloud/answer/15549049?hl=en - App Identity & Branding — https://support.google.com/cloud/answer/13804963?hl=en - Manage App Audience (user type, publishing status, test users) — https://support.google.com/cloud/answer/15549945?hl=en - Manage OAuth Clients (client types, one-time secret, deletion, propagation) — https://support.google.com/cloud/answer/15549257?hl=en - Manage App Data Access (declaring scopes) — https://support.google.com/cloud/answer/15549135?hl=en - Restricted Scopes — the authoritative list — https://support.google.com/cloud/answer/13464325?hl=en - Verification requirements — https://support.google.com/cloud/answer/13464321?hl=en - OAuth App Verification Help Center — https://support.google.com/cloud/answer/9110914?hl=en - Requesting minimum scopes — https://support.google.com/cloud/answer/13807380?hl=en - Security Assessment (CASA, AL1/AL2) — https://support.google.com/cloud/answer/13465431?hl=en - Annual Recertification — https://support.google.com/cloud/answer/13463816?hl=en - Using OAuth 2.0 to Access Google APIs (refresh token expiry rules) — https://developers.google.com/identity/protocols/oauth2 - OAuth 2.0 for Web Server Applications (redirect URI rules, `access_type`, `prompt`) — https://developers.google.com/identity/protocols/oauth2/web-server - OAuth 2.0 Policies — https://developers.google.com/identity/protocols/oauth2/policies - OAuth 2.0 Scopes for Google APIs — https://developers.google.com/identity/protocols/oauth2/scopes - OAuth app state overview (testing / published / verified) — https://developers.google.com/identity/protocols/oauth2/production-readiness/overview - Brand verification — https://developers.google.com/identity/protocols/oauth2/production-readiness/brand-verification - Sensitive scope verification (demo video, 3–5 business days) — https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification - Restricted scope verification — https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification - Additional considerations for Google Workspace — https://developers.google.com/identity/protocols/oauth2/production-readiness/google-workspace - Configure the OAuth consent screen and choose scopes — https://developers.google.com/workspace/guides/configure-oauth-consent - Control which third-party apps access Google Workspace data (admin API controls) — https://support.google.com/a/answer/7281227?hl=en ## google-contacts-oauth-app URL: https://docs.unified.to/skills/google-contacts-oauth-app/SKILL.md # Google Contacts (People API) OAuth2 App Registration The good news first, because it inverts the usual Google calculation: **no People API scope is restricted.** Whole- address-book read and write are *sensitive* scopes — app verification, a justification, a demo video — and nothing more. There is no CASA security assessment, no annual revalidation, no permitted-application-type gate. A Contacts integration is one of the cheapest full-access Google integrations to get approved. What costs time here instead is the **data model**. "Contacts" is not one corpus but three — saved contacts, "other contacts", and the Workspace directory — each behind its own scope, its own method, and its own field mask. An app that requests the obvious scope and calls the obvious method sees a fraction of the people the user sees in Gmail, and the user reports it as a bug. That, the two-rung scope ladder, and seven-day sync tokens are what this file is for. Everything else is shared. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns all the console mechanics, and this file does not repeat them: - Choosing or creating the Cloud project, enabling APIs, permissions, and what only a human can do in the console. - Configuring the app on the Google Auth Platform (Branding, Audience, Data Access, Clients), creating a **Web application** client, and the redirect-URI rules with the platform's per-data-center callback list. - The generic scope model: declaring every scope on Data Access, the non-sensitive / sensitive / restricted tiers, and the rule that the app's tier is its most sensitive scope. - The client secret shown and downloadable **only once at creation**, and add-then-disable rotation. - `Testing` vs `In production` — including the seven-day refresh-token expiry — the 100-user caps, verification, and the refresh-token rules (`access_type=offline`, `prompt=consent`, 100 tokens per account per client, six-month idle expiry). If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Contacts-specific ones: | Input | Notes | | --- | --- | | **Read-only or read-write?** | Decides `contacts.readonly` vs `contacts` — there is no narrower write scope (§1) | | **Does the product need "other contacts"?** | A separate scope and separate methods, or the app sees far fewer people (§2, §3) | | **Does it need the Workspace domain directory?** | A separate scope, Workspace-only, and an admin switch can turn it off (§2) | | **Consumer Gmail users, one Workspace customer, or a multi-tenant connector?** | Directory features are meaningless for consumer accounts (§2) | | **Contact groups: read, or also write?** | Group writes need the full `contacts` scope (§6) | | **Incremental sync or full re-list each time?** | Sync tokens expire in seven days — decide the fallback now (§5) | | **Expected contacts per user and sync frequency** | Quota is per-user and the full-sync quota cannot be raised (§7) | ## Quick Start 1. Work the base skill's console steps, and **enable the People API** on the project. A perfectly configured client against a project with the API disabled fails on the first call, not at authorization. 2. Settle the scope set before touching Data Access: §1 (the family and tiers), §2 (which corpus each unlocks). 3. Confirm with the owner that **sensitive-scope verification** is planned — brand, justifications, demo video. There is no security assessment to budget for (§1). 4. If the directory is in scope, confirm the customers are on Workspace and the admin sharing switch is on (§2). 5. Declare exactly the scopes you will request, and no more; request exactly what you declared. 6. Finish the base skill's capture, round-trip and handoff steps, adding the Contacts checks in §9. ## People API platform state (verified 2026-09-20 — re-verify before trusting) - **The People API (`people.googleapis.com/v1`) is the only current API for a user's contacts.** The GData Contacts API v3 and its `https://www.google.com/m8/feeds` scope were **turned down on 19 January 2022** (§8). - **No People API scope is on Google's restricted list.** That list covers Gmail, Drive, Fit, Chat, Data Portability, Photos Ambient and Health — checked scope by scope on this date. Contacts and directory scopes are **sensitive**: verification, yes; CASA, no. - **Three corpora, three scopes**: saved contacts (`contacts` / `contacts.readonly`), "other contacts" (`contacts.other.readonly`), Workspace directory (`directory.readonly`). None implies another (§2). - **`personFields` / `readMask` is required on every read.** Omitting it is a 400, not a default (§4). - **Sync tokens expire seven days after the full sync**, with reason `EXPIRED_SYNC_TOKEN` (§5). - **The first page of a full sync carries an extra quota that is fixed and cannot be increased**; exceeding it returns 429 (§7). If the scope tables or method list do not look like this, stop and report what you actually see. ## 1. The scope family and what each one costs | Scope | Grants | Tier | | --- | --- | --- | | `https://www.googleapis.com/auth/contacts` | "See, edit, download, and permanently delete your contacts" — the only write scope | **Sensitive** | | `https://www.googleapis.com/auth/contacts.readonly` | "See and download your contacts" | **Sensitive** | | `https://www.googleapis.com/auth/contacts.other.readonly` | "See and download contact info automatically saved in your 'Other contacts'" — read-only, always | **Sensitive** | | `https://www.googleapis.com/auth/directory.readonly` | "See and download your organization's Google Workspace directory" | **Sensitive** | | `https://www.googleapis.com/auth/profile.emails.read` | Email addresses on the signed-in user's own profile | See the note below | | `https://www.googleapis.com/auth/userinfo.email`, `.../userinfo.profile`, `openid` | Identity of the signed-in user | **Non-sensitive** | **None of these is restricted.** That is the single most valuable fact in this file: the annual CASA security assessment, the assurance-level negotiation and the permitted-application-type gate that dominate a Drive or Gmail project simply do not apply to Contacts. What remains is ordinary sensitive-scope verification — a brand check, a per-scope justification, and an unlisted demo video of the consent flow — which the base skill covers. **The tier is still sensitive, not free.** An app requesting `contacts.readonly` goes through review, shows the unverified-app screen until it passes, and is capped at 100 lifetime users while unverified. Plan verification into launch. **`profile.emails.read` is real but oddly documented.** It appears in the authorization block of the People API's own method references (`people.get`, `people.getBatchGet`), but it is **not** on Google's public OAuth 2.0 Scopes reference, which lists `user.emails.read` for the People API instead. Treat it as a live but legacy-flavoured scope: before declaring it, paste the exact string into the Data Access picker and read the tier the console gives it, and if the console does not recognise it, use `user.emails.read` or drop it — for the signed-in user's own address, `userinfo.email` is non-sensitive and usually enough. The other `user.*.read` profile scopes on the same family (`user.addresses.read`, `user.birthday.read`, `user.emails.read`, `user.gender.read`, `user.organization.read`, `user.phonenumbers.read`) each unlock one field group on the **signed-in user's own profile**. They do nothing for contacts, and each one added widens the review. Request one only when a named feature needs that field. ### There is no narrow write scope Drive has `drive.file`; Gmail has `gmail.send`. **Contacts has nothing equivalent.** The ladder is exactly two rungs: - `contacts.readonly` — read everything in the address book. - `contacts` — read, create, update **and permanently delete** everything in the address book. An app that only needs to create contacts must still request the scope that can delete them all, and the consent screen says so in those words. That is a product conversation to have before the first customer sees the screen, not a console setting. If writes are occasional, consider whether a read-only connection plus an out-of-band write path serves better than asking every customer for delete rights. ## 2. Three corpora, and why an app sees fewer people than the user This is the defect report that arrives after launch: *"it only imported 40 of my 3,000 contacts."* Almost always, nothing is broken. | Corpus | What it is | Scope | Methods | | --- | --- | --- | --- | | **Saved contacts** ("connections") | What the user deliberately saved — the `myContacts` group and any groups they made | `contacts.readonly` or `contacts` | `people.connections.list`, `people.searchContacts`, `people.get` | | **Other contacts** | Addresses Google auto-saved from the user's interactions, belonging to **no contact group** | `contacts.other.readonly` | `otherContacts.list`, `otherContacts.search`, `otherContacts.copyOtherContactToMyContactsGroup` | | **Directory** | The Workspace domain's profiles and shared domain contacts | `directory.readonly` | `people.listDirectoryPeople`, `people.searchDirectoryPeople` | **No scope implies another, and no method spans corpora.** `contacts.readonly` does not return other contacts. `contacts.other.readonly` does not return saved contacts. `directory.readonly` returns neither. An app that requests only `contacts.readonly` and calls only `connections.list` is behaving correctly and returning a small number, because most people a consumer Gmail user "has" were never saved — they are other contacts, which is exactly what the user sees in Gmail's autocomplete and at the Other contacts view in the Contacts UI. **Other contacts are read-only, and deliberately thin.** Google restricts the fields the API will return for the CONTACT source to `names`, `emailAddresses`, `phoneNumbers`, `photos` and `metadata` — full name, email, phone, and nothing more. There is no create, update or delete; the only write is `otherContacts.copyOtherContactToMyContactsGroup`, which promotes one into the saved address book (and therefore needs the `contacts` scope for the destination). Do not design a sync that expects to enrich or edit them. **Directory people need a Workspace account and an administrator's cooperation.** The directory methods are domain-scoped: a consumer Gmail account has no directory, and the call returns nothing useful however the scopes look. Google's own guide states that **domain administrators must enable external contact and profile sharing** for the API to reach domain data — so a Workspace customer whose admin has that off gets an empty directory with a valid token and a correctly declared scope. That is an admin-side fix, and admin-console changes of this kind can take up to 24 hours to propagate. Pass sources explicitly: `DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE` for the org's users and `DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT` for shared domain contacts — they are different populations and most apps want both. **There is no domain-wide "list every user's contacts" call.** Contacts are per-user: either each user authorizes, or a service account with domain-wide delegation impersonates them one at a time. Nothing in the People API returns one admin view of everyone's address books. ## 3. Choosing the read method | Method | Use it for | Page size | Sync tokens | | --- | --- | --- | --- | | `people.connections.list` | The full saved address book, and incremental sync | 1–1000, default 100 | **Yes** (`requestSyncToken`) | | `otherContacts.list` | The full "other contacts" corpus, and incremental sync | 1–1000, default 100 | **Yes** | | `people.listDirectoryPeople` | The whole domain directory, and incremental sync | 1–1000, default 100 | **Yes** | | `people.searchContacts` | A typeahead over saved contacts | default 10, **capped at 30** | No | | `otherContacts.search` | A typeahead over other contacts | default 10, **capped at 30** | No | | `people.searchDirectoryPeople` | A typeahead over the directory | 1–500, default 100 | No | | `people.getBatchGet` | Hydrating known resource names | **max 200 names per call** | n/a | Three things to know before wiring search into anything: - **Search is prefix matching, not substring.** A person named "foo name" matches `f`, `fo`, `foo`, `foo n`, `nam` — and does **not** match `oo n`. Users type substrings and conclude the search is broken. - **Search needs a warmup.** Google documents sending a search request with an **empty query first** to update the cache; without it, early results are stale or thin. Read `searchContacts` results only after the warmup. - **Search is not a backfill.** Capped at 30 results with no sync token, it is a typeahead endpoint. Full ingestion belongs to the `list` methods. `people.connections.list` only accepts `people/me` as the resource name — you list the authorizing user's connections and nobody else's. ## 4. `personFields` and `readMask` — required, and not interchangeable Every read takes a field mask, and **it is required**: `people.connections.list` and `people.getBatchGet` return a **400** when `personFields` is missing. There is no default set. - `people.connections.list`, `people.get`, `people.getBatchGet` spell it **`personFields`**. - `otherContacts.list`, `otherContacts.search`, `people.searchContacts`, `listDirectoryPeople`, `searchDirectoryPeople` spell it **`readMask`**. Same idea, different parameter name, and passing the wrong one is a 400 that reads like a typo because it is one. The value is a comma-separated list from a fixed vocabulary (`addresses`, `ageRanges`, `biographies`, `birthdays`, `calendarUrls`, `clientData`, `coverPhotos`, `emailAddresses`, `events`, `externalIds`, `genders`, `imClients`, `interests`, `locales`, `locations`, `memberships`, `metadata`, `miscKeywords`, `names`, `nicknames`, `occupations`, `organizations`, `phoneNumbers`, `photos`, `relations`, `sipAddresses`, `skills`, `urls`, `userDefined`). Three traps: - **The mask for other contacts is much shorter.** For `READ_SOURCE_TYPE_CONTACT` only `names`, `emailAddresses`, `phoneNumbers`, `photos` and `metadata` are accepted; `otherContacts.search` accepts only `names`, `emailAddresses`, `phoneNumbers` and `metadata`. A mask copied from `connections.list` fails here. - **`sources` is not the same thing as the mask**, and the defaults differ per method. `otherContacts.list` will not accept `READ_SOURCE_TYPE_PROFILE` on its own — a profile source must be paired with the contact source. - **Omitting `metadata` breaks sync.** Deletions and change tracking arrive inside metadata (§5); a mask that leaves it out gives a sync that silently never deletes anything. Field masks are also the cheapest quota lever there is: ask for the field groups the product maps and no others. ## 5. Sync tokens and their seven-day expiry `connections.list`, `otherContacts.list` and `listDirectoryPeople` all support incremental sync the same way: set `requestSyncToken=true` on a full sync, read `nextSyncToken` **from the last page only**, and pass it as `syncToken` next time. What breaks in production: - **Sync tokens expire seven days after the full sync.** An expired token fails with an `ErrorInfo` whose reason is `EXPIRED_SYNC_TOKEN` (HTTP 410 in Google's guides), and the only recovery is a full sync with no `syncToken`. Any connector syncing less often than weekly — or paused for a week by an outage, a rate limit or a disabled account — must treat the full re-sync as a normal path, not an error branch. - **Deletions only appear in a sync response**, as a person with `PersonMetadata.deleted` set to `true`. Outside a sync, deleted contacts simply stop appearing, so a full-list-and-diff strategy must detect removals itself. - **Every other parameter must match the first call.** When paginating or syncing, the field mask, sources, sort order and page size have to be identical to the call that produced the token. Changing the mask to map one new field invalidates the sync, so plan a full re-sync into any field-mapping change. - **Writes propagate for several minutes** into sync results, and Google states plainly that **incremental syncs are not intended for read-after-write**. A write-then-sync test that shows nothing is usually just early. - The **default sort order is `LAST_MODIFIED_ASCENDING`**; `FIRST_NAME_ASCENDING` and `LAST_NAME_ASCENDING` exist but change the pagination contract as above. ## 6. Contact groups Groups are labels on contacts, exposed as their own resource, and they follow the same two-rung scope ladder: `contacts.readonly` reads them, `contacts` writes them. `contactGroups.list` pages 1–1000 with a **default of 30** — smaller than every other list default here, and easy to mistake for "the user only has 30 labels" — and it supports sync tokens and a `groupFields` mask (`clientData`, `groupType`, `memberCount`, `metadata`, `name`). The membership rules are where integrations trip: - A group is `USER_CONTACT_GROUP` or `SYSTEM_CONTACT_GROUP`. **Only `contactGroups/myContacts` and `contactGroups/starred` accept member additions**; other system groups are deprecated and members can only be *removed* from them. - `contactGroups.members.modify` takes at most **1000 resource names** across adds and removes combined. - **Creating a group whose name duplicates an existing one returns 409.** A sync that re-creates groups by name needs to reconcile first. - Saved contacts live in groups; **other contacts are by definition in none** (§2). Mapping "groups" onto a unified tags or lists concept works for the first corpus only. ## 7. Quota and per-user limits **Google publishes no per-minute numbers for the People API.** There is no public quota table equivalent to Calendar's or Drive's; the documentation speaks of request classes ("critical read requests", "critical write requests") without values. The real limits for a project are the ones shown in the Cloud Console under the People API's quota page, which is also where an increase is requested. Do not quote a figure from a blog post into a capacity plan — read the console. What *is* documented, and matters: - **The first page of a full sync has an additional quota, it is fixed, and it cannot be increased.** Exceeding it returns **429**. A connector that re-syncs many tenants on the same schedule, or that retries a failed full sync in a tight loop, will hit this and cannot buy its way out — stagger full syncs and back off. - **Quota is enforced per end user as well as per project.** One heavy tenant can be throttled while every other customer is fine, which looks like a broken connection rather than a rate limit. - **A quota failure does not always arrive as 429.** Google APIs also return **403 with reason `rateLimitExceeded` or `userRateLimitExceeded`** (and `RATE_LIMIT_EXCEEDED` in the newer error details) for quota. Treating that 403 as an authorization failure is the classic misdiagnosis: it triggers a pointless token refresh or, worse, disconnects a healthy customer. Map it to backoff. - Page sizes are the other lever: 1000 per page on the list methods, 200 resource names per `getBatchGet`, 1000 resource names per group-membership modify. ## 8. The old Contacts API — dead, except the part that is not - **Contacts API v3 (GData, `https://www.google.com/m8/feeds`) was turned down on 19 January 2022.** Its single scope covered both personal contacts and directory data; the People API split that into `contacts` (personal) and `directory.readonly` (domain). Any runbook, library or scope string mentioning `m8/feeds` is describing a dead API. - **Apps Script's Contacts service shut down on 31 January 2025**, replaced by the People API advanced service. Old internal automations may still be written against it. - **The Domain Shared Contacts API survives.** Google's own migration guide states it is not affected by the Contacts API turndown, and it is still documented as current: a GData/XML, Workspace-admin-only API for **external** contacts shared across a domain, enabled from the Admin console. So "the old GData contacts APIs are gone" is not quite true, and if a customer's requirement is *shared external domain contacts* rather than users' personal contacts, the People API is not the right surface. Google warns explicitly against using it for a domain's own users — that produces duplicates. - A separate **Contact Delegation API** exists for delegating one Workspace user's contacts to another. It is not part of the People API and does not change any scope decision here. ## Product fact — what the Contacts connector asks for > **As of 2026-09-20, Unified.to's Google Contacts connector requests:** > > - **Read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/profile.emails.read`, > `https://www.googleapis.com/auth/contacts.readonly`, `https://www.googleapis.com/auth/contacts.other.readonly` > - **Write:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/contacts` > - **Login/identity only:** `openid`, `profile`, `email` > > Three consequences worth reading twice: > > - **Nothing requested is restricted**, so an app registered for this connector needs sensitive-scope verification > and **no CASA assessment**. This is the cheap Google integration. > - **The write configuration does not include `contacts.other.readonly`.** A connection made for writing therefore > cannot read the "other contacts" corpus at all — the §2 shortfall arrives as a product difference between a read > connection and a write connection, not as a bug. > - **Neither configuration requests `directory.readonly`.** Workspace directory people are out of reach on this > connector today; asking for them is a connector change, not a console setting. > > Other connector-side behaviour to know: it ships **platform-owned Google OAuth credentials**, so a customer only > needs their own Cloud client when they specifically want their own (own branding on the consent screen, their own > verification posture, their own quota). It sends `access_type` and `prompt` on the authorize URL — the two > parameters the base skill flags as the usual cause of a missing refresh token — exchanges and refreshes against > Google's standard endpoints, and treats Google's 403 quota responses as rate limiting to back off from rather than > as an authorization failure (§7). A **Google service-account credential** path also exists as an alternative to the > user OAuth flow, requesting the full `contacts` scope, which is the domain-wide-delegation route rather than a > per-user consent. > > Scope sets change. This note is dated, not live; confirm with the connector's owner before submitting anything. ## 9. Contacts-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Make one real `people.connections.list` call with an explicit `personFields` and confirm the People API is enabled on the project and returns data. 2. Compare the count against what the user sees in the Contacts UI **before** calling it a bug — if the gap is large, check whether the missing people are other contacts (§2) and whether `contacts.other.readonly` was requested, declared *and* granted. 3. Inspect the granted `scope` in the token response against what was requested. Google's granular-consent screen lets a user approve contacts and decline other contacts; the result is a valid token that 403s on one of the three corpora, at the first call and not at consent. 4. Test the **seven-day sync path deliberately**: run a full sync, keep the token, and confirm the code takes the full re-sync branch on `EXPIRED_SYNC_TOKEN` rather than crashing. Do not wait a week to find out. 5. Confirm `metadata` is in the field mask and that a deleted contact comes back with `deleted: true` on the next incremental sync (§5). 6. If the directory matters, test with a **Workspace** account whose admin has contact and profile sharing enabled — a consumer account cannot prove anything here. 7. If writes are in scope, confirm the consent screen wording ("permanently delete") has been seen and accepted by whoever owns the customer relationship (§1). | Symptom | Cause | | --- | --- | | Every People call fails although scopes look right | People API not enabled on the project | | 400 on every read | Missing `personFields` / `readMask`, or the wrong one of the two for that method (§4) | | Far fewer people than the user expects | Other contacts are a separate corpus and scope (§2) | | Directory calls return nothing on a valid Workspace token | Admin has not enabled contact/profile sharing, or the sources were not passed (§2) | | Directory calls return nothing on a consumer account | Consumer accounts have no directory (§2) | | One of three read paths 403s, the others work | Granular consent dropped that scope (§9.3) | | Sync suddenly fails after a quiet week | `EXPIRED_SYNC_TOKEN` — seven-day expiry, full re-sync required (§5) | | Sync never removes deleted contacts | `metadata` missing from the field mask, or diffing without a sync token (§5) | | Sync breaks right after a field-mapping change | Sync parameters must match the call that issued the token (§5) | | 429 on the first page of a full sync | The fixed full-sync quota — stagger syncs; it cannot be increased (§7) | | 403 `rateLimitExceeded` treated as an auth error | It is quota, not authorization — back off (§7) | | Search misses obvious matches | Prefix matching only, and the warmup request was skipped (§3) | | Group create returns 409 | A group of that name already exists (§6) | | Anything referencing `m8/feeds` | Contacts API v3, dead since 19 January 2022 (§8) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - The product needs to **create or update** contacts and nobody has accepted that the only write scope also grants permanent deletion, in those words, on the consent screen (§1). - Someone plans around `directory.readonly` without confirming the customers are on **Workspace** and that their admins will enable contact and profile sharing (§2). - A requirement turns out to be **shared external domain contacts** rather than users' personal contacts — that is the Domain Shared Contacts API and a different, admin-level integration (§8). - A capacity plan needs real quota numbers: read them from the project's console quota page, or ask, rather than quoting a figure (§7). - The sync design assumes tokens survive longer than seven days, or assumes a field-mask change is free (§5). - `profile.emails.read` is wanted but the Data Access picker does not recognise it or labels it unexpectedly (§1). - The People API scope tiers do not match the **platform state** section above. ## References Contacts/People-specific only; the base skill carries the generic Google OAuth references. Every URL verified to resolve on 2026-09-20. - People API overview — https://developers.google.com/people - Get ready to use the People API (enabling the API) — https://developers.google.com/people/v1/getting-started - Read and manage contacts (sync behaviour, field masks) — https://developers.google.com/people/v1/contacts - Read "Other contacts" — https://developers.google.com/people/v1/other-contacts - Read the domain directory (admin sharing requirement, sources) — https://developers.google.com/people/v1/directory - Read profiles — https://developers.google.com/people/v1/profiles - Contacts API (v3) migration guide — turndown date, scope split, Domain Shared Contacts exception — https://developers.google.com/people/contacts-api-migration - `people.connections.list` — page size, sync tokens, `EXPIRED_SYNC_TOKEN`, full-sync quota — https://developers.google.com/people/api/rest/v1/people.connections/list - `people.get` — the full authorization-scope block for People reads — https://developers.google.com/people/api/rest/v1/people/get - `people.getBatchGet` — 200-resource-name limit — https://developers.google.com/people/api/rest/v1/people/getBatchGet - `people.searchContacts` — prefix matching, warmup, 30-result cap — https://developers.google.com/people/api/rest/v1/people/searchContacts - `otherContacts.list` — readMask restrictions, sources rules — https://developers.google.com/people/api/rest/v1/otherContacts/list - `otherContacts.search` — https://developers.google.com/people/api/rest/v1/otherContacts/search - `people.listDirectoryPeople` — https://developers.google.com/people/api/rest/v1/people/listDirectoryPeople - `people.searchDirectoryPeople` — https://developers.google.com/people/api/rest/v1/people/searchDirectoryPeople - `contactGroups` resource (group types, system groups) — https://developers.google.com/people/api/rest/v1/contactGroups - `contactGroups.list` — page size default 30, groupFields, sync tokens — https://developers.google.com/people/api/rest/v1/contactGroups/list - `contactGroups.members.modify` — 1000-name limit, `myContacts` / `starred` — https://developers.google.com/people/api/rest/v1/contactGroups.members/modify - Apps Script: migrate from the Contacts service to the People API — https://developers.google.com/apps-script/migration/contacts-people - Domain Shared Contacts API (still current, Workspace admin, external contacts) — https://developers.google.com/admin-sdk/domain-shared-contacts - Contact Delegation API — https://developers.google.com/admin-sdk/contact-delegation ## google-docs-oauth-app URL: https://docs.unified.to/skills/google-docs-oauth-app/SKILL.md # Google Docs OAuth2 App Registration The Docs scopes themselves are the cheap part of this job: `documents` and `documents.readonly` are **sensitive**, not restricted, so on their own they cost a verification review and no security assessment. What makes a Docs integration expensive is the scope it almost always drags in beside them. **The Docs API addresses a document by ID and has no way to list or search for one.** Everything that finds a document — enumerating a user's documents, watching for new ones, exporting one to PDF or DOCX, reading its comments — is a Drive call. Pick the obvious Drive scope and the project lands in the restricted tier with an annual CASA assessment; pick `drive.file` and the product changes shape; pick neither and the product only works on documents whose IDs arrive from somewhere else. That fork is §2, and it is the whole point of this file. Settle it before anyone touches Data Access. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns the console mechanics and this file does not repeat them: - The Cloud project and organization, enabling APIs, and who may administer the project. - The Google Auth Platform pages (Branding, Audience, Data Access, Clients) and creating a **Web application** client. - Redirect-URI rules — exact matching, HTTPS only, the propagation delay — and the per-data-center callback list. - The generic scope model: declaring every scope on Data Access, the non-sensitive / sensitive / restricted tiers, the rule that the app's tier is its most sensitive scope, and `access_type=offline` + `prompt=consent` for refresh tokens. - The client secret shown and downloadable **only once at creation**, and add-then-disable rotation. - `Testing` vs `In production` — including the seven-day refresh-token expiry — the 100-user caps, verification, and the annual CASA assessment at assurance level AL1 or AL2. If you are reading only this file, you are missing all of the above. **Read `google-drive-oauth-app` too the moment a Drive scope enters the picture.** It owns the Drive scope tiers, the permitted-application-type gate that no paperwork can satisfy, the `drive.file` + Picker trade-off in full, shared drives, the verification exemptions, and the Search Console domain-ownership problem. This file says *whether* you need a Drive scope; that file says what one costs. ## Inputs to collect before you start The base skill lists the console inputs. These are the Docs-specific ones, and the first two decide the project's tier: | Input | Notes | | --- | --- | | **Where do document IDs come from?** | User picks / pastes / an upstream system supplies them, or the app must discover them. This is the Drive-dependency question (§2) | | **Read-only, or create and edit documents?** | `documents.readonly` vs `documents`; and on the Drive side `drive.readonly` vs `drive` | | **Structured content, or a rendered file?** | Structured parsing is Docs; PDF/DOCX/TXT export is a Drive call with its own scopes (§4) | | **Do tabs, headers/footers, footnotes, suggestions or comments matter?** | Changes what a read returns, and comments change the scope set (§5) | | **Expected read and write volume per customer** | Docs quotas are per-minute and per-user-per-project (§6) | | **Are any target documents "published to the web"?** | Published documents are unreachable by `documents.get` (§3) | ## Quick Start 1. Work the base skill's console steps, and **enable the Google Docs API** on the project — and the Google Drive API too if §2 lands on any Drive scope. A perfect client against a project with the API disabled fails at the first call, not at authorization. 2. Settle §2 — the Drive dependency — **before** declaring anything on Data Access. It decides sensitive vs restricted for the whole project. 3. If any Drive scope is restricted, switch to `google-drive-oauth-app` for the application-type gate, CASA ownership and the exemptions, and start verification now rather than after launch. 4. Declare the narrowest Docs scope the product needs, and check §2's redundancy trap before adding it at all. 5. Finish the base skill's capture, round-trip and handoff steps, adding the Docs checks in §7. ## Docs platform state (verified 2026-09-20 — re-verify before trusting) - **Docs scope tiers, from the Docs API's own scope page:** `https://www.googleapis.com/auth/documents` and `https://www.googleapis.com/auth/documents.readonly` are both **Sensitive**. The same page lists `drive.file` as **Recommended / Non-sensitive** and `drive` and `drive.readonly` as **Restricted**. There is no restricted Docs scope — the restricted tier only ever arrives via Drive. - **The `documents` resource exposes exactly three methods**: `documents.create`, `documents.get` and `documents.batchUpdate`. `get` and `batchUpdate` both require a `documentId`. There is **no list, no search and no change-notification method.** - **`documents.get` accepts any one of** `documents`, `documents.readonly`, `drive`, `drive.readonly` or `drive.file`. **`documents.create` and `documents.batchUpdate` accept any one of** `documents`, `drive` or `drive.file`. - **`files.export` — the only way to get a Docs file as PDF/DOCX/TXT — accepts only Drive scopes** (`drive`, `drive.file`, `drive.readonly`, `drive.meet.readonly`). It does **not** accept `documents` or `documents.readonly`. Exported content is limited to **10 MB**. - **Docs quotas:** 3,000 read requests/minute/project and 300/minute/user/project; 600 write requests/minute/project and 60/minute/user/project. If the scope tables do not look like this, stop and report what you actually see rather than proceeding. ## 1. The Docs scopes | Scope | Grants | Tier | | --- | --- | --- | | `https://www.googleapis.com/auth/documents.readonly` | See all the user's Google Docs documents | **Sensitive** | | `https://www.googleapis.com/auth/documents` | See, edit, create and delete all the user's Google Docs documents | **Sensitive** | Two things follow, and both are easy to get backwards: - **Neither is restricted.** A Docs integration that never touches Drive needs verification with a scope justification and a demo video, and **no security assessment and no annual renewal**. That is a materially cheaper project than a Drive one, and it is worth knowing before anyone budgets for CASA out of habit. - **Both are all-documents scopes.** `documents.readonly` reads *every* document the user can open, not a subset. The word "readonly" reduces the write risk, not the breadth. Google's own guidance on this page is to prefer a non-sensitive per-file scope where the product can live with one — which for Docs means `drive.file` (§2). ## 2. The Drive dependency — the decision that sets the project's tier **This is the centrepiece. Raise it as a product decision, do not make it on the owner's behalf.** The Docs API can `get` a document, `create` a blank one and `batchUpdate` one. All three are addressed by `documentId`. It cannot answer "which documents does this user have?", "which ones changed since Tuesday?", "what is this document called before I fetch it?" or "give me this as a PDF". Google's own Docs documentation points at Drive for all of it: `files.list` with `q: mimeType = 'application/vnd.google-apps.document'` to find documents, `files.copy` to duplicate one, `files.export` to render one, `files.get` for metadata. So the real question is not *which Docs scope* but **where document IDs come from**. There are three honest answers, and they cost wildly different amounts: | Path | Scopes | Tier and cost | What the product can do | | --- | --- | --- | --- | | **A. Discover through Drive** | `drive.readonly` (read) or `drive` (write), plus a Docs scope if you still want one | **Restricted** — verification **plus** annual CASA, **plus** the permitted-application-type gate | Enumerate, search, sync, watch, export, read comments. The full product. | | **B. Per-file through the Picker** | `drive.file` (covers Docs `get`, `create` and `batchUpdate` on picked files, and `files.export`) | **Non-sensitive** — basic verification, no assessment, no annual renewal | Only files the user explicitly picks, files shared with the app, and files the app created. No enumeration, ever. | | **C. No Drive scope at all** | `documents.readonly` or `documents` alone | **Sensitive** — verification, no assessment | Read or edit a document **whose ID the app already has** — pasted URL, upstream record, a document the app created. No listing, no export, no comments, no metadata beyond what the document resource itself carries. | Path **A** is what "sync the customer's documents" means, and the Drive skill's §2 application-type gate applies before any budget conversation: restricted Drive scopes are limited to backup-and-sync, productivity-and-education, and reporting-and-security apps, and an app outside those categories cannot obtain them at any price. Path **B** is a different product, not a cheaper configuration — the user hands over one file at a time through the Google Picker. Worth knowing: the Docs API supports `drive.file` on **all three** of its methods, so path B loses no Docs capability at all. Everything it loses, it loses on the Drive side: enumeration and search. Path **C** is the one people forget, and for some integrations it is exactly right. A product that operates on a document the user pasted a link to, or on documents it created itself and recorded the IDs of, never needs a Drive scope and never needs a security assessment. Note that `documents.create` returns the created document — including its `documentId` — so an app that creates its own documents can keep working without ever listing anything. ### The redundancy trap **`drive.readonly` on its own already authorizes `documents.get`; `drive` on its own already authorizes `documents.create` and `documents.batchUpdate`.** An app that has already accepted restricted Drive scopes gains **nothing** by also declaring `documents.readonly` or `documents`. It adds a line to the consent screen, a scope to justify in the verification review, and a scope to re-justify at every renewal — for zero new capability. Check this against the actual call list before declaring; it is one of the few scope reductions that costs nothing. The inverse trap is worse and quieter: **`documents.readonly` does not authorize `files.export`**, so an app that declared Docs scopes only and then adds "download as PDF" discovers mid-sprint that the feature needs a Drive scope and, unless it can use `drive.file`, a whole restricted-tier review. ### Switching later is a re-consent event Moving an existing integration between these paths changes the requested scope set, which re-consents every connected customer, and moving to path B adds a Picker UI. Say so plainly rather than proposing it as a configuration tweak. ## 3. The document model, and why index arithmetic breaks You do not need to implement Docs to register its OAuth client, but the shape of the API explains most of the support tickets that follow, and it is what a demo video for verification has to show working. - A document is a tree, not a string: `Body` → a sequence of `StructuralElement`s (`Paragraph`, `Table`, `TableOfContents`, `SectionBreak`), and within a paragraph a sequence of `ParagraphElement`s (`TextRun`, `AutoText`, `PageBreak`, `InlineObjectElement`…). Reading text means walking that tree; there is no "plain text" field. - Positions are **indexes into a segment**, where a segment is the body, a header, a footer or a footnote, each with its own index origin. Indexes are measured in **UTF-16 code units**, so an emoji or any astral-plane character consumes two. Naive character offsets computed in a language with a different string model silently misplace edits. - `documents.batchUpdate` applies its requests **in order and atomically** — one invalid request fails the whole batch and applies none of it. Because each insert or delete shifts every later index, Google's own advice is to **order requests in descending index**, which removes the need to recompute offsets within a batch. - **Across calls, indexes go stale.** Any collaborator — or a second process of your own — editing between your `get` and your `batchUpdate` invalidates the offsets you computed, and the API will happily apply them to the wrong place. The API's answer is `WriteControl` on `batchUpdate`: save `revisionId` from the `get`, then send either `requiredRevisionId` (fail the write if the document moved) or `targetRevisionId` (let the server merge your changes against the collaborator's). An integration that writes to shared documents and sets neither is not "usually fine"; it is silently corrupting text under concurrency. - **`documents.create` ignores any content in the request** — it creates a blank document with the given title, and everything else arrives via `batchUpdate`. Create-then-populate is two calls, always. - **Published documents are unreachable.** A document published to the web gets a different, public ID, and `documents.get` returns **404** for it (as does `files.copy`). Only the original `documentId` works, and there is no documented way to derive it from a published URL. ## 4. Exporting vs reading structured content Two genuinely different operations, with different scopes: - **Structured read** — `documents.get` returns the JSON document tree. Authorized by a Docs scope *or* a Drive scope. This is what you use for text extraction, formatting-aware conversion, and anything that needs to know where a paragraph starts. - **Export to a file** — `files.export` on the **Drive** API renders a Docs file to PDF, DOCX, ODT, RTF, TXT, EPUB, HTML, Markdown and so on. It is **not** a Docs API call and it **does not accept the Docs scopes**: `drive`, `drive.file`, `drive.readonly` or `drive.meet.readonly` only. Exported content is capped at **10 MB**; large documents fail on size rather than on permission, and the fix is chunking or a different format, not more scopes. So "let users download the document as a PDF" is a Drive requirement dressed as a Docs feature. If the app is on path C (§2), that feature alone moves it onto a Drive scope — `drive.file` if the document was picked or created by the app, otherwise a restricted one. ## 5. Tabs, headers and footers, suggestions, comments Only one of these changes the scope set. The rest change what a read returns, which is where "the API is missing half our content" reports come from. **Tabs — no scope effect, large correctness effect.** A document can hold multiple tabs, each with its own content tree. `documents.get` takes `includeTabsContent`: - `true` → content arrives under `document.tabs`, and the legacy top-level fields (`document.body`, `headers`, `footers`, `footnotes`, `namedRanges`, `inlineObjects`…) are left **empty**. - omitted or `false` → the legacy fields are populated **from the first tab only**, and `document.tabs` is empty. Content in every other tab is silently absent. Nothing errors. Child tabs nest, so reading a whole document means walking the tab tree, not iterating a flat list. On `batchUpdate`, a request that does not name a tab is applied, in most cases, to the first tab. A field mask that references `tabs` implicitly turns `includeTabsContent` on. **Headers, footers and footnotes — no scope effect.** They are separate segments with their own index origins, hanging off the document (or off each tab when `includeTabsContent` is `true`). No extra scope; just do not assume body indexes address them. **Suggestions — no scope effect, but the *access level* changes the answer.** `documents.get` takes `suggestionsViewMode`, and when you omit it the API picks a default appropriate to the current user's privileges. The documented consequence matters: **indexes differ between suggestion view modes**, and only `SUGGESTIONS_INLINE` returns indexes valid for a subsequent `batchUpdate`. A read-only integration that later gains write capability, or a connection authorized by a viewer rather than an editor, can compute offsets from a different view of the document than the one it writes into. Set the mode explicitly rather than inheriting a default that varies per user. **Comments — this one does change the scope set.** Docs comment and suggestion *threads* are not a Docs API resource in general availability; reading them is the **Drive** comments API, which accepts only `drive`, `drive.file`, `drive.readonly` or `drive.meet.readonly`. `documents.get` has a `commentsViewMode` parameter, but it is **Developer Preview**, and it requires `includeTabsContent: true` (or a field mask referencing `tabs`). So "show comments" is a Drive scope requirement today, and a preview-program dependency if you want it inline. Do not plan a launch on it. ## 6. Quotas | | Per minute per project | Per minute per user per project | | --- | --- | --- | | **Read requests** | 3,000 | 300 | | **Write requests** | 600 | 60 | Points that matter when sizing an integration: - **The per-user-per-project limits are the binding ones** for a multi-tenant connector doing an initial sync: 300 reads/minute for one customer's user, regardless of how much project headroom is free. - **A whole `batchUpdate` counts as one request**, however many subrequests it carries. Batching is a quota strategy, not only a latency one — and it is also how you keep an edit atomic. - Exceeding a quota generally returns **429**, and the documented remedy is truncated exponential backoff. Google sometimes surfaces quota exhaustion as a **403** with a `rateLimitExceeded` / `userRateLimitExceeded` reason instead; treating that as an auth failure and re-authorizing is the classic wrong response. It is a backoff, not a token problem. - These are Docs quotas only. Discovery, export and comments consume **Drive** quota, which is counted separately. ## Product fact — what the Docs connector asks for > **As of 2026-09-20, Unified.to's Google Docs connector requests:** > > - **File read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/drive.readonly`, > `https://www.googleapis.com/auth/drive.labels.readonly`, `https://www.googleapis.com/auth/documents.readonly` > - **File write:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/drive`, > `https://www.googleapis.com/auth/drive.labels.readonly`, `https://www.googleapis.com/auth/documents` > - **Employee-directory read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/drive.readonly` > - **Login/identity only:** `openid`, `profile`, `email` > > **Resulting tier: restricted.** `drive.readonly` and `drive` are restricted scopes, so every configuration except > login-only puts the registering app in the restricted tier — verification **plus** an annual CASA assessment, plus > the permitted-application-type gate in `google-drive-oauth-app` §2. The Docs scopes alongside them are sensitive and > do not raise the tier; `drive.labels.readonly` is non-sensitive. Note that this is a **path A** connector (§2): it > reaches documents through the Drive file surface, which is why Drive scopes are load-bearing rather than optional — > and, per the redundancy trap in §2, the Docs scopes it also requests are already implied by the Drive ones it holds. > > There is **no `drive.file` variant**, so the non-sensitive path B is not available without a change on the connector > side. The connector does support a **Google service-account credential** path as an alternative to the user OAuth > flow, minting tokens for the documents, drive and drive-labels-read scopes — the domain-wide route discussed in > `google-drive-oauth-app` §5. > > Two auth quirks worth carrying into the handoff: the authorize request is built with `access_type` and `prompt` > parameters, so the refresh-token rules in the base skill's §5 are already wired in and should be verified rather > than assumed; and the connector remaps Google's 403-with-quota-reason responses to 429 so reads back off instead of > being mistaken for an authorization failure (§6). > > Scope sets change. This note is dated, not live — **confirm the current set with the connector's owner** before > declaring scopes or submitting anything for review. ## 7. Docs-specific end-to-end checks On top of the base skill's authorize → refresh round trip, and the Drive checks in `google-drive-oauth-app` §7 if a Drive scope is in play: 1. **Enable both APIs you actually call.** Docs API always; Drive API if discovery, export or comments are in scope. A missing enablement fails at the first call with `403 accessNotConfigured`, long after consent looked fine. 2. `documents.get` one real document and confirm the JSON tree comes back — not just a 200 on the token exchange. 3. **Test discovery the way the product will do it.** If the app is on path B, confirm a Picker-selected document is readable **and** that a never-picked one is not. Testing only against a document the app itself created will pass on `drive.file` and prove nothing. 4. **Read a multi-tab document with `includeTabsContent` both ways** and confirm the code reads all tabs. A single-tab test document hides this defect completely. 5. If export is a feature, run a real `files.export` and confirm it is authorized by the Drive scope you declared — and test a document large enough to approach the 10 MB cap. 6. If the app writes, do a `get` → `batchUpdate` round trip **with `WriteControl` set**, and prove the failure path by editing the document between the two calls. 7. Inspect the granted `scope` in the token response against what was requested; a granular-consent screen lets the user drop scopes, and a partial grant fails later on a call, not at consent. | Symptom | Cause | | --- | --- | | Auth succeeds, every Docs call fails | Google Docs API not enabled on the project | | "We can't find any of the customer's documents" | The Docs API has no list method — discovery needs a Drive scope (§2) | | `documents.get` works, PDF export 403s | `files.export` does not accept the Docs scopes; it needs a Drive scope (§4) | | Only the first tab's content ever appears | `includeTabsContent` omitted — the legacy fields carry the first tab only (§5) | | `404` on a document the user can clearly open | A published-to-the-web document ID; only the original `documentId` works (§3) | | Edits land in the wrong place under collaboration | Stale indexes — no `WriteControl`, or offsets computed in a different suggestions view mode (§3, §5) | | Offsets drift on documents containing emoji | Indexes are UTF-16 code units; astral characters consume two (§3) | | Created document is empty despite content in the request | `documents.create` ignores supplied content — populate with `batchUpdate` (§3) | | `403` with `rateLimitExceeded` on a working connection | Docs quota, not authorization — back off, do not re-authorize (§6) | | Export fails only on long documents | 10 MB export cap (§4) | | Comments never appear | Drive comments API and its Drive scopes; inline `commentsViewMode` is Developer Preview (§5) | ## Stop and ask Beyond the base skill's list and the Drive skill's list: - **Nobody has answered where document IDs come from.** Register nothing until §2 is settled — it decides sensitive versus restricted for the entire project, and it is close to irreversible once customers are connected. - The product needs Drive-wide discovery and there is **no confirmed budget and named owner for the CASA assessment**, or the app may not qualify as a **permitted application type** for restricted Drive scopes. - Someone proposes adding `documents` / `documents.readonly` to an app that already holds `drive` / `drive.readonly` — confirm against the real call list first; it is usually pure review surface for no capability (§2). - A launch plan depends on **Developer Preview** comment/suggestion-thread functionality (§5). - Someone wants to move live connections between §2's paths — that re-consents every customer and, for path B, changes the product's UI. - The Docs scope tiers or the method-to-scope mapping do not match the **Docs platform state** section above. ## References Docs-specific only; the base skill carries the generic Google OAuth references and `google-drive-oauth-app` carries the Drive scope, CASA and shared-drive references. Verified to resolve on 2026-09-20. - Choose Google Docs API scopes (the `documents` / `documents.readonly` tiers) — https://developers.google.com/workspace/docs/api/auth - Google Docs API concepts: document, document ID, and managing Docs files through Drive — https://developers.google.com/workspace/docs/api/concepts/document - Structure of a Google Docs document (structural elements, segments, UTF-16 indexes) — https://developers.google.com/workspace/docs/api/concepts/structure - Requests and responses (the three methods, atomic batches, published-document 404) — https://developers.google.com/workspace/docs/api/concepts/request-response - Best practices (edit backwards, plan for collaboration, `WriteControl`, take tabs into account) — https://developers.google.com/workspace/docs/api/how-tos/best-practices - Batch requests (one batch = one request against quota) — https://developers.google.com/workspace/docs/api/how-tos/batch - Work with tabs (`includeTabsContent` and the legacy first-tab representation) — https://developers.google.com/workspace/docs/api/how-tos/tabs - Work with comments and suggestions (`SuggestionsViewMode`, `commentsViewMode`, index differences) — https://developers.google.com/workspace/docs/api/how-tos/suggestions - `documents.get` reference (authorization scopes, `includeTabsContent`, `suggestionsViewMode`) — https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/get - `documents.create` reference (scopes; supplied content is ignored) — https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/create - `documents.batchUpdate` reference (scopes, `WriteControl`) — https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/batchUpdate - Docs API usage limits (per-minute read/write quotas, backoff) — https://developers.google.com/workspace/docs/api/limits - Troubleshoot Docs authentication and authorization — https://developers.google.com/workspace/docs/api/troubleshoot-authentication-authorization - Drive `files.export` reference (Drive-only scopes) — https://developers.google.com/workspace/drive/api/reference/rest/v3/files/export - Download and export files (the 10 MB export cap) — https://developers.google.com/workspace/drive/api/guides/manage-downloads - Drive export MIME types for Docs files — https://developers.google.com/workspace/drive/api/guides/ref-export-formats - Search for files and folders (the `mimeType` query used for document discovery) — https://developers.google.com/workspace/drive/api/guides/search-files - Drive `comments.list` reference (comments are a Drive scope) — https://developers.google.com/workspace/drive/api/reference/rest/v3/comments/list - Google Workspace Developer Preview Program — https://developers.google.com/workspace/preview ## google-drive-oauth-app URL: https://docs.unified.to/skills/google-drive-oauth-app/SKILL.md # Google Drive OAuth2 App Registration Registering the client is quick. **The Drive scope decision you make while doing it can cost a funded external security engagement and a recurring annual one, or nothing at all**, and it is close to irreversible once customers are connected. The Drive scopes that let an app see a user's existing files — `drive` and `drive.readonly` — are **restricted scopes**, which means verification *plus* an annual third-party CASA security assessment. The non-sensitive alternative, `drive.file`, needs none of that — but it only ever sees files the user explicitly hands to the app, which is a different product, not a cheaper configuration. That decision, and the eligibility rule behind it, is what this file is for. Everything else is shared. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns all the console mechanics, and this file does not repeat them: - Choosing/creating the Cloud project and organization, enabling APIs, and who may administer it. - Configuring the app on the Google Auth Platform (Branding, Audience, Data Access, Clients) and creating a **Web application** client — the only type that yields a usable client secret. - Redirect-URI rules (exact matching, HTTPS-only, propagation delay) and the platform's per-data-center callback list. - The generic scope model: declaring every scope on Data Access, the non-sensitive / sensitive / restricted tiers, and the rule that the app's tier is its most sensitive scope. - The client secret being shown and downloadable **only once at creation** (it is stored hashed), and add-then-disable rotation. - `Testing` vs `In production` — including the seven-day refresh-token expiry — the unverified-app screen and the 100-user caps, brand and scope verification, and the annual CASA assessment at assurance level **AL1 or AL2**. If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Drive-specific ones, and they decide the cost of the project: | Input | Notes | | --- | --- | | **Read-only or read-write?** | Decides `drive.readonly` vs `drive` — both restricted (§1) | | **Can the product work with per-file access?** | The `drive.file` escape hatch. Ask **before** registering, not after (§3) | | **What category of app is this?** | Restricted Drive scopes are limited to three application types (§2) | | **Is there budget and a named owner for a CASA assessment?** | Blocking for restricted scopes | | **Consumer accounts, a single Workspace customer, or a multi-tenant connector?** | Decides whether a verification exemption applies at all (§5) | | **Whose domain hosts the OAuth callback?** | Must be provable in Search Console by the project's owner (§6) | | **Do shared drives matter?** | Changes what "it can't see our files" means (§4) | ## Quick Start 1. Work the base skill's console steps, and **enable the Google Drive API** on the project — a correctly configured client against a project with the API disabled fails at the first API call, not at authorization. 2. Before clicking anything on Data Access, settle the scope decision: §1 (tiers), §2 (eligibility), §3 (`drive.file`). 3. If any scope is restricted, confirm the app is a permitted application type **and** that CASA is funded and owned, then start verification now rather than after launch. 4. Check the exemptions in §5 — one of them may remove the whole assessment. 5. Settle the callback domain and its Search Console ownership before registering redirect URIs (§6). 6. Finish the base skill's capture, round-trip and handoff steps, adding the Drive checks in §7. ## Drive platform state (verified 2026-09-20 — re-verify before trusting) - **Drive scope tiers, from the Drive API's own scope page:** `drive`, `drive.readonly`, `drive.activity`, `drive.activity.readonly`, `drive.meet.readonly`, `drive.metadata`, `drive.metadata.readonly` and `drive.scripts` are **restricted**. `drive.apps.readonly` is **sensitive**. `drive.file`, `drive.appdata` (a.k.a. `drive.appfolder`) and `drive.install` are **non-sensitive**. - **Only certain application types may use restricted Drive scopes at all**: backup-and-sync, productivity-and- education, and reporting-and-security. No amount of paperwork fixes an app outside those categories (§2). - **Google actively recommends migrating to `drive.file`**, and states it works with all Drive API REST resources. - **Restricted scopes require the annual CASA assessment** whenever the app can reach Drive data from or through a server — which any hosted integration does. If the scope tables do not look like this, stop and report what you actually see. ## 1. The Drive scope tiers | Tier | Drive scopes | What it costs | | --- | --- | --- | | **Non-sensitive** | `drive.file`, `drive.appdata` (a.k.a. `drive.appfolder`), `drive.install` | Basic app verification only. No security assessment. | | **Sensitive** | `drive.apps.readonly` | Additional app verification. No security assessment. | | **Restricted** | `drive`, `drive.readonly`, `drive.activity`, `drive.activity.readonly`, `drive.meet.readonly`, `drive.metadata`, `drive.metadata.readonly`, `drive.scripts` | Full verification **plus** an annual CASA security assessment. | Two traps in that table: - **`drive.metadata.readonly` is fully restricted** despite reading only metadata. "It's just filenames" is not an argument Google's tiering accepts, and choosing it buys the entire restricted-tier cost for a fraction of the capability. If metadata is all the product needs, that is an argument for `drive.file`, not for `drive.metadata.readonly`. - **Only `drive.file` and `drive.appdata` genuinely escape the restricted tier** among the scopes that touch user content. `drive.appdata` is narrower still: a hidden, app-private folder the user never sees, useful for the app's own configuration and useless for reading the user's documents. Drive Labels scopes sit in their own tiers: `drive.labels.readonly` and `drive.admin.labels.readonly` are non-sensitive; `drive.labels` and `drive.admin.labels` are sensitive. A read-only label scope therefore adds nothing to the review burden of an app that is already restricted, and does not by itself make a non-sensitive app sensitive. ## 2. Permitted application type — the gate before the paperwork Restricted Drive scopes are not available to every app that is willing to pay for an assessment. Google limits them to three application categories: - **Backup and sync** — backing up or synchronizing Drive content. - **Productivity and education** — creating, editing or collaborating on the user's files. - **Reporting and security** — auditing, compliance or security analysis over an organization's Drive content. **If the app is not one of these, restricted scopes are not obtainable at any price.** Check this *first*: it is the one Drive constraint that no amount of verification effort, budget or CASA scheduling can satisfy, and discovering it after a customer has been promised whole-Drive sync is an expensive conversation. If the category is genuinely unclear, Google allows selecting nothing and letting its review team classify the app. That is safer than claiming a category you cannot defend in a demonstration video. ## 3. The escape hatch: `drive.file` + the Google Picker `drive.file` is **non-sensitive**. It avoids restricted-scope verification, the CASA assessment and the annual re-assessment entirely, and Google states it works with all Drive API REST resources — the same calls, a narrower set of files. The honest trade-off, stated plainly because this is where people get misled: - `drive.file` grants access **only to files the user explicitly picks** through the Google Picker, files a user shares with the app, and files the app itself created. It is per-file consent, granted one file at a time. - **It cannot enumerate a user's Drive.** Any feature that says "sync all my files", "index the whole Drive", "watch for any new document", "back up everything", or "report on how files are shared across the org" is not implementable on `drive.file`. Those are exactly the categories Google permits restricted scopes *for* (§2). - So this is a **product decision, not a compliance workaround** — and it is one to raise, not to take. If the product's value is whole-Drive visibility, `drive.file` is the wrong scope and the assessment is the price of the product. If the product only ever operates on documents the user hands it, `drive.file` is strictly better and removes the assessment and its annual renewal outright. Present both and let the owner choose. - Switching an existing integration from restricted scopes to `drive.file` is a **re-consent event for every customer** plus a UI change (the Picker has to be added). It is a project. Say so rather than proposing it as a tweak. Ask *can this work with per-file access?* **before** registering, and record the answer in the handoff. Discovering it afterwards costs an assessment cycle. ## 4. Shared drives Shared drives (Workspace team drives) are **not a separate scope** — the same Drive scopes cover them. Access is governed by membership and file permissions, and shared-drive permissions are strictly expansive: a member's access can be raised for specific files but never lowered below their shared-drive role. Two consequences worth telling a user up front: - An authorizing user only ever exposes shared drives **they are a member of**. "The app can't see our team drive" is almost always a membership problem, not a scope problem, and re-registering the client will not fix it. - **Service accounts have no storage quota and cannot own files.** A service-account path must write into a shared drive or impersonate a real user, or file creation fails on quota. ## 5. Verification exemptions that can apply to a Drive app Check these before budgeting an assessment; one of them may remove it entirely. Google lists among the cases that do not require verification: personal use, development/testing/staging projects, apps touching only service-owned data, **internal use within a single Workspace organization**, and **domain-wide installation**. - **Internal to one organization.** If every user is in the owner's own Workspace org, the `Internal` audience applies and restricted Drive scopes need no Google review. This is a real exemption and a real limitation — a multi-tenant connector's users are by definition not all in one org, so it is available to a customer registering their own app and not to a vendor registering one for everybody. - **Domain-wide installation.** An app installed and allowlisted by a Workspace super administrator, rather than consented to by individual users, is a genuinely different posture from a public consumer app, and Google lists it as an exception to verification. **What it does to the CASA requirement is not spelled out in Google's published wording.** Do not promise anyone that domain-wide installation removes the security assessment — confirm the current exception text at the source, and if it still does not say, treat the assessment as required and ask Google. Neither exemption changes §2: an app outside the three permitted application types still cannot use restricted Drive scopes. ## 6. The callback domain and Search Console ownership Brand verification requires proving ownership of the app's authorized domains in Google Search Console, from an account that is an Owner or Editor on the Cloud project. For Drive this lands hardest on a specific case: **A customer bringing their own Google app cannot verify a vendor's domain.** They do not control it, cannot add the Search Console verification record, and so cannot complete brand verification for a callback hosted on it. There is no console setting that works around this. > **Product fact — as of 2026-09-20**, Unified.to's Google Drive connector is flagged as requiring a **custom API > domain (CNAME)** for its OAuth callback, precisely because of this rule. A customer registering their own Google > client is expected to point a domain *they* control at the platform and register the callback on that host, so the > domain they can prove in Search Console is the domain in the redirect URI. Confirm the exact hostname and the CNAME > setup with the connector's owner before registering anything. Settle this before adding redirect URIs — changing the callback host later means re-registering URIs and re-running brand verification. ## Product fact — what the Drive connector asks for > **As of 2026-09-20, Unified.to's Google Drive connector requests:** > > - **Read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/drive.readonly`, > `https://www.googleapis.com/auth/drive.labels.readonly` > - **Write:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/drive`, > `https://www.googleapis.com/auth/drive.labels.readonly` > - **Login/identity only:** `openid`, `profile`, `email` > > `drive.readonly` and `drive` are both **restricted**; `drive.labels.readonly` is non-sensitive and adds nothing to > the tier. So both the read and the write configurations put the registering app squarely in the restricted tier — > verification *and* an annual CASA assessment. Only the login-only configuration stays non-sensitive. > > The connector does **not** currently offer a `drive.file` variant, so the §3 escape hatch is not available without a > change on the connector side. It does support a **Google service-account credential** path as an alternative to the > user OAuth flow — the domain-wide route in §5. > > Scope sets change. This note is dated, not live; confirm with the connector's owner before submitting anything. ## 7. Drive-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Make one real Drive read (list files) and confirm the Drive API is enabled on the project and returns data. 2. Inspect the granted `scope` in the token response against what was requested — a granular-consent screen lets the user drop scopes, and a partially granted Drive scope set fails later with `insufficientPermissions`, not at consent. 3. If shared drives matter, test against a file **in a shared drive**, not just My Drive, with a user who is a member. 4. If the app uses `drive.file`, confirm a file picked through the Picker is readable **and** that a file that was never picked is not — that asymmetry is the whole scope, and it is easy to test against a file the app itself created and conclude wrongly. | Symptom | Cause | | --- | --- | | Every Drive call fails although scopes look right | Google Drive API not enabled on the project | | Auth succeeds, Drive calls 403 with `insufficientPermissions` | Granted scopes narrower than requested, or a `drive.file` app touching a file the user never picked (§3) | | App can see My Drive but not the team's files | Shared-drive membership, not a scope problem (§4) | | Service-account writes fail on quota | Service accounts cannot own files — write into a shared drive or impersonate a user (§4) | | Restricted-scope request rejected despite complete paperwork | App is not a permitted application type for restricted Drive scopes (§2) | | `403` with `rateLimitExceeded` / `userRateLimitExceeded` | Drive quota, not authorization — back off rather than re-authorizing | ## Stop and ask Beyond the base skill's list, hand back to a human when: - The product needs whole-Drive access and nobody has confirmed **budget and a named owner for the CASA assessment** — register nothing until that is answered. - The app may not qualify as a **permitted application type** for restricted Drive scopes (§2). That is a product classification, not a console choice. - Someone proposes migrating live connections between restricted scopes and `drive.file` — that re-consents every customer and changes the product's UI (§3). - Search Console **domain ownership cannot be proven** for the callback domain — decide the CNAME / custom-domain story with the platform owner first (§6). - Anyone wants to rely on the **domain-wide installation** exemption to skip the security assessment (§5). - The Drive scope tiers do not match the **Drive platform state** section above. ## References Drive-specific only; the base skill carries the generic Google OAuth references. Verified to resolve on 2026-09-20. - Choose Google Drive API scopes (tiers, restricted-scope application types, `drive.file` guidance) — https://developers.google.com/workspace/drive/api/guides/api-specific-auth - Google Picker overview (the `drive.file` companion) — https://developers.google.com/workspace/drive/picker/guides/overview - Shared drives overview — https://developers.google.com/workspace/drive/api/guides/about-shareddrives - Shared drive vs My Drive differences — https://developers.google.com/workspace/drive/api/guides/shared-drives-diffs - Share files, folders and drives (permissions model) — https://developers.google.com/workspace/drive/api/guides/manage-sharing - Drive Labels API authorization (label scope tiers) — https://developers.google.com/workspace/drive/labels/guides/authorize ## google-meet-oauth-app URL: https://docs.unified.to/skills/google-meet-oauth-app/SKILL.md # Google Meet OAuth2 App Registration Meet is the Google product where the scope tier depends entirely on *which* of three unrelated capabilities someone means. A Meet **link** on a calendar event costs nothing beyond the Calendar scope already in hand. Meet **space and conference metadata** costs one sensitive scope. **Recordings and transcripts as files** are read out of Drive, so they cost a restricted Drive scope, verification *plus* an annual CASA security assessment. All three get written on a ticket as "add Meet support." This file settles the tiers with the source for each, maps the REST API surface onto the scope that reaches it, and records the constraints that make a correctly-scoped Meet integration return nothing anyway — the organizer-only conference list, the Workspace editions without which no artifact is ever produced, and the admin switches on top. Everything else is shared. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns all the console mechanics, and this file does not repeat them: - Choosing/creating the Cloud project and organization, enabling APIs, and who may administer it. - Configuring the app on the Google Auth Platform (Branding, Audience, Data Access, Clients) and creating a **Web application** client — the only type that yields a usable client secret. - Redirect-URI rules (exact matching, HTTPS-only, propagation delay) and the platform's per-data-center callback list. - The generic scope model: declaring every scope on Data Access, the non-sensitive / sensitive / restricted tiers, the rule that the app's tier is its most sensitive scope, and `access_type=offline` + `prompt=consent` as the only way to get a refresh token back. - The client secret being shown and downloadable **only once at creation**, add-then-disable rotation, `Testing` vs `In production` and the seven-day refresh-token expiry, the unverified-app screen, the 100-user caps, verification, the annual CASA assessment at assurance level AL1/AL2, and the Workspace admin API controls in their generic form. If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Meet-specific ones, and the first three decide the cost: | Input | Notes | | --- | --- | | **Link, metadata, or the files?** | The three tiers in §2. Ask before anything else — the answer decides whether a security assessment is in scope | | **Only spaces this app creates, or any meeting the user can reach?** | `meetings.space.created` vs `meetings.space.readonly` — and Calendar-created meetings are *other apps'* spaces (§3) | | **Does the app need to change meeting settings (auto-record, auto-transcribe, moderation)?** | `meetings.space.settings`, which is non-sensitive (§1) | | **Real-time audio/video, or after-the-fact artifacts?** | Real-time means the Meet Media API: restricted scopes **and** a Developer Preview enrolment (§5) | | **What Workspace edition do the customers have?** | No edition, no recording or transcript — the API is not the gate (§4) | | **Whose Drive holds the artifacts?** | The meeting organizer's, not the authorizing user's (§4) | | **Push events or polling?** | Workspace Events subscriptions need Pub/Sub and expire in days (§7) | ## Quick Start 1. Work the base skill's console steps, and **enable the Google Meet REST API** on the project — plus Drive, Calendar or Workspace Events if §6 says so. 2. Settle which of the three capabilities in §2 is actually wanted, in writing, before touching Data Access. 3. Pick scopes from §1 and check the tier against the Meet auth guide's own table, not from memory. 4. If recordings or transcripts as *files* are in scope, stop and read `google-drive-oauth-app`: that is a restricted Drive scope, a permitted-application-type gate and an annual assessment (§4). 5. Confirm the customer's Workspace edition actually produces the artifact at all (§4) before promising it. 6. Declare exactly those scopes, request exactly those scopes, and finish the base skill's capture, round-trip and handoff steps with the Meet checks in §9. ## Meet platform state (verified 2026-09-20 — re-verify before trusting) - **Google publishes a per-scope tier table for Meet**, on the Meet REST API authenticate-and-authorize guide (page last updated 2026-09-14). It is the authoritative statement and it is unambiguous — see §1. - **The Meet REST API is v2**, at `https://meet.googleapis.com/v2`, with a `v2beta` carrying preview features. - **Meet scopes do not appear on the Cloud console's restricted-scopes list**, which covers Gmail, Drive, Fit, Data Portability, Photos Ambient and Health. Meet's only restricted exposure is via Drive scopes (§4) and via the Meet Media API scopes, which Google's Media API page calls restricted although that list does not enumerate them (§5). - **The Meet Media API is Developer Preview only.** The Cloud project, the OAuth principal *and every participant* in the conference must be enrolled in the Google Workspace Developer Preview Program (§5). - **Meet REST API usage is free today, with a stated intent to charge.** Google's usage-limits page says exceeding the quota limits "is planned to incur charges to your Google Cloud billing account later in 2026" (§8). - **Artifacts moved folders in July 2026.** Meet now writes transcripts into a "Google Meet" folder with a per-meeting sub-folder; the old "Meet Recordings" folder was renamed "Legacy Meet Recordings" and moved inside it. Anything matching on folder name will have broken (§4). - **From 30 April 2026, viewers and commenters on new Meet recordings can copy and download them** unless an admin changed the setting before that date. A customer's assumption that recordings cannot leave the org may be stale. If the Meet scope table or the Media API preview gate does not look like this, stop and report what you actually see. ## 1. The Meet scope tiers — the answer Every scope below is a `https://www.googleapis.com/auth/` URL; the table drops the prefix for width. | Scope | Grants (Google's wording) | **Tier** | Source | | --- | --- | --- | --- | | `meetings.space.settings` | "Edit and see the settings for all of your Google Meet calls." | **Non-sensitive** | Meet REST API auth guide | | `meetings.space.created` | "Allow apps to create, modify, and read metadata about meeting spaces created by your app." | **Sensitive** | Meet REST API auth guide; repeated on the Workspace Events API scopes page | | `meetings.space.readonly` | "Allow apps to read metadata about any meeting space the user has access to." | **Sensitive** | Meet REST API auth guide; repeated on the Workspace Events API scopes page | | `meetings.conference.media.readonly` | "Capture real-time video and audio in Google Meet video calls." | **Restricted** | Meet Media API get-started guide | | `meetings.conference.media.audio.readonly` | "Capture real-time audio in Google Meet video calls." | **Restricted** | Meet Media API get-started guide | | `meetings.conference.media.video.readonly` | "Capture real-time video in Google Meet video calls." | **Restricted** | Meet Media API get-started guide | | `drive.readonly` | "Allow apps to download recording and transcript files from Google Drive API." | **Restricted** | Meet REST API auth guide *and* the Cloud restricted-scopes list | | `drive.meet.readonly` | "View Drive files created or edited by Google Meet." | **Restricted** | Meet REST API auth guide *and* the Cloud restricted-scopes list | Read that table as three facts: - **No Meet REST API scope is restricted.** Spaces, conferences, participants, and the *metadata* of recordings and transcripts are all reachable at sensitive tier or below — verification with a justification and a demo video, but **no CASA assessment and no annual recertification**. `meetings.space.settings` is cheaper still: non-sensitive, basic app verification only. That is unusual, and it is worth saying out loud to anyone budgeting the work. - **Every Meet Media API scope is restricted.** Google states it plainly: "Due to the sensitive nature of conferences, all Meet Media API scopes are restricted." - **The restricted cost enters through Drive, not through Meet.** The moment the requirement is the recording or transcript *file*, the app needs `drive.readonly` or `drive.meet.readonly`, and the whole project moves to restricted tier (base §5: the tier of the app is the tier of its most sensitive scope). **What Google does not publish, and what to do about it.** Three gaps worth naming rather than guessing past: - The general **OAuth 2.0 Scopes for Google APIs** catalogue lists exactly three Meet scopes — `meetings.space.created`, `meetings.space.readonly`, `meetings.space.settings` — and assigns **no tier to any scope of any API**. It is a directory, not a tier source. Do not cite it for sensitivity. - The Cloud console's **restricted-scopes list does not mention Meet at all**, including the Media API scopes that the Media API page calls restricted. The two Google surfaces are not in contradiction — the list enumerates the classic restricted families — but if someone checks only the list, they will conclude Media API scopes are unrestricted. They are not: take the tier from the Meet Media API page and expect restricted-scope verification. - The Media API get-started page names a companion scope as **`meetings.space.read`** (Sensitive), a string that appears nowhere else in Google's Meet documentation and is not in the scopes catalogue, while the Meet REST API auth guide's equivalent is `meetings.space.readonly`. Treat `meetings.space.readonly` as the real string, **verify the exact spelling in the console's Data Access picker before declaring it**, and record what the picker says. An undeclared or misspelled scope drops users on the unverified-app screen (base §5). Where a per-scope tier is genuinely absent from Google's pages, say so and read the label off the Data Access picker — do not infer a tier from a neighbouring scope. ## 2. The three things people call "Meet support" | What is actually wanted | What it needs | Tier | Whose skill | | --- | --- | --- | --- | | A **Meet link** on a calendar event (create or read) | The Calendar event scope alone — no Meet scope, no Meet API | Sensitive (Calendar only) | `google-calendar-oauth-app` | | **Space / conference metadata**: meeting codes, settings, conference records, participants, artifact *metadata* | `meetings.space.created` or `meetings.space.readonly`; `meetings.space.settings` to write settings | Sensitive (non-sensitive for settings alone) | This file | | **Recording / transcript / smart-note files**: downloading or reading contents | A restricted **Drive** scope on top of the Meet scope | **Restricted** — verification + annual CASA | `google-drive-oauth-app` | | **Real-time audio/video** off a live call | Meet Media API scopes + `meetings.space.readonly` + Developer Preview enrolment | **Restricted**, and gated by preview access | §5 | The jump from row two to row three is the expensive one and it is invisible in the requirement's wording: "sync the meeting transcript" sounds like row two and is row three. The Meet REST API will hand out a transcript's *resource*, its state and its Drive export URI at sensitive tier; turning that URI into text is a Drive read. The jump from row one to row two is the cheap one people over-pay for: if the product only needs to put a Meet link on an event, or read the link off one, it needs no Meet scope at all and no Meet API enabled. ## 3. The Meet REST API surface, and which scope reaches what ``` spaces create / get / patch / endActiveConference conferenceRecords get / list (one per call inside a space) conferenceRecords.recordings get / list (MP4 in the organizer's Drive) conferenceRecords.transcripts get / list (Google Docs file) conferenceRecords.transcripts.entries get / list (speaker, timestamp, text, language) conferenceRecords.participants get / list conferenceRecords.participants.participantSessions get / list ``` Plus `spaces.members` for co-host management, and a smart-notes artifact resource alongside recordings and transcripts. `SpaceConfig` on a space carries access type, entry-point access, moderation and moderation restrictions, attendance-report generation, and the auto-artifact configuration for recording, transcription and smart notes. Four access rules decide whether a correctly-scoped call returns anything: - **`meetings.space.created` only reaches spaces this app created.** A meeting scheduled in Google Calendar is a space created by *Calendar*, not by your app. Google's own use-case table is explicit: reading artifacts or settings for conferences created by other apps needs `meetings.space.readonly` (and `meetings.space.settings` to read or edit the settings of any space the user can access through any other app). An integration that syncs customers' real meetings and requests only `meetings.space.created` will authorize cleanly and find nothing. - **`conferenceRecords.list` only returns conferences where the authorizing user is the meeting organizer.** Not attended — organized. "The app can only see meetings this person ran" is the documented behaviour, not a bug, and no scope widens it. Filterable fields are `space.meeting_code`, `space.name`, `start_time` and `end_time`; page size defaults to 25 and caps at 100. - **Artifacts are retrievable by a meeting space owner or participant**, and artifact objects only become useful once their state reads `FILE_GENERATED` — after the conference ends, with a lag. Poll the artifact endpoint, or subscribe to the file-generated events (§7). A conference that has ended is not a conference whose recording exists yet. - **Transcript entries are deleted 30 days after the conference ends**, and participant data is likewise documented as available for up to 30 days. Anything that needs the words later must copy them out inside the window — which, for the full text, means the Drive path and its restricted scope. Participants come back as one of `signedinUser`, `anonymousUser` or `phoneUser`. All three carry a display name; only `signedinUser` carries a user ID (interoperable with the Admin SDK and People API). Any identity mapping that assumes an email or an ID for every participant breaks on the first dial-in or unauthenticated guest. One policy line to keep in the handoff, because it is Google's own framing: the Meet REST API "isn't intended for performance tracking or user evaluation within your domain." ## 4. Recordings and transcripts: Drive files, and the editions behind them Meet saves recordings (MP4), transcripts (Docs) and smart notes (Docs) to the **meeting organizer's** Google Drive — not the authorizing user's. The recordings resource exposes a Drive destination with an `exportUri` for playback or download and a file identifier that corresponds to the Drive file. By default the artifacts are retained under normal Drive rules; an admin can manage them separately with Meet-specific retention rules in Google Vault. So the download path is a Drive read, with the restricted Drive scope from §1 and everything the `google-drive-oauth-app` skill says about restricted Drive scopes: verification, the permitted-application-type gate, and an annual CASA assessment. Google documents `drive.readonly` and `drive.meet.readonly` as the artifact-download scopes for Meet; it does not document a `drive.file`-based path for these files, so do not assume one exists. **Before any of that, the artifact has to exist.** Google publishes edition requirements per feature, and they differ per feature and even between Google's own pages: - **Recording** — the user-facing page lists Business Standard, Business Plus, Essentials, Enterprise Essentials, Enterprise Starter, Enterprise Standard, Enterprise Plus, Education Plus (Staff licence), Teaching and Learning Upgrade, Workspace Individual, and Google One subscribers with 2 TB or more. The admin-facing page's supported list is shorter (Business Standard and Business Plus; Enterprise Standard and Enterprise Plus; Education Plus; Essentials, Enterprise Essentials and Enterprise Essentials Plus). - **Transcripts** — Business Standard, Business Plus, Enterprise Starter, Enterprise Standard, Enterprise Plus, Teaching and Learning Upgrade, Education Plus, Workspace Individual. The admin page for turning transcription on or off lists a narrower set again (Business Standard; Business Plus; Enterprise Standard and Enterprise Plus; Education Plus). Transcripts are on by default except for Education student licences, run independently of recording, and are limited to eight languages. - **Smart notes ("take notes for me")** — "an eligible Google Workspace edition or Google AI plan", i.e. a Gemini entitlement, and only for work/school accounts or an eligible AI plan. Because those two Google lists disagree, **check the specific customer's edition rather than quoting a list**, and say in the handoff which page the answer came from. Three further ways an artifact fails to appear with a perfect token: - **Drive must be on for the organization and have free space** — in both the organization and the recording user's Drive. Drive off, or full, means no recording at all. - **An admin can require explicit in-meeting consent** from all participants before recording, transcription or smart notes start, and host-management settings decide who may start them. - **Auto-generation is per space and needs `meetings.space.settings`.** Pre-configuring auto-recording, auto-transcription or smart notes on a space — including a space Calendar created — is a settings write, and only meeting organizers, not co-hosts, can do it. ## 5. The Meet Media API: restricted, and preview-gated If the requirement is real-time audio or video off a live conference rather than a file afterwards, it is the Meet Media API, and the registration story changes: - **All three Media API scopes are restricted** (§1), so verification plus a CASA assessment, annually. - **It is Developer Preview.** The Cloud project, the OAuth principal and **every participant** in the conference must be enrolled in the Google Workspace Developer Preview Program. One unenrolled participant is enough to make a conference ineligible. - **The app must also request the general read-meeting scope** to read metadata and negotiate the connection — `meetings.space.readonly` per the REST API guide, spelled `meetings.space.read` on the Media API page (§1). - **A Workspace admin switch controls it**, off unless enabled: *Admin console → Apps → Google Workspace → Google Meet → Meet safety settings → Media API*, where the admin also chooses whether anyone from the host's organization may consent or only the host and co-hosts. Changes can take up to 24 hours. Participants are told when the Media API is in use and can turn it off mid-call. - **Consent, not just scopes.** A connection is only permitted when someone in the call can consent on behalf of the meeting; for a consumer-account (gmail.com) organizer the initiator must be present. Encrypted or watermarked meetings reject it outright, as do accounts registered to minors, and third-party hardware clients running Meet are unsupported. - Technical requirements are real engineering commitments — AV1, VP9 and VP8 with named decoder implementations, specific WebRTC header extensions, periodic client metrics, ~4 Mbps minimum bandwidth, 12 months' notice on codec changes. If someone reaches for the Media API because the REST API's post-meeting artifacts feel slow, push back: the REST API is the documented answer when real-time media is not genuinely required, and Google says so on its own page. ## 6. Which APIs to enable Enable on the Cloud project, before the first call: - **Google Meet REST API** — for everything in §3. Without it, authorization succeeds and the first call returns `403` with `accessNotConfigured` (base §8). It is also the API to enable for the Meet Media API. - **Google Drive API** — only if the app downloads or reads recording, transcript or smart-note files (§4). - **Google Calendar API** — only if the app also creates or reads events carrying Meet links (§2, and the Calendar skill). - **Google Workspace Events API** and **Cloud Pub/Sub** — only for push subscriptions (§7). - **Admin SDK** — only if the integration enumerates users org-wide. ## 7. Events and subscriptions Meet delivers push events through the **Google Workspace Events API** into a **Cloud Pub/Sub** topic; there is no direct webhook. Event types cover a conference started/ended, a participant joined/left, and recording, transcript and smart-note started/ended/fileGenerated. Target either a meeting space, or a *user* (which yields events for every space that user owns). The constraints that bite: - **Grant the Meet push service account `Pub/Sub Publisher` on the topic** — `meet-api-event-push@system.gserviceaccount.com`. A subscription that never delivers is usually this. - **Subscriptions expire fast.** Up to **7 days** when the payload excludes resource data, and up to **4 hours** — 24 hours with domain-wide delegation — when it includes it. Nothing auto-renews; renew or reactivate. Note also that Google documents including resource data as supported only for Chat resources, so plan on name-only payloads for Meet and a follow-up REST read. - **Subscription scopes are the Meet scopes, not a subscription-specific scope**: the Events API accepts `meetings.space.created` and `meetings.space.readonly` for Meet event types, both sensitive. - **Invitees are not the organizer.** Calendar invitees and other participants can receive only `google.workspace.meet.conference.v2.started` and `google.workspace.meet.transcript.v2.fileGenerated`. - Polling the REST API is the documented fallback for an outage or an inactive subscription — build it, given the 30-day windows in §3. ## 8. Quotas, and the coming bill Per-minute, per project and per user per project, from Google's usage-limits page: | | Per minute per project | Per minute per user per project | | --- | --- | --- | | Read requests | 6,000 | 600 | | Write requests | 1,000 | 100 | | Reduced write (space creation) | 100 | 10 | - Exceeding a quota returns **`429`**. Back off with truncated exponential backoff plus jitter — `min(((2^n) + random_milliseconds), maximum_backoff)`, random part under 1,000 ms, ceiling 32–64 seconds. This is not an authorization failure; re-authorizing a throttled connection burns a consent and fixes nothing. - **Within the per-minute limits there is no daily cap.** - **Space creation is the scarce one**: 10 per user per minute. A bulk provisioning job hits that first. - **Pricing.** Standard use is free today, and Google states that exceeding the quota limits is planned to incur charges to the Cloud billing account later in 2026. Anyone sizing a high-volume Meet integration should know that before launch, not after. - Quota increases are requested from the Cloud console's Quotas page; approval is not guaranteed, and a service account counts as a single account. ## Product fact — what the Meet connector asks for > **As of 2026-09-20, Unified.to's Google Meet connector requests**, per object and operation: > > - **Calendars, read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/calendar.readonly` > - **Calendars, write:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/calendar` > - **Events, read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/calendar.events.readonly` > - **Events, write:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/calendar.events` > - **Availability (busy), read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/calendar.freebusy`, > `https://www.googleapis.com/auth/calendar.events.freebusy` > - **Recordings, read:** `https://www.googleapis.com/auth/meetings.space.readonly` — and **only** that; no identity > scopes accompany it, and there is no write configuration for recordings at all > - **Login/identity only:** `openid`, `profile`, `email` > > **Where that lands: sensitive at worst, never restricted.** `meetings.space.readonly` is sensitive (§1) and every > Calendar scope is sensitive; no Drive scope and no Media API scope is requested. So this configuration needs brand > and scope verification, and **no CASA security assessment** — the single most useful thing to tell whoever is > budgeting it. > > The consequence of that cheapness is the ceiling. Asking only for `meetings.space.readonly` means Meet **metadata**: > conference records and artifact resources, not the recording or transcript *contents*, which would need the > restricted Drive scope in §4. It also means the connector cannot create or configure spaces — no > `meetings.space.created`, no `meetings.space.settings` — so auto-recording and auto-transcription cannot be turned > on through it (§4), and the organizer-only rule in §3 still applies to whatever it does list. > > Auth quirks worth knowing: it is a Meet-branded connector whose main surface is Calendar, categorized for calendar > and identity, and pointed at the Meet v2 API host; it uses the standard Google authorize and token endpoints, asks > for offline access and forces the consent prompt (so a refresh token comes back on re-authorization, base §5), and > exchanges and refreshes by form POST with the client credentials. Alongside user OAuth it offers a **Google > service-account credential** path — domain-wide delegation — whose generated token is scoped to **full read-write > Calendar plus `meetings.space.readonly`**, which is broader than the read-only user configurations and is a > conversation to have with a customer's admin before enabling it. > > Because the recordings configuration is the one that pairs no identity scope with a single Meet scope, and every > other configuration pairs identity scopes with Calendar scopes, expect the **granular consent screen** in both > shapes (base §5): read the granted `scope` back from the token response rather than assuming (§9). > > Scope sets change. This note is dated, not live; confirm with the connector's owner before submitting anything. ## 9. Meet-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Make one real Meet read and confirm the Google Meet REST API is enabled and returning data. 2. **Compare the granted `scope` in the token response against what was requested.** A user who unticks the Meet scope on the granular consent screen produces a token that authenticates perfectly and 403s on every Meet call. 3. Test with a meeting the user **organized** and a meeting they merely **attended**, and confirm you understand why only the first appears in `conferenceRecords.list` (§3) before anyone reports it as a bug. 4. Test against a meeting created **from Google Calendar**, not one the app created — that is the case `meetings.space.created` cannot see (§3). 5. If artifacts matter, run the full lifecycle on an account whose edition actually supports them (§4): record or transcribe, wait for the artifact state to reach `FILE_GENERATED`, and confirm what the app gets is metadata plus a Drive URI, not content, unless a Drive scope was granted. 6. If events matter, create a subscription, receive one event, and confirm the app renews before expiry (§7). 7. Confirm participant handling for a dial-in or unauthenticated guest — no user ID comes back (§3). | Symptom | Cause | | --- | --- | | Auth succeeds, every Meet call `403 accessNotConfigured` | Google Meet REST API not enabled on the project (§6) | | Auth succeeds, Meet calls 403 but identity works | User declined the Meet scope on the granular consent screen (§9.2) | | Conference list is empty for a busy user | `conferenceRecords.list` returns only meetings that user **organized** (§3) | | Calendar-scheduled meetings are invisible | App holds `meetings.space.created`; Calendar created those spaces (§3) | | Recording or transcript resource never appears | Conference ended but artifact state is not `FILE_GENERATED` yet — poll or subscribe (§3) | | Artifacts never appear at all, for any meeting | Edition does not include the feature, Drive is off or full, or consent/host settings blocked it (§4) | | Transcript text is gone after a month | Transcript entries are deleted 30 days after the conference (§3) | | Artifact metadata reads fine, downloads fail | Downloading is a Drive read and needs a restricted Drive scope (§4) | | Some participants have no user ID | `anonymousUser` / `phoneUser` — only `signedinUser` carries one (§3) | | Event subscription goes quiet after days or hours | Subscription expired — 7 days name-only, 4 hours with resource data (§7) | | Subscription created but nothing ever arrives | Meet push service account lacks Pub/Sub Publisher on the topic (§7) | | `429` under load | Quota, not authorization — back off; space creation caps at 10/user/minute (§8) | | Media API connection refused mid-call or at join | Admin switch off, consent withheld, an unenrolled participant, encryption/watermark, or a minor's account (§5) | | Folder-matching automation stopped finding files | July 2026 artifact folder rename (Platform state) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - The requirement is recordings or transcripts **as content**. That is a restricted Drive scope, the permitted- application-type gate and a funded annual CASA assessment — a product decision, not a console setting (§4). - Anyone wants the **Meet Media API**: restricted scopes plus Developer Preview enrolment for the project, the principal and every participant, plus a customer admin switch (§5). - A customer's Workspace **edition** does not include recording, transcripts or smart notes, and someone proposes shipping the feature anyway (§4). - The two Google edition lists disagree for the customer's exact edition and a commitment depends on which is right (§4). - The console's Data Access picker labels a Meet scope differently from §1, or shows `meetings.space.read` as a real scope — record what it says and report the discrepancy rather than declaring a scope on trust (§1). - Scopes would be narrowed or widened on a live integration — every customer re-consents. - Domain-wide delegation is proposed to get around organizer-only visibility (§3). It needs a customer super administrator and changes the trust model; it is not a fix for a scope that returns nothing. - Volume planning depends on the **future Meet API pricing** (§8). Get that from Google, not from an estimate. ## References Meet-specific only; the base skill carries the generic Google OAuth references, the Cloud restricted-scopes list and the OAuth scopes catalogue. Verified to resolve on 2026-09-20. - Authenticate and authorize Meet REST API requests (**the scope tier table**) — https://developers.google.com/workspace/meet/api/guides/authenticate-authorize - Meet REST API overview (resources, terms, artifact definitions, 30-day entries) — https://developers.google.com/workspace/meet/api/guides/overview - Meeting spaces overview (**OAuth scopes by use case**, space lifecycle) — https://developers.google.com/workspace/meet/api/guides/meeting-spaces-overview - Configure meeting space settings (moderation, auto artifacts, `meetings.space.settings`) — https://developers.google.com/workspace/meet/api/guides/meeting-spaces-configuration - Manage meeting spaces — https://developers.google.com/workspace/meet/api/guides/manage-meeting-spaces - Manage meeting space members (co-hosts) — https://developers.google.com/workspace/meet/api/guides/meeting-space-members - Work with conferences (organizer-only list, filters) — https://developers.google.com/workspace/meet/api/guides/conferences - Work with participants (signed-in / anonymous / phone users) — https://developers.google.com/workspace/meet/api/guides/participants - Work with artifacts (Drive destination, `exportUri`, retention) — https://developers.google.com/workspace/meet/api/guides/artifacts - Usage limits, backoff and the planned 2026 charging — https://developers.google.com/workspace/meet/api/guides/limits - Respond to events from Meet — https://developers.google.com/workspace/meet/api/guides/events-overview - `spaces` REST reference (SpaceConfig, ArtifactConfig) — https://developers.google.com/workspace/meet/api/reference/rest/v2/spaces - `conferenceRecords.recordings` reference — https://developers.google.com/workspace/meet/api/reference/rest/v2/conferenceRecords.recordings - `conferenceRecords.transcripts` reference — https://developers.google.com/workspace/meet/api/reference/rest/v2/conferenceRecords.transcripts - Meet release notes — https://developers.google.com/workspace/meet/release-notes - Meet Media API overview (preview gate, consent, eligibility) — https://developers.google.com/workspace/meet/media-api/guides/overview - Meet Media API get started (**all Media API scopes are restricted**, codecs, end-user requirements) — https://developers.google.com/workspace/meet/media-api/guides/get-started - Subscribe to Google Meet events (event types, target resources, invitee limits) — https://developers.google.com/workspace/events/guides/events-meet - Choose Google Workspace Events API scopes (Meet scope tiers, second source) — https://developers.google.com/workspace/events/guides/auth - Google Workspace Events API overview (subscription expiration: 7 days / 4 hours) — https://developers.google.com/workspace/events/guides - Create a Google Workspace subscription (Pub/Sub topic, Meet push service account) — https://developers.google.com/workspace/events/guides/create-subscription - Record a video meeting (editions that support recording) — https://support.google.com/meet/answer/9308681?hl=en - Use Transcripts with Google Meet (editions, folder rename, languages) — https://support.google.com/meet/answer/12849897?hl=en - "Take notes for me" in Google Meet (Gemini entitlement) — https://support.google.com/meet/answer/14754931?hl=en - Turn Meet recording on or off for your organization (admin editions, Drive prerequisites, April 2026 change) — https://support.google.com/a/answer/7557052?hl=en - Turn meeting transcription on or off (admin editions) — https://support.google.com/a/answer/12076932?hl=en - Control Media API access in Google Meet (admin switch, consent options) — https://support.google.com/a/answer/16333500?hl=en - Retain Google Meet data with Vault — https://support.google.com/vault/answer/7682297?hl=en ## google-oauth-app URL: https://docs.unified.to/skills/google-oauth-app/SKILL.md # Google OAuth2 App Registration — Gmail, Calendar/Meet, Contacts/People, Drive/Sheets The Google-API-specific half of registering an OAuth 2.0 client for this connector: what it asks Google for, what that costs in review terms, and how its authorize and token requests are shaped. The registration mechanics themselves are not here. They are the same for every Google API and live in the base skill. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, in full, then come back here.** It owns: - **Project and app setup** — picking or creating the Cloud project, organization placement, permissions, and the Google Auth Platform pages (Overview, Branding, Audience, Clients, Data Access, Verification Center). - **The client itself** — why **Web application** is the only common type that yields a usable client secret, and why a service account is not a substitute; plus the exact-match **redirect-URI rules** and the platform's callback list. - **Scope mechanics** — the non-sensitive / sensitive / restricted tiers, why the Data Access declaration and the authorize request must be identical, and the refresh-token rules (`access_type=offline`, `prompt=consent`, 100 live tokens per account per client, six-month idle expiry). - **The client secret** — shown and downloadable in full exactly once at creation, hashed afterwards with only the last four characters visible, and rotated by add-then-disable rather than replace. - **Testing vs In production, verification and CASA** — the seven-day refresh tokens in `Testing`, the two different 100-user caps, the brand / sensitive / restricted review layers, the CASA assurance levels and annual revalidation, and the end-to-end verification and hand-off checklists. If you loaded only this file, you are missing all of the above, and nothing below substitutes for it — in particular, declaring a scope, capturing the secret and publishing the app are base-skill steps. ## Inputs specific to this connector The base skill's input table still applies (app name, branding assets, owning account, redirect URIs, audience, new client vs edit). Collect these on top of it, in the same batch: | Input | Notes | | --- | --- | | **Which Google products the customer actually needs** — Gmail, Calendar/Meet, Contacts/People, Drive, Sheets | Decides the scope set, and therefore the review tier | | **Do the Gmail or broad Drive scopes apply?** | Blocking on timeline — they put the whole app in the restricted tier | | **Can the product live with per-file Drive access (`drive.file`)?** | A product decision, not a console setting — raise it, do not decide it | ## 1. APIs to enable for these scopes Scopes belonging to an API that is not enabled do not appear in the Data Access picker, and a call made with such a scope fails with `403 accessNotConfigured`. Enable, from **APIs & Services → Library**, whichever of these the chosen scope set needs: | Product area | API to enable | | --- | --- | | Gmail | Gmail API | | Calendar | Google Calendar API | | Meet | Google Meet API | | Contacts / People | People API | | Drive | Google Drive API | | Sheets | Google Sheets API | Identity scopes (`openid`, `profile`, `email`, `userinfo.*`) need no API enabled. ## 2. What the connector actually requests As of 2026-09-20, **the connector requests the following scope strings**, assembled per object type — only the scopes for the objects a given connection uses are sent. **Confirm the current set with the connector's owner** before declaring scopes on Data Access or submitting for verification. Identity, on nearly every request: ``` openid profile email https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile ``` Calendar and Meet: ``` https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events.readonly https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.freebusy https://www.googleapis.com/auth/calendar.events.freebusy https://www.googleapis.com/auth/meetings.space.readonly ``` Gmail (**restricted**): ``` https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.compose https://www.googleapis.com/auth/gmail.modify ``` Contacts / People: ``` https://www.googleapis.com/auth/profile.emails.read https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/contacts.other.readonly https://www.googleapis.com/auth/contacts ``` Drive (**restricted**, except `drive.file`) and Sheets: ``` https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/drive.labels.readonly https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/spreadsheets ``` ### The tier consequence **Declared as a union, this connector's scope set is restricted-tier**, because of the Gmail scopes and the broad Drive scopes (`drive`, `drive.readonly`). Since the tier of the app is the tier of its most sensitive scope, that pulls the whole project into restricted verification plus the CASA assessment and its annual revalidation. Read the other way round, that is a lever: - Calendar, Meet, Contacts/People and Sheets on their own are **sensitive**, not restricted — brand check, justification and demo video, no security assessment. - **`https://www.googleapis.com/auth/drive.file` is not restricted.** It grants access only to files the app created or the user explicitly picked. Where the product can live with per-file access, dropping `drive`/`drive.readonly` removes an entire annual assessment from the roadmap. - **Gmail has no such escape hatch** — every Gmail scope, including `gmail.readonly`, is restricted. If the customer needs Gmail at all, the app is restricted-tier. So confirm which objects the customer actually needs before declaring the union of everything. Adding Gmail or broad Drive later can force an out-of-cycle re-assessment; declaring them now when nobody uses them buys the same review for nothing. ## 3. Auth quirks this connector relies on Also as of 2026-09-20, and also worth confirming with the connector's owner: - Authorize endpoint `https://accounts.google.com/o/oauth2/v2/auth`; token and refresh endpoint `https://oauth2.googleapis.com/token`. - The authorize request carries `client_id`, `scope`, `response_type=code`, `state`, `redirect_uri`, **`access_type=offline`** and **`prompt=consent`** — the two parameters without which a reconnect stores no refresh token (base skill, scope mechanics). - Token exchange is a **form POST with the credentials in the body** (`client_id`, `client_secret`, `code`, `grant_type`, `redirect_uri`) — not HTTP Basic. Refresh sends `client_id`, `client_secret`, `grant_type`, `refresh_token`. - Access tokens are sent as `Bearer`. Scopes are joined with a single space. - **No PKCE.** This is a confidential web-application client authenticating with its secret; no `code_challenge` is sent. That matches Google's web server flow, which does not require PKCE for confidential clients — but it means the secret is load-bearing and must be protected accordingly. - Login-only flows request `openid profile email` and nothing else. ## 4. Gmail-specific token behaviour **A user changing their Google Account password invalidates refresh tokens that carry Gmail scopes.** Nothing carrying only Calendar, Contacts, Drive or Sheets scopes is affected. So a support report of "our Gmail connections keep breaking but everything else is fine" is usually expected behaviour, not a bug: the user re-authorizes. Expect it to look like a cluster whenever a customer runs a password-rotation policy. ## 5. Symptoms specific to these APIs The base skill's table covers the generic OAuth failures. These are the ones that come from this scope set: | Symptom | Cause | | --- | --- | | `403` with `accessNotConfigured` on the first Gmail / Calendar / Meet / People / Drive / Sheets call | That product's API is not enabled in the project (§1) — the OAuth grant succeeded, the API is simply off | | Gmail connections break when a user changes their password, others survive | Expected: Gmail-scoped refresh tokens are invalidated on password change (§4) | | Drive lists only a handful of files, or 404s on a file the user can see | `drive.file` was granted instead of full Drive — it only reaches files the app created or the user picked (§2) | | A Calendar-only or Contacts-only customer is stuck behind a restricted-scope review | The Data Access page declares the union including Gmail / broad Drive, so the whole app is restricted-tier (§2) | ## Stop and ask On top of the base skill's list, hand back to a human when: the choice between `drive.file` and full Drive access is really a product decision; the customer needs Gmail at all and nobody has budgeted for CASA and its annual revalidation; or someone wants the union of every scope above declared when only one product area is in use. ## References The base skill carries the generic Google OAuth, verification and CASA references. These are the per-API scope pages for this connector: - Choose Gmail API scopes — https://developers.google.com/workspace/gmail/api/auth/scopes - Google Drive API-specific authorization and scopes — https://developers.google.com/workspace/drive/api/guides/api-specific-auth - Google Calendar API auth and scopes — https://developers.google.com/workspace/calendar/api/auth - People API authorization and scopes — https://developers.google.com/people/v1/how-tos/authorizing - Google Sheets API scopes — https://developers.google.com/workspace/sheets/api/scopes ## google-sheets-oauth-app URL: https://docs.unified.to/skills/google-sheets-oauth-app/SKILL.md # Google Sheets OAuth2 App Registration Both Sheets scopes are **sensitive — neither is restricted**, so a Sheets-only app needs verification and no security assessment. That is the good news, and it is not usually the whole story: **the Sheets API has no method that lists or searches spreadsheets.** An app that must find a user's spreadsheet reaches for a Drive scope to do it, and *which* Drive scope it picks is what decides whether the project owes an annual third-party CASA assessment forever. So the expensive decision in a Sheets registration is not a Sheets decision. It is the Drive one made while doing it (§2), and it is a product decision — raise it, do not take it. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns all the console mechanics, and this file does not repeat them: - Choosing/creating the Cloud project, enabling the APIs the scopes belong to, and who may administer it. - Configuring the app on the Google Auth Platform (Branding, Audience, Data Access, Clients) and creating a **Web application** client — the only type that yields a usable client secret — plus redirect-URI matching rules. - The generic scope model: declaring every scope on Data Access, the non-sensitive / sensitive / restricted tiers, and the rule that **the app's tier is its most sensitive scope**. - The client secret shown and downloadable **only once at creation**, and add-then-disable rotation. - `Testing` vs `In production` — including the seven-day refresh-token expiry — the 100-user caps, brand and scope verification, the annual CASA assessment at assurance level AL1 or AL2, and `access_type=offline` + `prompt=consent` as the condition for getting a refresh token at all. If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Sheets-specific ones, and the first three set the project's tier: | Input | Notes | | --- | --- | | **How does the app get a spreadsheet ID?** | User pastes a URL, a Picker, or "sync everything" — this is the tier decision (§2) | | **Read-only or read-write?** | `spreadsheets.readonly` vs `spreadsheets`; both sensitive, so this does *not* change the tier (§1) | | **Must it enumerate or watch the user's spreadsheets?** | If yes, a restricted Drive scope and a CASA assessment are in scope — check the Drive skill's eligibility rule too (§2) | | **Does it need Drive-side file properties (names, owners, labels)?** | Pulls in Drive scopes even when the Sheets work would not (§2) | | **Expected concurrent users and calls per minute** | Quotas are per minute and per user per project, not per day (§5) | | **How large are the spreadsheets?** | Cell and metadata caps, and the 180-second request timeout, bite before the API does (§5) | | **Is any part of this an Apps Script project or a Workspace add-on?** | Changes which OAuth client exists at all (§6) | ## Quick Start 1. Work the base skill's console steps, and **enable the Google Sheets API** (`sheets.googleapis.com`) on the project — plus the Google Drive API if, and only if, §2 concludes a Drive scope is needed. 2. Settle §2 **before** touching Data Access. It is the only irreversible-feeling choice here. 3. Declare exactly the scopes from §1/§2 on Data Access, and request exactly those in the authorize URL. 4. If a restricted Drive scope survives §2, stop and confirm CASA budget, owner and application-type eligibility — `google-drive-oauth-app` §2 — before registering anything. 5. Finish the base skill's capture, round-trip and handoff steps, adding the Sheets checks in §7. 6. Hand over the quota and limits facts (§5) with the credentials — they are what the first load test hits. ## Sheets platform state (verified 2026-09-20 — re-verify before trusting) - **Scope tiers, from the Sheets API's own scope page:** `spreadsheets` and `spreadsheets.readonly` are **Sensitive**. `drive.file` is **Non-sensitive** and is the page's *Recommended* option. `drive` and `drive.readonly` are **Restricted**. There is no restricted Sheets scope. - **Sheets scopes apply to a whole spreadsheet file and cannot be limited to one sheet within it.** Google's own mitigation is a protected range, which is a document feature, not an authorization boundary. - **The `spreadsheets` resource exposes `create`, `get`, `getByDataFilter` and `batchUpdate` — and no `list`.** There is no Sheets call that enumerates or searches a user's spreadsheets. This is the fact behind §2. - **Quotas: 300 read and 300 write requests per minute per project, 60 of each per minute per user per project**, with no daily cap. Exceeding them returns `429`. - **All standard use of the Sheets API is free today, and Google states that exceeding the quota limits is *planned* to incur charges to the Cloud billing account later in 2026.** Treat "over quota is free to retry" as expiring. - **Google now ships a Sheets MCP server** (`sheetsmcp.googleapis.com`) whose tools draw on the same read/write quota pools. If someone is pointing an agent at Sheets, it is a separate API to enable, not a different quota budget. If the scope table or the method list does not look like this, stop and report what you actually see. ## 1. The Sheets scopes | Scope | Grants | Tier | | --- | --- | --- | | `https://www.googleapis.com/auth/spreadsheets` | See, edit, create and delete **all** the user's spreadsheets | **Sensitive** | | `https://www.googleapis.com/auth/spreadsheets.readonly` | See **all** the user's spreadsheets | **Sensitive** | Say it plainly, because people assume otherwise in both directions: - **Neither Sheets scope is restricted.** A Sheets-only app needs brand and sensitive-scope verification — a justification per scope and a demo video — and **no security assessment, no annual re-assessment**. Anyone budgeting CASA for a pure Sheets integration is budgeting for a Drive scope they have not noticed (§2). - **Neither is narrow, either.** `spreadsheets.readonly` reads *every* spreadsheet the user can open, given its ID. It is "read-only", not "limited". The only per-file Sheets access that exists comes from `drive.file` (§2). - **Read-only vs read-write does not change the tier.** Choose it on least privilege, not on review cost. A connector that only ever reports should hold `spreadsheets.readonly`, but it buys no paperwork relief. ## 2. The Drive decision that sets the project's tier This is the section worth reading twice. The Sheets API takes a spreadsheet ID and works on that file. It cannot tell you which spreadsheets exist. So the product question — *how does the app learn the ID?* — decides the scope set, and the scope set decides the tier: | How the app gets the ID | Scopes beyond Sheets | Tier of the whole app | What the product can do | | --- | --- | --- | --- | | **User pastes a spreadsheet URL or ID** | none | **Sensitive** | Works on the sheets the user names. No browsing, no discovery, no "watch my Drive". | | **User picks the file** (Google Picker) | `drive.file` — non-sensitive | **Sensitive** (from the Sheets scope) | Per-file consent: files the user picked, files shared with the app, files the app created. No enumeration. | | **App lists/searches the user's spreadsheets** | `drive.readonly` or `drive` — **restricted** | **Restricted** | Whole-Drive discovery, sync-everything, "find the file called Q3 Forecast" — and verification **plus an annual CASA assessment**. | Three things follow, and they are the whole point of this file: - **One scope moves the entire project.** Adding `drive.readonly` so a dropdown can list spreadsheets puts an app that is otherwise sensitive-tier into restricted-tier verification, an external security assessment, and a yearly renewal — for a file-picker convenience. Price the convenience against the assessment before declaring the scope. - **`drive.file` composes cleanly with Sheets.** It is non-sensitive, Google's recommended option on the Sheets scope page, and once a file is picked the full Sheets API works on it normally — create, read, batch-update, all of it. An app that creates its own spreadsheets also reaches them under `drive.file`, because it created them. What it can never do is *find* a file the user did not hand over. - **It is a product decision to raise, not to take.** "Connect your Sheet" (paste or pick) and "sync my whole Drive" are different products with different costs, not two configurations of one product. Present both, with the assessment attached to the second, and let the owner choose. If whole-Drive visibility genuinely *is* the product, the assessment is the price of the product — and then read `google-drive-oauth-app` §2 first, because restricted Drive scopes are additionally limited to three application categories and are unobtainable outside them at any price. Two traps inside the trade: - **Switching later re-consents everyone.** Moving a live integration between `drive.readonly` and `drive.file` is a re-authorization event for every connected customer plus a UI change (something has to pick the file). It is a project, not a settings change. - **Drive-side metadata drags the scope back in.** Wanting the file's name, owner, folder, sharing state or Drive labels is a Drive read, not a Sheets read. Check what the product actually renders before concluding it is Sheets-only. `drive.labels.readonly` is itself non-sensitive, but the file read it accompanies usually is not. ## 3. Addressing cells: A1, grid ranges, and anchors that survive edits Two range syntaxes, and they are not interchangeable across methods — value methods take A1 (or R1C1) strings, while `batchUpdate` requests take `GridRange` objects: - **A1** is `Sheet1!A1:B2`. `Sheet1!A:A` is a whole column, `Sheet1!1:2` whole rows, `Sheet1!A5:A` open-ended from row 5, and a bare `Sheet1` is the entire sheet. Omitting the sheet name targets **the first visible sheet** — which is whatever the user dragged to the front this morning. - **Quoting is load-bearing.** Sheet names with spaces or special characters need single quotes: `'My Custom Sheet'!A:A`. And names collide: with a named range called `Sheet1`, the string `Sheet1` resolves to the *named range* while `'Sheet1'` resolves to the sheet. A connector that interpolates user-chosen tab names into A1 strings without quoting will eventually write to the wrong place rather than fail. - **`GridRange` is zero-based and half-open** — `startIndex` inclusive, `endIndex` exclusive — and identifies the sheet by its numeric `sheetId`, not its title. Missing indexes mean unbounded on that side; start equal to end is an empty range. Off-by-one and one-based/zero-based confusion between the two systems is the most common Sheets mapping bug. - **Sheet IDs and spreadsheet IDs are stable across renames; A1 strings are not stable across edits.** Any row the app wants to come back to later should be anchored by **developer metadata**, not by row number — inserting a row above moves the data and leaves the range pointing at a neighbour. - **Developer metadata has a visibility that is tied to the Cloud project.** Project-visible metadata is readable only from the Cloud project that wrote it. Re-registering the integration under a *different* Cloud project therefore makes every project-visible anchor invisible, with no error — the data is simply not found. That is an OAuth-layer decision with a data-layer consequence: keep the project stable, and treat a project change as a migration. ## 4. Batch or die Sheets counts quota per **request**, and **a batch request — including every subrequest inside it — counts as one**. That single sentence is the difference between an integration that scales and one that does not: - Writing 500 cells with 500 `values.update` calls is 500 write requests, which exceeds the 60-per-user-per-minute ceiling eight times over. The same 500 cells in one `values.batchUpdate` is **one** write request. - Use `spreadsheets.values.batchGet` / `batchUpdate` for cell values, `spreadsheets.batchUpdate` for structure (inserting rows, formatting, named and protected ranges, sheets, tables, find-and-replace), and `spreadsheets.values.append` to add rows after an existing table. - **Batches are atomic.** If any subrequest is invalid, the whole update fails and *none* of the changes apply. That is a feature for consistency and a trap for partial progress: one bad row rejects the batch, so validate before sending rather than retrying blind. - On the read side, one `spreadsheets.get` with a `fields` mask usually beats several narrow reads; a `get` with full grid data on a large file is one request but an enormous response. There is no explicit cap on the data a read returns, but empty trailing rows and columns are omitted — so a response is not a reliable statement of the sheet's shape. - `valueInputOption` is required on writes and is a correctness choice, not a formatting one: `RAW` stores `=1+2` as the literal string, `USER_ENTERED` parses it into a formula and infers types and formats exactly as typing would. ## 5. Quotas, 429s and hard limits | Quota | Read requests | Write requests | | --- | --- | --- | | Per minute per project | 300 | 300 | | Per minute per user per project | 60 | 60 | - There is **no daily limit** — stay inside the per-minute windows and the day is unbounded. - **429s are normal at scale, not a bug and not an auth failure.** The documented response is truncated exponential backoff with jitter, up to a 32–64 second maximum, and the window clears after a minute. Any connector doing bulk work should treat `429` as flow control; note that Google also surfaces rate limiting as `403` with a `rateLimitExceeded` / `userRateLimitExceeded` / `RATE_LIMIT_EXCEEDED` reason, which is *not* a permission problem and must not trigger a re-authorization. - A **service account counts as a single user** against the per-user quota, so routing every customer through one service identity concentrates them all into 60 requests a minute. - Quota increases can be requested from the Cloud console's Quotas page; approval is not guaranteed and large increases take longer. Hard limits that end a design before the quota does: - **10 million cells, or 18,278 columns (column ZZZ), per spreadsheet** — and that cell budget is shared across *all* sheets in the file, so "add another tab per month" runs out. Google publishes no separate per-spreadsheet tab count on the file-limits page; the cell cap is the one that binds. - **A cell over 50,000 characters is dropped** when a spreadsheet is converted from Excel. - **Connected Sheets**: up to 200k rows for pivot tables, and 500k rows or 5M cells for extracts. - **Developer metadata: 30,000 characters for the spreadsheet plus 30,000 per sheet.** A per-row anchoring scheme with long keys will hit this on a long sheet. - **A single request that takes more than 180 seconds times out.** Large batches need splitting for time even when they are legal for size. ## 6. Apps Script and add-ons — only where they change the credential If the work is a bound script or a Workspace add-on rather than a hosted connector, the OAuth client is not yours in the way §1–§2 assume: - **Every Apps Script project has an associated Cloud project.** By default Apps Script creates a *default* project that most users cannot even open in the Cloud console. There is no OAuth client of yours to configure, and no consent screen you control. - **A standard Cloud project is required** to publish as a Google Workspace add-on, **to verify the script's OAuth client**, to run `scripts.run`, to read Cloud logs, and to create a file-open dialog (the Picker). Verification is on that list: a script on a default project cannot be verified, so anything customer-facing needs the switch. - **The switch is one-way and best made early.** Default → standard is possible at any time; standard → default is not, and switching late can force every user to re-authorize. Moving between two standard projects additionally means re-enabling advanced services and APIs and can disturb an existing Marketplace listing. - **Apps Script infers scopes by scanning the code, and it sometimes infers permissive ones.** A published add-on should pin the narrowest set explicitly in the manifest's `oauthScopes`; an inferred broad scope is exactly how a sensitive-tier add-on quietly becomes a restricted-tier one. - **A container-bound script sidesteps §2 entirely** — it is handed the spreadsheet it lives in, so it never needs to search Drive for it. That is a genuine architectural answer to the restricted-scope problem when the product can live inside the document. - **Granular consent applies across every Apps Script surface**, so partial grants are normal and the script must handle a scope the user declined rather than assuming all-or-nothing. ## Product fact — what the Sheets connector asks for > **As of 2026-09-20, Unified.to's Google Sheets connector requests**, per capability and direction: > > - **Login / identity only:** `openid`, `profile`, `email` — non-sensitive. > - **Cell, row, table and query capabilities:** read `https://www.googleapis.com/auth/spreadsheets.readonly`; > write `https://www.googleapis.com/auth/spreadsheets`. No Drive scope — **sensitive tier**. > - **Capabilities that locate or list spreadsheets, and the file-level capability:** read adds > `https://www.googleapis.com/auth/drive.readonly`; write adds `https://www.googleapis.com/auth/drive.file`. The > file-level read and write paths also add `https://www.googleapis.com/auth/drive.labels.readonly`, because that > capability reuses the Drive connector's implementation, which resolves Drive labels. > > **So the read and write sides land in different tiers.** The write configuration tops out at `spreadsheets` + > `drive.file` — **sensitive**, no security assessment. The read configuration includes `drive.readonly`, which is > **restricted**: enabling any read path that lists or locates spreadsheets puts the registering app into > restricted-scope verification **and an annual CASA assessment**. This is §2 made concrete, and it means the cheapest > honest answer is often to enable only the capabilities actually used. > > Auth quirks worth knowing: the authorize request carries `access_type` and `prompt`, so the offline/consent > behaviour the base skill describes is available; token exchange and refresh are standard form-POSTs against Google's > endpoints. The connector also supports a **Google service-account credential** as an alternative to the user OAuth > flow, requesting `spreadsheets` plus `drive.labels.readonly` — that path has no consent screen and no user scopes, > and inherits the service-account constraints in `google-drive-oauth-app` §4. Finally, it normalizes Google's `403` > rate-limit responses into `429`, so quota pressure surfaces as backoff rather than as an auth failure. > > Scope sets change. This note is dated, not live; confirm with the connector's owner before submitting anything. ## 7. Sheets-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Make one real read of cell values and confirm the **Google Sheets API is enabled** on the project — a perfectly configured client against a project without the API fails at the first call, not at authorization. 2. Compare the **granted** `scope` against the requested one. Granular consent lets a user drop, say, the Drive scope and keep the Sheets one; the failure then appears later as a missing file rather than as a consent error. 3. If the app uses `drive.file`, test the asymmetry deliberately: a spreadsheet the user picked is readable, **and one that was never picked is not**. Testing only against a file the app itself created proves nothing. 4. Test against a spreadsheet the authorizing user **does not own** but can open, and one in a shared drive if those matter — that is where permission problems masquerade as scope problems. 5. Exercise one real batch write with `valueInputOption` set as production sets it, and confirm formulas and dates land as intended rather than as strings. 6. Push past 60 requests in a minute on purpose once, and confirm the client backs off rather than re-authorizing. | Symptom | Cause | | --- | --- | | Every Sheets call fails although the scopes look right | Google Sheets API not enabled on the project | | Auth succeeds; reading a specific spreadsheet 404s | With `drive.file`, a file the user never picked is invisible, not forbidden (§2) | | The app cannot list the user's spreadsheets | Expected — no Sheets method lists spreadsheets; that needs a Drive scope (§2) | | `403` with `rateLimitExceeded` / `userRateLimitExceeded`, or `429` | Per-minute quota, not authorization — back off, never re-authorize (§5) | | Writes land in the wrong tab | A1 range with no sheet name resolves to the first **visible** sheet, or an unquoted name collided with a named range (§3) | | A whole batch fails for one bad row | `batchUpdate` is atomic by design (§4) | | Request times out on a big sheet | The 180-second per-request processing limit (§5) | | Previously written row anchors stop resolving | Project-visible developer metadata is scoped to the Cloud project that created it (§3) | | Verification asks for a security assessment on a "Sheets app" | A restricted Drive scope is in the declared set (§2) | | A published Apps Script add-on cannot be verified | It is still on a default Cloud project (§6) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - The product needs to **enumerate or search a user's spreadsheets** and nobody has confirmed CASA budget, a named owner, and application-type eligibility (§2, and `google-drive-oauth-app` §2). Register nothing until that is answered. - Someone proposes **adding `drive.readonly` "just to list files"** on an app that is otherwise sensitive-tier — that is a tier change, an assessment and an annual renewal, and it deserves an explicit decision. - Someone proposes **migrating live connections** between `drive.readonly` and `drive.file`, or moving an Apps Script project to a different Cloud project — both re-consent every user (§2, §6). - Expected volume needs a **quota increase**, or the planned move to charging for over-quota usage changes the cost model (§5). - A single spreadsheet is near the **10-million-cell** ceiling and the design assumes it can keep growing (§5). - The Sheets scope tiers or the method list do not match the **Sheets platform state** section above. ## References Sheets-specific only; the base skill carries the generic Google OAuth references, and `google-drive-oauth-app` carries the Drive-side ones (restricted-scope application types, the Picker, shared drives). Verified to resolve on 2026-09-20. - Choose Google Sheets API scopes (the two Sheets scopes, their tiers, the Drive options, the no-per-sheet-scope rule) — https://developers.google.com/workspace/sheets/api/scopes - Google Sheets API overview (spreadsheet/sheet IDs, A1 and R1C1 notation, named and protected ranges) — https://developers.google.com/workspace/sheets/api/guides/concepts - Usage limits (per-minute quotas, one request per batch, atomicity, 180-second timeout, planned over-quota charging) — https://developers.google.com/workspace/sheets/api/limits - Read and write cell values (`valueInputOption`, render options, append, omitted trailing rows) — https://developers.google.com/workspace/sheets/api/guides/values - Update spreadsheets with `batchUpdate` (request types, atomicity, field masks) — https://developers.google.com/workspace/sheets/api/guides/batchupdate - Read, write and search metadata (developer metadata, project vs document visibility, 30,000-character limits) — https://developers.google.com/workspace/sheets/api/guides/metadata - `spreadsheets` REST resource — the method list with no `list` — https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets - `GridRange` and `DataFilter` semantics (zero-based, half-open) — https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets/other - Improve performance (`fields` partial responses, gzip) — https://developers.google.com/workspace/sheets/api/guides/performance - Files you can store in Google Drive (10M cells, 18,278 columns, Connected Sheets caps) — https://support.google.com/drive/answer/37603 - Apps Script Google Cloud projects (default vs standard, the one-way switch, what standard is required for) — https://developers.google.com/apps-script/guides/cloud-platform-projects - Apps Script authorization scopes (inferred scopes, `oauthScopes` manifest, granular consent) — https://developers.google.com/apps-script/concepts/scopes ## google-slides-oauth-app URL: https://docs.unified.to/skills/google-slides-oauth-app/SKILL.md # Google Slides OAuth2 App Registration The Slides scopes are the cheap half of this job. `presentations` and `presentations.readonly` are **Sensitive** on Google's own Slides scope page — there is no restricted Slides scope, so an app that holds nothing else pays only what the base skill's §5 charges a sensitive-tier app, and none of what it charges a restricted one. What makes a Slides integration expensive is the scope beside them. **The Slides API addresses a presentation by `presentationId` and exposes no method that lists, searches, copies, moves, exports or watches one.** Enumerating a customer's decks, rendering one to PPTX or PDF, dropping a generated deck into a folder, reading its comment threads — every one of those is a Drive call. Which Drive scope you pair with `presentations` is therefore the decision that sets the project's tier, and it is a product decision, not a console setting. That fork is §2, and it is the point of this file. Settle it before anyone touches Data Access. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns the console mechanics and this file does not repeat them: - The Cloud project and organization, enabling the APIs a scope belongs to, and who may administer the project. - The Google Auth Platform pages (Branding, Audience, Data Access, Clients), the **Web application** client, and the redirect-URI matching rules with the per-data-center callback list. - The generic scope model: declaring every scope, the three tiers and what each costs, the rule that an app takes the tier of its most sensitive scope, **the tier of every Drive scope**, and the refresh-token conditions on the authorize URL. - The client secret's one-shot visibility and how rotation works without re-consenting customers. - Publishing status and audience, the user caps, brand and scope verification, and the annual third-party security assessment that a restricted scope drags in. - The generic OAuth failure table — including what a `403` carrying a quota reason means and what not to do about it. If you are reading only this file, you are missing all of the above. **Read `google-drive-oauth-app` too the moment a Drive scope enters the picture.** It owns the Drive-side depth: what each Drive scope costs, the permitted-application-type gate that no paperwork can satisfy, the `drive.file` + Picker trade-off, shared drives, and the domain-ownership problem. This file says *which* Drive scope each product shape needs; that file says what one costs. ## Inputs to collect before you start The base skill lists the console inputs. These are the Slides-specific ones, and the first two decide the tier: | Input | Notes | | --- | --- | | **Where do presentation IDs come from?** | The user pastes or picks one, an upstream record carries it, the app created the deck — or the app must discover them. This is the Drive-dependency question (§2) | | **Read decks, or generate and edit them?** | `presentations.readonly` vs `presentations`; and on the Drive side, read vs write (§1, §2) | | **Must generated decks land in a particular Drive folder or shared drive?** | Creating is Slides; *placing* the file is a Drive call with Drive scopes (§2) | | **Is any output a rendered file or an image?** | PPTX/PDF/ODP/TXT is a Drive export; per-slide images are the thumbnail method and nothing else (§4) | | **Does the product re-edit decks other people are also editing?** | Decides whether revision pinning is optional or mandatory (§3) | | **Expected read, thumbnail and write volume per customer** | Thumbnails draw on a separate, much smaller quota bucket (§6) | | **Do comments, speaker notes or slide transitions matter?** | One is preview-only, one is partly writable, one does not exist (§5) | ## Quick Start 1. Work the base skill's console steps, and **enable the Google Slides API** on the project — plus the Google Drive API if §2 lands on any Drive scope. A perfect client against a project with the API off fails at the first call, not at authorization. 2. Settle §2 — the Drive dependency — **before** declaring anything on Data Access. It decides the whole project's tier, and the base skill's §5 is where you look up what that tier costs. 3. If the answer is a whole-Drive scope, switch to `google-drive-oauth-app` for the application-type gate and the assessment ownership, and start verification now rather than after launch. 4. Declare the narrowest Slides scope the product needs — and check §2's redundancy trap before declaring one at all. 5. Finish the base skill's capture, round-trip and handoff steps, adding the Slides checks in §7. ## Slides platform state (verified 2026-09-20 — re-verify before trusting) - **Slides scope tiers, from the Slides API's own scope page:** `https://www.googleapis.com/auth/presentations` and `https://www.googleapis.com/auth/presentations.readonly` are both **Sensitive**. The same page also lists the two Sheets scopes (for linked charts) and three Drive options; for what those Drive labels mean and cost, see the base skill's §5 and `google-drive-oauth-app`. **There is no restricted Slides scope** — the restricted tier only ever arrives through Drive. - **The `presentations` resource exposes exactly three methods**: `presentations.create`, `presentations.get` and `presentations.batchUpdate`, plus `presentations.pages.get` and `presentations.pages.getThumbnail` on the pages sub-resource. **There is no list, no search, no copy, no export, no delete and no change-notification method.** - **`presentations.get`, `pages.get` and `getThumbnail` accept any one of** `presentations`, `presentations.readonly`, `drive`, `drive.readonly` or `drive.file`. **`presentations.create` accepts** `presentations`, `drive` or `drive.file`. **`batchUpdate` accepts** those three plus `drive.readonly`, `spreadsheets` and `spreadsheets.readonly` — the Sheets entries are there for linked-chart requests, not as a substitute for a Slides write scope. - **`getThumbnail` returns a URL, not bytes, and "the URL to the image has a default lifetime of 30 minutes."** Sizes are `LARGE` (1600px wide), `MEDIUM` (800px) and `SMALL` (200px); PNG is the only MIME type. - **Drive exports a presentation to PPTX, ODP, PDF and plain text — and nothing else.** There is no PNG, JPEG or SVG export for a presentation; Drive lists those only for drawings. - **Slides quotas:** 3,000 read requests/minute/project and 600/minute/user/project; **a separate "expensive read" bucket covering `getThumbnail` at 300/minute/project and 60/minute/user/project**; 600 write requests/minute/project and 60/minute/user/project. - **Comment threads on a presentation are Developer Preview.** `presentations.get` and `pages.get` take a `commentsViewMode` parameter, and `batchUpdate` carries comment requests, all flagged Developer Preview. If the scope table or the method list does not look like this, stop and report what you actually see rather than proceeding. ## 1. The Slides scopes | Scope | Grants | Tier | | --- | --- | --- | | `https://www.googleapis.com/auth/presentations.readonly` | See all the user's Google Slides presentations | **Sensitive** | | `https://www.googleapis.com/auth/presentations` | See, edit, create and delete all the user's Google Slides presentations | **Sensitive** | Two consequences, both easy to get backwards: - **Neither is restricted.** A Slides integration that never touches Drive stays in the sensitive tier, which by the base skill's §5 is a materially cheaper project than a restricted one. Worth establishing before anyone budgets for a restricted-tier review out of habit — the Slides half of this integration never needs one. - **Both are all-presentations scopes.** `presentations.readonly` reads *every* deck the user can open, given its ID. "Read-only" narrows the write risk, not the breadth. The only per-file access that exists for Slides comes from the Drive side (§2), and Google's own recommendation on the Slides scope page is to prefer the per-file option where a product can live with one. ## 2. The Drive dependency — the decision that sets the project's tier **This is the centrepiece. Raise it as a product decision; do not make it on the owner's behalf.** The Slides API can `get` a presentation, `create` a blank one, read a page, thumbnail a page and `batchUpdate` one. All of it is addressed by `presentationId`. It cannot answer "which decks does this customer have?", "which template is called Q3 Review?", "give me this as a PPTX", "put the deck I just made in the Sales folder" or "duplicate this template". Google's own Slides guides point at Drive for every one of those: `files.list` with a `mimeType` query to find presentations, `files.copy` to duplicate one, `files.update` or `files.create` to place one in a folder, `files.export` to render one. That last one is not optional for most template products. **A presentation created through the Slides API lands in the user's root Drive folder** — moving it anywhere else, including into a shared drive, is a Drive call needing Drive scopes. So the real question is not *which Slides scope* but **where presentation IDs come from, and where output has to go**: | Path | Scopes beyond the Slides one | What it costs | What the product can do | | --- | --- | --- | --- | | **A. Discover through Drive** | a whole-Drive scope — read or write; look its tier up in base §5 before budgeting | The heaviest path: the base skill's restricted-tier review **plus** the permitted-application-type gate in `google-drive-oauth-app` §2 | Enumerate, search, sync, copy templates, file output into folders and shared drives, export, read comments. The full product. | | **B. Per-file through the Picker** | `drive.file` | Base §5 and the Drive skill; materially the lightest option Google offers here | Only decks the user explicitly picked, decks shared with the app, and decks the app created — including creating, editing, thumbnailing and exporting those. **No enumeration, ever.** | | **C. No Drive scope at all** | none | Sensitive, from the Slides scope alone | Read or edit a deck **whose ID the app already has**, and create new decks in the user's root folder. No listing, no copying a template, no folder placement, no export, no comments. | Path **A** is what "sync the customer's decks" and "clone our template into their Drive" both mean, and the Drive skill's application-type gate applies before any budget conversation — an app outside the permitted categories cannot obtain those scopes at any price. Path **B** is a different product, not a cheaper configuration: the user hands over one file at a time through the Google Picker. Worth knowing how little Slides capability it loses — **`drive.file` is accepted by every Slides method**, `get`, `pages.get`, `getThumbnail`, `create` and `batchUpdate` alike, and it is also accepted by Drive's `files.export`. Everything path B gives up, it gives up on the Drive side: finding files the user never handed over. Path **C** is the one people forget and is exactly right for some products. A deck generator that is handed a template ID by an upstream system, or that only ever operates on decks it created and recorded the IDs of, never needs a Drive scope. `presentations.create` returns the created presentation including its `presentationId`, so an app that makes its own decks can keep working without ever listing anything — as long as root-folder placement is acceptable. ### The redundancy trap **A whole-Drive read scope already authorizes `presentations.get`, `pages.get` and `getThumbnail`; a whole-Drive write scope already authorizes `create` and `batchUpdate` as well.** An app that has already accepted whole-Drive scopes gains **nothing** by also declaring `presentations` or `presentations.readonly`. It adds a line to the consent screen, a scope to justify at review, and a scope to re-justify at every renewal, for zero new capability. Check it against the real call list before declaring; it is one of the few scope reductions that costs nothing. The inverse trap is quieter and worse: **`presentations.readonly` does not authorize `files.export`.** An app that declared Slides scopes only and then adds "download as PPTX" discovers mid-sprint that the feature needs a Drive scope — `drive.file` if the deck was picked or app-created, otherwise a whole-Drive one and the review that comes with it. ### Switching later is a re-consent event Moving a live integration between these paths changes the requested scope set, which re-consents every connected customer, and moving to path B adds a Picker to the UI. Say so plainly rather than proposing it as a configuration tweak. ## 3. The presentation model, object IDs, and index arithmetic across calls You do not need to implement Slides to register its client, but the shape of the API explains most of the support tickets that follow, and a verification demo video has to show it working. - A presentation is **pages containing page elements**, not a document flow. Pages come in four kinds — **slides**, **layouts**, **masters** and **notes pages** — and elements are `Shape`, `Image`, `Video`, `Line`, `Table`, `WordArt`, `SheetsChart` or a `Group` of them. Text lives inside shapes and table cells, nowhere else. - **Notes masters are read-only in the Slides API**, and on a notes page **only the text in the speaker-notes shape can be modified.** A product that wants to restyle handouts cannot. - **Object IDs can be supplied by the caller**, and that is the trick that makes multi-step batches work: a `CreateSlideRequest` can name its own `objectId`, and a later request in the *same* batch can address it. Google's rules: unique among all pages and page elements in the presentation, starting with `[a-zA-Z0-9_]`, remaining characters also allowing `-` and `:`, **length between 5 and 50 characters inclusive**. Omit it and Google generates one. A connector that mints IDs from a customer string will eventually produce a 4-character ID or a collision, and the whole batch fails. - **`batchUpdate` is ordered and atomic.** "Each request is validated before being applied. If any request is not valid, then the entire request will fail and nothing will be applied." The server processes subrequests in the order given, so a later subrequest can depend on an earlier one — and replies come back positionally, some empty. - **Text positions are indexes, and they are fragile.** `InsertTextRequest.insertionIndex` is zero-based, measured **in Unicode code units** against the shape's `TextElement` indexes, and Google notes the index "may be adjusted to prevent insertions inside Unicode grapheme clusters", inserting immediately after the cluster instead. Some control characters and Private Use Area characters are silently stripped from inserted text. Offsets computed with a different string model, or assumed to survive an emoji, land in the wrong place without erroring. - **Across calls, indexes and object IDs go stale.** Any collaborator — or a second process of your own — editing between your `get` and your `batchUpdate` invalidates what you computed, and the API will cheerfully apply it somewhere else. The answer is `WriteControl`: keep the `revisionId` from the `get` and send it back as `requiredRevisionId`, and the write is refused with a **400** if the deck moved. An integration that edits decks humans also edit and never pins a revision is not "usually fine"; it is silently corrupting slides under concurrency. Google is explicit that with collaborators present "the presentation might not exactly reflect your changes." - **`presentations.create` makes a blank deck with the given title** and nothing else; content arrives via `batchUpdate`. Create-then-populate is two calls, always. ## 4. Rendering: thumbnails are not export Two different operations that people ask for interchangeably: - **`presentations.pages.getThumbnail`** renders **one page** to a PNG and returns a **URL with a default lifetime of 30 minutes**, at 1600px, 800px or 200px wide. It is the only image rendering the Slides API does. Two design consequences: a product that stores thumbnails must fetch the bytes promptly rather than persisting the URL, and a product that thumbnails a whole deck pays one call per slide against the small "expensive read" bucket (§6). - **`files.export` on the Drive API** renders a whole presentation to **PPTX, ODP, PDF or plain text** — and it is a Drive call with Drive scopes, not a Slides one. There is **no image export for a presentation**; "give me a PNG of every slide" is the thumbnail loop above, not an export. So "let users download the deck" and "show a slide preview" are two different features with two different scope stories, and only the second is reachable without Drive. ## 5. What the API does not do that people expect Half the scoping arguments on a Slides project come from assuming one of these exists: - **No enumeration, search or copy.** All three are Drive (§2). There is no Slides equivalent of "list my decks." - **No change notifications.** The Slides API has no watch method. Detecting that a deck changed means polling — a `get` to compare `revisionId`, or Drive-side change tracking with a Drive scope. - **No slide transitions or element animations.** Google's `Request` catalog has no request for either. A deck generated through the API is static; transitions have to be in the template the deck was copied from, which is itself a Drive `files.copy`. - **No presenting or slideshow control**, and no access to revision history — revisions are a Drive resource. - **No image export**, per §4. - **Comments are Developer Preview.** `commentsViewMode` on `presentations.get` and `pages.get`, and the comment requests in `batchUpdate`, are all part of Google's Developer Preview Program. Do not plan a launch on them; if comments must ship, the Drive comments API and its Drive scopes are today's answer. - **Speaker notes are partly writable and handouts are not** (§3). ## 6. Quotas | | Per minute per project | Per minute per user per project | | --- | --- | --- | | **Read requests** | 3,000 | 600 | | **Expensive read requests (`getThumbnail`)** | 300 | 60 | | **Write requests** | 600 | 60 | What matters when sizing a multi-tenant connector: - **The thumbnail bucket is the one that breaks first.** Sixty thumbnails a minute for one customer's user is one 60-slide deck per minute — a "generate previews for the whole library" feature hits the ceiling immediately, and it is a tenth of the ordinary read allowance. - **The per-user-per-project limits bind before the project ones** for a single customer's initial sync. - **A whole `batchUpdate` counts as one request**, however many subrequests it carries, and it is authenticated once. Batching is a quota strategy as much as a latency one — and it is also what keeps an edit atomic. - Google's documented remedy for exceeding a Slides quota is **exponential backoff**, and its documented status code here is `429`. The base skill's symptom table covers the other code Google sometimes uses and what not to do when you see it. - These are Slides quotas only. Discovery, copying, folder placement and export consume **Drive** quota, counted separately. ## Product fact — what the Slides connector asks for > **As of 2026-09-20, Unified.to's Google Slides connector requests:** > > - **File read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/drive.readonly`, > `https://www.googleapis.com/auth/drive.labels.readonly`, `https://www.googleapis.com/auth/presentations.readonly` > - **File write:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/drive`, > `https://www.googleapis.com/auth/drive.labels.readonly`, `https://www.googleapis.com/auth/presentations` > - **Employee-directory read:** `openid`, `profile`, `email`, `https://www.googleapis.com/auth/drive.readonly` — > note there is **no Slides scope in this configuration at all**; it resolves people from the Drive file surface. > - **Login/identity only:** `openid`, `profile`, `email` > > **This is a path A connector (§2).** It reaches presentations as Drive files filtered to the Google Slides MIME > type, so the whole-Drive scopes are load-bearing and every configuration except login-only inherits whatever the > base skill's §5 says a whole-Drive scope costs — including the permitted-application-type gate in > `google-drive-oauth-app` §2. The Slides scopes it requests alongside them sit at the sensitive tier and do not raise > that; by the redundancy trap in §2 they are also already implied by the Drive scopes it holds, so confirm with the > connector's owner whether they are still needed before justifying them at review. > > There is **no `drive.file` variant**, so path B is not available without a change on the connector side. The > connector does support a **Google service-account credential** as an alternative to the user OAuth flow, minting > tokens for the presentations, drive and drive-labels-read scopes — the domain-wide route discussed in > `google-drive-oauth-app` §5. > > Two auth quirks worth carrying into the handoff: the authorize request is built with `access_type` and `prompt` > parameters, so the base skill's refresh-token conditions are already wired in and should be verified rather than > assumed; and the connector remaps Google's quota-flavoured `403` responses to `429` so reads back off instead of > being mistaken for an authorization failure. > > Scope sets change. This note is dated, not live — **confirm the current set with the connector's owner** before > declaring scopes or submitting anything for review. ## 7. Slides-specific end-to-end checks On top of the base skill's authorize → refresh round trip, and the Drive checks in `google-drive-oauth-app` §7 if a Drive scope is in play: 1. **Enable both APIs you actually call.** Slides API always; Drive API if discovery, copying, folder placement, export or comments are in scope. A missing enablement fails at the first call, long after consent looked fine. 2. `presentations.get` a real deck and confirm the page tree comes back — not just a 200 on the token exchange. 3. **Test discovery the way the product will do it.** On path B, confirm a Picker-selected deck is readable **and that a never-picked one is not**. Testing only against a deck the app itself created passes on `drive.file` and proves nothing. 4. If the product generates decks, confirm **where the file lands** and that moving it to the intended folder or shared drive is authorized. Root-folder-only output is a product defect discovered in week two otherwise. 5. If previews are a feature, call `getThumbnail` and confirm the client **fetches the bytes inside the 30-minute window** rather than storing the URL — then thumbnail a deck with enough slides to touch the expensive-read ceiling (§6). 6. If export is a feature, run a real `files.export` to PPTX and to PDF, and confirm it is authorized by the Drive scope you declared — not by the Slides one. 7. If the app writes, do a `get` → `batchUpdate` round trip **with `requiredRevisionId` set**, and prove the failure path by editing the deck between the two calls. 8. Inspect the granted `scope` in the token response against what was requested; a granular-consent screen lets the user drop one, and a partial grant fails later on a call rather than at consent. | Symptom | Cause | | --- | --- | | Auth succeeds, every Slides call fails | Google Slides API not enabled on the project | | "We can't find any of the customer's presentations" | No Slides method lists anything — discovery is Drive (§2) | | `presentations.get` works, PPTX or PDF export 403s | `files.export` does not accept the Slides scopes; it needs a Drive scope (§4) | | Generated decks all appear in the user's root Drive folder | Expected — placing a file is a Drive call with Drive scopes (§2) | | Thumbnail URLs 404 for saved previews | The URL's default lifetime is 30 minutes; fetch the bytes, do not persist the URL (§4) | | Previews throttle far sooner than reads do | `getThumbnail` draws on the separate expensive-read bucket (§6) | | `400` on a write that worked a moment ago | `requiredRevisionId` no longer matches — the deck was edited between calls (§3) | | Edits land on the wrong text under collaboration | Stale indexes computed before a collaborator's edit, with no revision pinned (§3) | | Offsets drift on slides containing emoji | Indexes are Unicode code units and may shift to avoid splitting grapheme clusters (§3) | | Whole batch rejected for one bad subrequest | `batchUpdate` is atomic by design (§3) | | `CreateSlideRequest` rejected for a valid-looking ID | Object IDs must be 5–50 characters and unique in the presentation (§3) | | Created deck is empty despite content in the request | `create` makes a blank deck; populate with `batchUpdate` (§3) | | Transitions and animations never appear on generated decks | The API has no request for either — they must come from the copied template (§5) | | Comments never appear | Comment support in the Slides API is Developer Preview (§5) | ## Stop and ask Beyond the base skill's list and the Drive skill's list: - **Nobody has answered where presentation IDs come from, or where generated decks must land.** Register nothing until §2 is settled — it decides the tier for the whole project and is close to irreversible once customers are connected. - The product needs Drive-wide discovery or template copying and there is **no confirmed budget and named owner** for what the base skill's §5 says that costs, or the app may not qualify as a **permitted application type** for whole-Drive scopes. - Someone proposes adding `presentations` / `presentations.readonly` to an app that already holds whole-Drive scopes — confirm against the real call list first; it is usually pure review surface for no capability (§2). - A launch plan depends on **Developer Preview** comment functionality (§5), or on transitions, animations, image export or change notifications the API does not have (§5). - Someone wants to move live connections between §2's paths — that re-consents every customer and, for path B, changes the product's UI. - The Slides scope tiers or the method-to-scope mapping do not match the **Slides platform state** section above. ## References Slides-specific only; the base skill carries the generic Google OAuth references and `google-drive-oauth-app` carries the Drive scope and assessment references. Verified to resolve on 2026-09-20. - Choose Google Slides API scopes (the `presentations` tiers and the Drive options) — https://developers.google.com/workspace/slides/api/scopes - Slides API introduction (pages, page elements, notes pages read-only, batch updates) — https://developers.google.com/workspace/slides/api/guides/overview - Create and manage presentations (root-folder placement, moving via Drive, copying via `files.copy`) — https://developers.google.com/workspace/slides/api/guides/presentations - Text structure and styling (`TextElement` sequences, indexes, inserting text) — https://developers.google.com/workspace/slides/api/concepts/text - Page elements — https://developers.google.com/workspace/slides/api/concepts/page-elements - Transforms (page element positioning) — https://developers.google.com/workspace/slides/api/guides/transform - Merge data into Slides (`ReplaceAllText` and template-driven generation) — https://developers.google.com/workspace/slides/api/guides/merge - `presentations` REST resource — the three-method list — https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations - `presentations.get` (authorization scopes) — https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations/get - `presentations.create` (scopes; creates a blank presentation) — https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations/create - `presentations.batchUpdate` (scopes, atomicity, `WriteControl.requiredRevisionId`) — https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations/batchUpdate - `Request` reference (caller-supplied object IDs, `insertionIndex` in Unicode code units) — https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations/request - `presentations.pages.get` (scopes, `commentsViewMode`) — https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations.pages/get - `presentations.pages.getThumbnail` (30-minute URL lifetime, sizes, PNG only) — https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations.pages/getThumbnail - Batch requests (one batch = one request, ordered subrequests, single authentication) — https://developers.google.com/workspace/slides/api/guides/batch - Use field masks (partial reads and writes) — https://developers.google.com/workspace/slides/api/guides/field-masks - Improve performance — https://developers.google.com/workspace/slides/api/guides/performance - Slides API usage limits (read, expensive-read and write quotas; backoff) — https://developers.google.com/workspace/slides/api/limits - Manage comments (Developer Preview, `commentsViewMode`) — https://developers.google.com/workspace/slides/api/guides/comments - Troubleshoot Slides authentication and authorization — https://developers.google.com/workspace/slides/api/troubleshoot-authentication-authorization - Drive `files.export` reference (Drive-only scopes) — https://developers.google.com/workspace/drive/api/reference/rest/v3/files/export - Drive export MIME types (PPTX, ODP, PDF, TXT for presentations — no image export) — https://developers.google.com/workspace/drive/api/guides/ref-export-formats - Search for files and folders (the `mimeType` query used for deck discovery) — https://developers.google.com/workspace/drive/api/guides/search-files - Google Workspace Developer Preview Program — https://developers.google.com/workspace/preview ## google-tasks-oauth-app URL: https://docs.unified.to/skills/google-tasks-oauth-app/SKILL.md # Google Tasks OAuth2 App Registration **This is the cheap Google review.** Tasks has exactly two scopes, neither is restricted, and a Tasks-only app needs app verification but **no CASA security assessment and no annual re-assessment** — the cost that dominates the Drive and Gmail skills is simply absent here. Registration is the base skill, unchanged. What is left is small and worth reading anyway, because three things bite in production and none of them fail at authorization: **completed and hidden tasks are missing from list responses unless you ask for them**, **hierarchy and ordering are not writable fields**, and **the `@default` list alias everyone uses is not in Google's current docs**. ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns all the console mechanics, and this file does not repeat them: - Choosing/creating the Cloud project, enabling the API, and who may administer it. - Configuring the app on the Google Auth Platform (Branding, Audience, Data Access, Clients) and creating a **Web application** client — the only type that yields a usable client secret. - Redirect-URI rules (exact matching, HTTPS-only, propagation delay) and the platform's per-data-center callback list. - The generic scope model — declaring every scope on Data Access, the three tiers, `access_type=offline` + `prompt=consent` as the only way to get a refresh token — and the client secret being shown **once** at creation. - `Testing` vs `In production` (including the seven-day refresh-token expiry), the 100-user caps, verification, and a Workspace admin's power to block the client or switch the service off entirely. If you are reading only this file, you are missing all of the above. ## Tasks platform state (verified 2026-09-20 — re-verify before trusting) - **There are exactly two scopes**, and the v1 discovery document (revision `20260915`) lists no others: `https://www.googleapis.com/auth/tasks` ("Create, edit, organize, and delete all your tasks") and `https://www.googleapis.com/auth/tasks.readonly` ("View your tasks"). - **Neither appears on Google's restricted-scope list.** That list is authoritative and enumerates Gmail, Drive, Fit, Chat, Data Portability, Photos Ambient and Health scopes — Tasks is not on it (§1). - **API host `tasks.googleapis.com`, service path `/tasks/v1`.** Two resources only: `tasklists` (delete, get, insert, list, patch, update) and `tasks` (clear, delete, get, insert, list, move, patch, update). - **There is no `watch` / push-notification method.** Nothing in the API delivers change events; any "webhook" over Tasks is polling that someone else built. - **Documented quota: 50,000 queries per day**, courtesy limit, per project (§5). If the API surface does not look like this, stop and report what you actually see. ## Inputs to collect before you start The base skill lists the console inputs. Tasks adds only these: | Input | Notes | | --- | --- | | **Read-only, or read-write?** | The whole scope decision. Note that *reordering and re-parenting are writes* (§3) | | **Must completed / cleared / assigned tasks be visible?** | Changes the list calls, not the scopes (§4) | | **User OAuth, or a service account with domain-wide delegation?** | Both work; they share a quota bucket differently (§5) | | **Is "tasks on the calendar" in the requirement?** | Almost always means Tasks, not Calendar (§6) | ## Quick Start 1. Work the base skill's console steps, and **enable the Google Tasks API** on the project. 2. Pick one scope — `tasks.readonly` or `tasks` — and declare exactly that on Data Access (§1). 3. Expect **sensitive-scope verification**: justification plus a demo video, no security assessment (§1). 4. Decide the visibility flags the integration will send *before* anyone tests a sync (§4). 5. Finish the base skill's capture, round-trip and handoff steps, adding the checks in §7. ## 1. The two scopes and their tier | Scope | Grants | Tier | | --- | --- | --- | | `https://www.googleapis.com/auth/tasks.readonly` | Read task lists and tasks | **Sensitive** | | `https://www.googleapis.com/auth/tasks` | Everything, including `insert`, `patch`, `update`, `delete`, `move`, `clear` | **Sensitive** | **Stated plainly: neither Tasks scope is restricted.** Verified against Google's restricted-scope list on 2026-09-20 — Tasks does not appear on it. So a Tasks-only app faces brand verification and **sensitive**-scope review (a per-scope justification and an unlisted demo video, which Google quotes at roughly 3–5 business days), and **not** the CASA security assessment, its assurance levels, or the annual recertification. If someone has budgeted an external assessment for a Tasks integration, they have budgeted for the wrong API. Two caveats worth saying out loud: - Google publishes an exhaustive **restricted** list but not an exhaustive **sensitive** list. "Not restricted" is the verifiable half; read the tier label off the console's Data Access picker for the final word before submitting. - **The tier of the app is the tier of its most sensitive scope** (base skill). Tasks is cheap *on its own*. Bundle it with Gmail or Drive read scopes in the same project and the project is restricted — the Tasks half saves nothing. There is no narrower alternative to choose between: no per-list scope, no "metadata only" variant, no app-private equivalent of `drive.file`. The only lever is read-only versus read-write, and §3 decides that more often than people expect. ## 2. The model: task lists, tasks, and `@default` Two levels, and that is the entire hierarchy of containers: - **Task lists** — `GET /users/@me/lists`. A user can have up to **2000 lists**; `maxResults` defaults to 1000 and caps at 1000, so one page usually holds everything, but the `nextPageToken` still has to be honoured. - **Tasks** — `GET /lists/{tasklist}/tasks`, always scoped to one list. `maxResults` defaults to **20** and caps at **100**, which is the single most common cause of "the sync only found twenty tasks". Cursor pagination via `pageToken` / `nextPageToken`. There is no cross-list task query. Anything resembling "all of the user's tasks" is a fan-out: list the lists, then page each one. Budget the quota for that shape (§5). > **`@default` is undocumented.** The `@default` task-list alias is used everywhere in the wild, but as of 2026-09-20 > the string appears **nowhere** in the Tasks REST reference or in the v1 discovery document. `@me` *is* documented, in > the `users/@me/lists` path. Treat `@default` as an undocumented convenience: do not build a sync on it, and do not > put it in a customer-facing configuration. Enumerate the lists and match on an identifier you were given. ## 3. Hierarchy and ordering: `parent`, `position`, and why `move` is its own call A task carries `parent` (omitted for a top-level task) and `position` (a string compared **lexicographically** against siblings — greater sorts later). Both are documented **output-only / read-only**. That is the trap: **you cannot re-parent or reorder a task by writing `parent` or `position`.** Those fields on an update are ignored, silently — the call succeeds and nothing moves. - **Re-parent, reorder, or move between lists:** `POST /lists/{tasklist}/tasks/{task}/move`, with optional query parameters `parent`, `previous` (the new previous sibling; omit to place first) and `destinationTasklist`. - **At creation only,** `insert` accepts `parent` and `previous` as query parameters, so a task can be born in place. - **`move` requires the full `tasks` scope.** Reordering is a write. An integration that is "read-only except it keeps our ordering in sync" is a read-write integration, and needs the sensitive write scope (§1). Documented edge cases on `move` — each an error, not a silent no-op: a `parent` or `previous` task must exist in the list and **must not be hidden**; assigned and repeating tasks can neither have subtasks nor become subtasks; a task that is both completed and hidden cannot be nested and can only be moved to position 0. Limit: **2,000 subtasks per task**. Two more read-only-field surprises while mapping: `due` records **date only** — the time portion is discarded on write, and the time a task is scheduled for "isn't possible to read or write using the API" — and `hidden`, `links`, `webViewLink` and `assignmentInfo` are all output-only. ## 4. The silent disappearances: completed, hidden, deleted, assigned Four flags on `tasks.list` decide what comes back. Their defaults do not agree with each other, and every one of them fails by *omission* — a 200 with a short list, never an error. | Parameter | Default | What it costs you | | --- | --- | --- | | `showCompleted` | **true** | On by default, and still not enough on its own — see below | | `showHidden` | **false** | The one that actually bites | | `showDeleted` | **false** | Deleted tasks are tombstones, not gone (below) | | `showAssigned` | **false** | Tasks assigned from Docs and Chat Spaces are absent by default | **Completed tasks need both flags.** Google's own wording: `showHidden` must *also* be true to show tasks completed in first-party clients — the web UI and the mobile apps. `showCompleted=true` alone is the default and is why a sync looks correct in testing (where tasks were completed via the API) and loses history in production (where users complete tasks in the Google UI). **"Hidden" is what `clear` does.** `POST /lists/{tasklist}/clear` marks every completed task in the list `hidden`; the `hidden` field means "this task was completed when the list was last cleared". So a user tidying their list does not delete anything — it vanishes from your default list response and reappears the moment you send `showHidden=true`. Combine that with §3: a hidden task cannot be used as a `parent` or `previous`, so a sync that re-orders after a clear starts failing on tasks it can no longer even see. **Deleted tasks are tombstones.** `showDeleted=true` returns tasks with `deleted: true`. For an incremental sync that is the only way to learn a task was removed, since nothing in the API delivers change events (Tasks platform state). For a first full sync it is noise. Pair it with `updatedMin` rather than sending it blindly. The other list filters — `dueMin`/`dueMax`, `completedMin`/`completedMax`, `updatedMin` — are all optional RFC 3339 timestamps that default to *no* filtering. **Data caps that read like quota errors:** up to **20,000 non-hidden tasks per list** and **100,000 tasks in total** per user. A user at the per-list ceiling is a support conversation, not a retry. ## 5. Quotas - **Documented:** a courtesy limit of **50,000 queries per day**, per project, adjustable through the Cloud console's Quotas page (approval not guaranteed, and large increases take longer). - **Not documented in the Tasks docs:** any per-minute or per-user figure. Those limits exist and are real — they surface as **`403` with reason `rateLimitExceeded` or `userRateLimitExceeded`** — but the numbers are read off the project's Quotas page in the Cloud console, not from any published Tasks page. Do not quote a QPM figure you found in a blog post. - **A `403` rate-limit is not an auth failure**, and this is the misdiagnosis to pre-empt: Google returns quota exhaustion with HTTP 403, the same status as a permission problem, so a naive client treats it as a broken token and re-authorizes in a loop. Map the rate-limit reasons to a 429-style back-off before you ship. - **Service accounts share one bucket.** Google states explicitly that API calls by a service account "are considered to be using a single account". A domain-wide-delegation deployment therefore concentrates every impersonated user's traffic into one quota identity — the opposite of what the per-user flow does. - The §2 fan-out is the quota risk: *lists × pages of 100* per full sync, per user. Do that arithmetic against 50,000 before promising a polling interval. ## 6. Calendar overlap — which scope a reader actually needs Google Tasks shows up inside Google Calendar (and in the Gmail, Chat, Drive and Docs side panels); Google's own help page tells users to "access Tasks in Calendar in your browser". So requirements arrive written as "read the tasks on the user's calendar", and the reflex is to reach for a Calendar scope. **That is wrong in both directions:** - **A Calendar scope does not get you tasks.** The Calendar API's `eventType` enum is `birthday`, `default`, `focusTime`, `fromGmail`, `outOfOffice`, `workingLocation` — there is no task type, and the Events resource does not represent tasks at all. The Tasks API is the only documented programmatic path to a user's tasks. - **A Tasks scope does not get you the calendar view.** Because `due` is date-only (§3), a task the user scheduled for a specific time renders on the Calendar grid at that time while the API can neither read nor write that time. If the requirement genuinely depends on time-of-day placement, say now that the API cannot supply it. Practical consequence: an integration whose job is "the user's to-dos" should request **only** a Tasks scope. Adding Calendar scopes to reach tasks buys nothing and widens the verification review for no capability. One more shared-with-Calendar reality from the base skill: on work and school accounts an administrator controls whether Tasks is available at all. Where the *service* is off, calls fail with `403 PERMISSION_DENIED` regardless of how well the client is allowlisted — that is the customer's admin console, not your registration. ## Product fact — what the Tasks connector asks for > **As of 2026-09-20, Unified.to's Google Tasks connector requests:** > > - **Read** (task lists and tasks): `https://www.googleapis.com/auth/tasks.readonly` > - **Write** (task lists and tasks): `https://www.googleapis.com/auth/tasks` > - **Login/identity only:** `openid`, `profile`, `email` > > Scopes are selected per object *and per direction*, so a read-only deployment declares `tasks.readonly` alone and > never triggers the write scope. Remember §3 before choosing that: reordering and re-parenting are writes. > > Auth quirks worth confirming: the authorize request carries `access_type` and `prompt`, which is what makes a > refresh token come back on a first *and* a repeat authorization (base skill); the code exchange and refresh are > form-posted with the client secret in the body; tokens are sent as bearer. Alongside user OAuth the connector also > supports a **Google service-account credential** path scoped to `.../auth/tasks` — the domain-wide-delegation route, > with the shared quota bucket noted in §5. Paging uses `pageToken`/`nextPageToken` with `maxResults` capped at 100, > matching §2. Provider `403`s carrying a rate-limit reason are remapped to a 429-style back-off rather than treated > as an auth failure, per §5. Unlike the Drive connector, this one is not flagged as needing a custom callback domain. > > Scope sets and auth behaviour change. This note is **dated, not live** — confirm with the connector's owner before > submitting anything to Google. ## 7. Tasks-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. List the user's task lists, then list tasks in one of them, and confirm the Tasks API is enabled and returning data. 2. Re-run the task list with **`showCompleted=true&showHidden=true`** and compare counts against the first call. If they differ, your default sync is losing completed history (§4) — verify against a task completed in the Google UI, not one completed through the API. 3. Create more than 20 tasks and page the list. Anything that stops at 20 has not read `maxResults` (§2). 4. If ordering or subtasks matter, attempt a re-parent by writing `parent` on an update, confirm it silently does nothing, then do it through `move` (§3). This is the check people skip. 5. Run `clear` on a scratch list and confirm the cleared tasks reappear only with `showHidden=true` (§4). 6. Confirm the granted `scope` in the token response matches what was requested — granular consent lets a user drop a scope, and a write-scope drop fails later at `move`/`insert`, not at consent. | Symptom | Cause | | --- | --- | | Every Tasks call fails although scopes look right | Google Tasks API not enabled on the project, or the admin has Tasks off for the account (§6) | | The sync finds exactly 20 tasks per list | `maxResults` default (§2) | | Completed tasks missing, or history disappears over time | `showHidden` not sent — the user cleared the list (§4) | | Deletions never propagate | `showDeleted` not sent; no push notifications exist to carry them (§4) | | Tasks assigned from Docs or Chat Spaces never appear | `showAssigned` defaults to false (§4) | | Reordering / re-parenting "succeeds" and changes nothing | `parent` and `position` are read-only; use `move` (§3) | | `move` fails on a task that exists | Target `parent`/`previous` is hidden, or the task is assigned/repeating (§3) | | Time-of-day on a task is always lost | `due` is date-only by design (§3) | | `403` with `rateLimitExceeded` and a re-auth loop | Quota, not authorization — back off (§5) | | A Calendar-scoped app cannot see the user's tasks | Tasks are not Calendar events (§6) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - Someone has scheduled or budgeted a **CASA security assessment** for a Tasks-only app — check §1 before anyone pays. - Tasks scopes are about to be added to a project that already holds **Gmail or Drive restricted scopes**; that is a verification-scope decision, not a checkbox (§1). - The requirement depends on **time-of-day** task scheduling, **cross-list queries**, or **change notifications** — none of which the API provides (§2, §3, §6). - A design relies on **`@default`** as a stable list identifier (§2). - A **service-account / domain-wide-delegation** deployment is proposed at volume, and nobody has done the shared-quota arithmetic (§5). - The API surface or scope list does not match the **Tasks platform state** section above. ## References Tasks-specific only; the base skill carries the generic Google OAuth references. Verified to resolve on 2026-09-20. - Google Tasks API reference overview — https://developers.google.com/workspace/tasks/reference/rest - Task resource (`parent`, `position`, `hidden`, `deleted`, `due`, `assignmentInfo`) — https://developers.google.com/workspace/tasks/reference/rest/v1/tasks - `tasks.list` (`showCompleted`, `showHidden`, `showDeleted`, `showAssigned`, `maxResults`) — https://developers.google.com/workspace/tasks/reference/rest/v1/tasks/list - `tasks.move` (`parent`, `previous`, `destinationTasklist`, subtask limit) — https://developers.google.com/workspace/tasks/reference/rest/v1/tasks/move - `tasks.insert` (`parent` / `previous` at creation, per-list task limits) — https://developers.google.com/workspace/tasks/reference/rest/v1/tasks/insert - `tasks.clear` (what "hidden" means) — https://developers.google.com/workspace/tasks/reference/rest/v1/tasks/clear - `tasklists.list` (2000-list limit, `users/@me/lists`) — https://developers.google.com/workspace/tasks/reference/rest/v1/tasklists/list - Google Tasks API quotas and usage limits (50,000 queries/day; service accounts count as one account) — https://developers.google.com/workspace/tasks/limits - Google Tasks API quickstart (a working scope declaration) — https://developers.google.com/workspace/tasks/quickstart/python - Learn about Google Tasks (surfacing in Calendar and side panels; admin controls access) — https://support.google.com/tasks/answer/7675772?hl=en - Google Calendar API Events reference (`eventType` has no task value) — https://developers.google.com/workspace/calendar/api/v3/reference/events ## google-workspace-directory-oauth-app URL: https://docs.unified.to/skills/google-workspace-directory-oauth-app/SKILL.md # Google Workspace Directory OAuth2 App Registration This is not a per-user product API. The Directory API is part of the **Admin SDK**, and Google describes it as the way to "programmatically create and manage admin-controlled resources owned by a Google Workspace account." Two things follow immediately, and they decide whether the integration is even possible: - **It only exists inside Google Workspace.** There is no consumer equivalent. A personal `@gmail.com` account has no customer, no org units, no directory to read, and no admin console to consent from. A connector that lists "Google" as a directory source will be tried against consumer accounts and will fail every time. - **The scope is not the gate.** Google checks the **admin privilege of the consenting user** on top of the OAuth grant. A perfectly declared, perfectly verified client, authorized by an ordinary employee, returns `403` on its first real call. Nothing in the Cloud console fixes that — the fix lives in the customer's Admin console (§3). The compensation is that the paperwork is unusually light: checked against Google's restricted-scope list on 2026-09-20, **no `admin.directory.*` scope appears on it.** The expensive part of this registration is not verification. It is that every customer needs an administrator to connect, and some of them will need a super admin (§4). ## Built on: `google-cloud-console-oauth` **Read `google-cloud-console-oauth` first, then this file.** It owns the console mechanics and this file does not repeat them: - The Cloud project and organization, enabling the API a scope belongs to, and who may administer the project. - The Google Auth Platform pages (Branding, Audience, Data Access, Clients), the **Web application** client, and the redirect-URI matching rules with the per-data-center callback list. - The generic scope model: declaring every scope, the three tiers and what each costs, the rule that an app takes the tier of its most sensitive scope, and the refresh-token conditions on the authorize URL — plus the authoritative restricted-scope list this file checks the Directory family against. - The client secret's one-shot visibility, rotation, publishing status, audience, the user caps and verification. - The Workspace admin's app-access controls, which can block or allowlist this client independently of everything below, and the generic OAuth failure table including what a quota-flavoured `403` means. If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Directory-specific ones: | Input | Notes | | --- | --- | | **Which directory objects does the product touch?** | Users, groups, group members, org units, aliases, custom schemas, roles, devices — each is its own scope pair (§2) | | **Read-only, or does it provision?** | Read-only lands on `.readonly` scopes and on a read privilege; provisioning needs write scopes *and* write privileges (§2, §3) | | **Who at the customer will click Connect?** | A super admin, a delegated admin, or an ordinary employee — this is the question that decides whether the integration works at all (§3) | | **User OAuth, or a delegated service account?** | Different credential, different customer task, different failure modes (§4) | | **Multi-domain customers?** | `customer` vs `domain` are not interchangeable and one of them silently under-returns (§5) | | **How fresh must the directory be?** | There is no delta token; freshness comes from polling plus one push channel (§6) | | **Largest expected customer, in users and groups** | Page-size caps and a per-account rate limit that cannot be raised (§7) | ## Quick Start 1. Work the base skill's console steps, and **enable the Directory API** on the project (Google's quickstart carries the enable link). A perfect client against a project with the API off fails at the first call, not at consent. 2. Pick the narrowest scope pair per object type from §2 and declare exactly those. 3. **Decide the credential model before registering anything** (§4): a user OAuth client an admin authorizes, or a service account each customer's super admin delegates. They are different products for the customer. 4. Write the admin requirement into the product's connect instructions (§3). It is the single largest source of "your integration is broken" tickets and the single thing a console cannot fix. 5. Finish the base skill's capture, round-trip and handoff steps, adding the Directory checks in §8 — and do the round trip **from a non-admin account too**, because that is the failure you are shipping to customers. ## Directory platform state (verified 2026-09-20 — re-verify before trusting) - **Base URL `https://admin.googleapis.com/admin/directory/v1/`.** Google's own guides still show a few legacy `www.googleapis.com/admin/directory/...` forms, notably around notification channels; prefer the `admin.googleapis.com` host everywhere and treat a legacy host as something to verify, not inherit. - **Scope tiers: none of the `admin.directory.*` family appears on Google's restricted-scope list** (the base skill holds that list). The Directory API's own scope page publishes the family with meanings but **no tier labels at all**. Resolving a tier for one of them is therefore the negative test the base skill's §5 sets out, run against this family. - **The scope family is one pair per object area:** devices (`device.chromeos`, `device.mobile`, `.readonly`, `device.mobile.action`), groups and members (`group`, `group.readonly`, `group.member`, `group.member.readonly`), org units (`orgunit`, `orgunit.readonly`), users and aliases (`user`, `user.readonly`, `user.alias`, `user.alias.readonly`), user security (`user.security`), role management (`rolemanagement`, `.readonly`), custom schemas (`userschema`, `.readonly`), customers (`customer`, `.readonly`), domains (`domain`, `.readonly`) and calendar resources (`resource.calendar`, `.readonly`). - **`users.list` and `users.watch` also accept `https://www.googleapis.com/auth/cloud-platform`.** It is a project-wide Cloud scope that happens to satisfy these methods. Do not reach for it — it is enormously broader than `admin.directory.user.readonly` and will dominate a consent screen and a review. - **Page-size caps differ per resource:** users default 100, **maximum 500**; groups and group members default and maximum **200**; Chrome and mobile devices default and maximum 100. **User aliases and org units do not paginate at all.** A `pageToken` **is only valid for three days.** - **There is no sync token and no delta method anywhere in `users.list`'s documented parameters.** Incremental sync is polling plus, for users only, a push channel (§6). - **Push notifications exist for the Users resource only** — no group, member or org-unit channels. If the scope page or the caps do not look like this, stop and report what you actually see rather than proceeding. ## 1. The API this actually is Directory is one API inside the Admin SDK, alongside Reports, Data Transfer, Groups Settings and others. Its vocabulary is the vocabulary the rest of this file uses, and Google defines it on the overview page: | Term | What it is | | --- | --- | | **Customer** | The entity owning the Workspace account. Everything is scoped to one. | | **Domain** | A DNS domain attached to the account — **an account can have many**, and not all accounts have one. | | **Organizational unit (OU)** | A sub-unit of the account's tree, used to apply policy. Users sit at an `orgUnitPath`. | | **Privilege** | The ability to perform an action. This is what an admin role is made of, and what §3 turns on. | | **Role / role assignment** | A named collection of privileges, and the record granting it to a user. | | **User / Group / Member / Schema** | The resources the connector actually reads. | Google's quickstart states the prerequisites plainly: "A Google Workspace domain with API access enabled. A Google Account in that domain with administrator privileges." Treat that second sentence as a product requirement, not a developer convenience. ## 2. Scopes: narrow pairs, and the cost of getting greedy Each object area has a full scope and a `.readonly` scope; a handful have extra action scopes. The ones an employee-directory or group-sync product actually needs: | Capability | Read scope | Write scope | | --- | --- | --- | | Users and user profiles | `https://www.googleapis.com/auth/admin.directory.user.readonly` | `https://www.googleapis.com/auth/admin.directory.user` | | Groups (and, per the reference, listing their members) | `https://www.googleapis.com/auth/admin.directory.group.readonly` | `https://www.googleapis.com/auth/admin.directory.group` | | Group membership specifically | `https://www.googleapis.com/auth/admin.directory.group.member.readonly` | `https://www.googleapis.com/auth/admin.directory.group.member` | | Org units | `https://www.googleapis.com/auth/admin.directory.orgunit.readonly` | `https://www.googleapis.com/auth/admin.directory.orgunit` | | Custom user fields | `https://www.googleapis.com/auth/admin.directory.userschema.readonly` | `https://www.googleapis.com/auth/admin.directory.userschema` | Three things worth saying out loud: - **`members.list` accepts `admin.directory.group.readonly` on its own.** Its documented scope list is `group`, `group.readonly`, `group.member` and `group.member.readonly`. So a read-only group-sync product does not strictly need the member scope to enumerate members — verify against the exact method list before declaring both, because each extra scope is another consent line an admin has to accept and another thing to justify at review. - **`.readonly` still means the whole directory.** `admin.directory.user.readonly` reads every user in the customer, including fields an ordinary employee cannot see. It is read-only, not narrow, and there is no per-OU scope. The narrowing that exists is done by the admin role (§3), not by the scope string. - **Write scopes are a different conversation with the customer's security team** than read ones, because the same token that patches a job title can suspend an account. Ask whether provisioning is genuinely in the product before declaring the non-`.readonly` variants. Reading the customer's directory structure — `orgunits.list` — needs the org-unit scope; it is not implied by the user scope, and a product that renders an org chart from `orgUnitPath` strings on user records may not need it at all. ## 3. The admin role is checked in addition to the scope **This is the section that decides whether a customer can connect.** A Google OAuth grant proves the user agreed to the scope. It does not prove the user is allowed to do the thing. Google's privilege documentation is explicit that the Admin console privilege is what carries into the API: of the **Users** privilege (Create, Read, Update, Delete) it says "This privilege grants API permissions you can use to perform these operations with the Directory API," and it says the same of the **Organizational Units** privilege. The prebuilt roles page names which roles work "using the Admin API" — **Super Admin**, **User Management Admin**, **Groups Admin** and **Help Desk Admin** among them. So: - **A super admin is not required for ordinary directory reads and writes.** A delegated admin holding the right privilege — Users ▸ Read for a read-only people sync, Groups for group management — is enough, and asking a customer for a super admin when a scoped delegated admin would do is a real objection in an enterprise security review. Say which privilege you need, not "make them an admin." - **A super admin *is* required for the things that manage admins themselves**, including promoting a user (`users.makeAdmin` — Google notes "delegated administrators cannot promote users to administrative roles") and role management, and for domain-wide delegation (§4). - **The role is per customer.** Nothing about it is visible from your Cloud project, and it cannot be pre-arranged. Every new customer repeats it. ### The one thing a non-admin can do There is a real exception, and it is useful. Google documents that "while user accounts can only be modified by administrators, any user on the domain can read user profiles": a non-admin can call `users.get` or `users.list` with **`viewType=domain_public`**, and Google says `admin.directory.user.readonly` "is ideal for this use case." Two caveats it attaches: the domain-public view returns only a standard set of core fields (custom fields are public or private by schema definition), and **"Contact sharing must be enabled for the domain. Individual users with contact sharing disabled are not returned in the results."** That gives a genuine product option — a lightweight "read the company directory" connector that any employee can authorize — but it is a different product from an HR-grade sync. The admin-only view is `viewType=admin_view`, and some query capability is admin-only with it: the `orgUnitPath` search field "can only be used when `viewType=admin_view`." A connector that hard-codes the admin view will fail for a non-admin no matter how modest the data it wanted. ### The failure it produces A connection authorized by a non-admin looks perfect: consent completes, the token exchange returns an access token and a refresh token, and then the first directory call returns **`403`**. Field reports quote the message as "Not Authorized to access this resource/api" — **that exact wording is not on a Google page this skill could verify**, so treat the string as a hint rather than a contract, and diagnose on the documented causes instead. Google documents these 403 reasons for the Directory API: | Reason | Meaning | | --- | --- | | `usageLimits.accessNotConfigured` | The API is not enabled in the Cloud project. | | `domainCannotUseApis` | The customer has disabled access to the Admin SDK API. | | `forbidden` | The caller does not have rights over that customer (the reseller page's framing; the same shape covers a caller without the privilege). | The practical triage order is: is the API enabled → does the customer allow the Admin SDK API → **does the consenting user actually hold the privilege** → is the request using `admin_view` when the consenting user is not an admin. Only the first is yours to fix. ## 4. Domain-wide delegation — the other path, and why it is per-customer The alternative to "an admin clicks Connect" is a service account the customer's Workspace pre-authorizes to impersonate its users. It removes the consent screen entirely and it is how a lot of Workspace tooling works — but it is not something you register once and ship. Google's help documentation is specific: **"You must be signed in as a super administrator for this task,"** and the task lives in the customer's Admin console under **Menu ▸ Security ▸ Access and data control ▸ API controls ▸ Manage Domain Wide Delegation**, where the admin enters your **client ID** and each **OAuth scope** the application may access — Google advises the scope list "should be appropriately narrow." Other facts that change project plans: - **"Changes can take up to 24 hours but typically happen more quickly."** A customer who just saved the delegation and reports failure may simply be early. - **If Multi-party approval is enabled, a second super admin has to approve it.** That is a scheduling problem, not a technical one, and it surfaces at onboarding. - **"With domain-wide delegation, the app has access to the data belonging to all of your users."** Google recommends reviewing and deleting unused service accounts. Expect a security review; expect some customers to refuse outright. - **It is per customer, every time.** Each new Workspace repeats the whole ceremony. There is no marketplace listing, no consent screen and no self-service path that skips it. Neither model dominates. User OAuth reaches only what the authorizing admin may reach and dies when that admin leaves the company; delegation survives staff changes and reaches everything, which is exactly why some customers will not grant it. Decide deliberately, and note that scopes change on the *customer's* delegation entry — adding one later means going back to every customer's super admin. ## 5. Customer, users, members, org units The shapes here are not interchangeable, and each has a way of quietly returning the wrong set. - **`customer=my_customer` is the convention for "the account this token belongs to."** Google documents `my_customer` as an alias for the caller's own `customerId`, and a resold customer's real `customerId` in its place. **You must provide either `customer` or `domain`**, and Google's own troubleshooting is emphatic that they are not interchangeable: for `customer`, "Only use the `customerId` that was generated by Google. Don't use the actual customer's domain," and it recommends `customer` over `domain` "because if a customer has secondary domains, using the `domain` parameter only returns users with email addresses on that particular domain." A multi-domain customer whose sync is missing half its people is usually this. - **Users are not members.** Group membership is its own resource: "A group member can be a user or another group," so a members list can contain nested groups, and flattening it to people is your job. `members.list` takes **`includeDerivedMembership`** (default `false`) to expand indirect membership — omit it and members who belong only through a nested group are simply absent. Two Google-documented timing traps go with it: adding a group as a member of another group "may be a delay of up to 10 minutes before the child group's members appear as members of the parent group," and the API rejects membership cycles. - **Listing a user's groups is a different call shape.** `groups.list` takes a `userKey` to return only the groups a given user belongs to, and Google notes it **"Cannot be used with the `customer` parameter."** Per-user group resolution over a large directory is therefore one call per user, which is where an initial sync meets §7. - **Org units are a tree with no pagination.** `orgunits.list` takes an `orgUnitPath` and a `type` of `CHILDREN` (default), `ALL` or `ALL_INCLUDING_PARENT`, and returns the whole requested set in one response — there is no `pageToken` and no `maxResults`. Google's limits page bounds it instead: an OU hierarchy is **limited to 35 levels of depth** and **40,000 org units per customer**, and you cannot create or update **more than one OU per customer per second**. ## 6. There is no delta: how freshness actually works `users.list` has no `syncToken`, no `updatedMin` and no change-feed parameter — its documented parameters are `customer`/`domain`, `query`, `projection`, `customFieldMask`, `showDeleted`, `viewType`, `orderBy`, `sortOrder`, `maxResults`, `pageToken` and `event`. So an integration that wants "what changed since yesterday" has three honest options, and each has a documented catch: 1. **Full re-list and diff.** Simple, correct, and bounded by §7's page-size caps and rate limits. Note that **`pageToken` is only valid for three days**, so a paused long sync cannot be resumed later from a stored token. 2. **Search-based filtering.** `users.list` accepts a `query` with clauses over name, email, org fields, manager, `orgUnitPath`, `isSuspended`, `isArchived` and custom indexed attributes — but the documented field list has **no created-at or updated-at field**, so there is no timestamp predicate to filter on. It also carries a freshness warning: "Most user data updates within 1 hour; however, it may take up to 36 hours for new data to be reflected in all search results." A sync that trusts search results to be current will drop recent changes. 3. **Push notifications, for users only.** `users.watch` opens a channel for one `event` at a time — `add`, `update`, `delete`, `makeAdmin` or `undelete` — against a `domain` or a `customer`, posting to an HTTPS `address` with a valid certificate (Google explicitly rejects self-signed, untrusted, revoked and mismatched certificates). The channel takes a caller-generated `id` (max 64 characters), an optional `token` echoed back in a header for spoof-checking — Google says "Don't include sensitive data such as OAuth tokens" in it — and an optional `ttl` or `expiration`. **Channels expire and must be renewed**, and they are stopped through the channels-stop method rather than by deleting anything. There is **no equivalent for groups, members or org units**: those are polling, always. Two staleness facts that ruin naive change detection: Google warns that **"when retrieving all users in a domain, the value of `lastLoginTime` might be inaccurate"** and to fetch a single user for an accurate value; and renaming a user "can take up to 10 minutes to propagate across all services," with the old username retained as an alias. Anything keyed on email address rather than the immutable user ID will double-count across a rename — Google's own advice is "we also recommend not using the user email address as a key for persistent data because the email address is subject to change." ## 7. Quotas and hard limits Directory's rate-limit story is its own, and one half of it cannot be bought out of: | Code | Reason | What it is | | --- | --- | --- | | `403` | `userRateLimitExceeded` | Per-user, per-Cloud-project. **Default 2,400 queries per minute**, and raisable from the Admin SDK API quotas page of your Cloud project. | | `403` | `quotaExceeded` | Concurrent-request limit for an operation. Back off. | | `429` | `rateLimitExceeded` | Concurrency limit **per Google Workspace account, not per API client or per user — and Google states this limit "can't be increased."** | That last row is the one to design around: a busy customer can throttle every integration they use at once, including yours, and no amount of quota paperwork on your side changes it. Google's documented remedy is truncated exponential backoff with jitter, terminating after five retries at roughly 32 seconds of total delay. (The base skill's symptom table covers how to read a `403` that is really flow control.) Hard limits worth knowing before promising a sync window: - **Page sizes** (§ platform state): users max 500 per page, groups and members 200, devices 100; aliases and org units do not paginate. - **`members.list` "times out after 60 minutes."** A pathologically large group is a real failure mode. - **Provisioning throughput**: no more than **10 users per domain per second**, and **one OU create/update per customer per second**. - **Account shape**: up to **600 domains** per account (1 primary + 599 additional), **30 aliases per user**, **20 domain aliases**, group descriptions capped at 4,096 characters, and **20 users moved between OUs at a time**. - **Billing**: Google notes that on a flexible plan, creating users through this API "will have monetary impact" for the customer. A provisioning bug is a customer invoice, not just a data problem. ## Product fact — what the Workspace Directory connector asks for > **As of 2026-09-20, Unified.to's Google Workspace Directory connector requests**, per capability and direction — > every configuration on top of `openid`, `profile`, `email`, `https://www.googleapis.com/auth/userinfo.email` and > `https://www.googleapis.com/auth/userinfo.profile`, which are also the login-only set: > > - **Employee read:** `https://www.googleapis.com/auth/admin.directory.user.readonly` > - **Employee write:** `https://www.googleapis.com/auth/admin.directory.user` > - **Group read:** `https://www.googleapis.com/auth/admin.directory.group.readonly` and > `https://www.googleapis.com/auth/admin.directory.group.member.readonly` > - **Group write:** `https://www.googleapis.com/auth/admin.directory.group` and > `https://www.googleapis.com/auth/admin.directory.group.member` > > **No org-unit, custom-schema, alias, customer, domain, device or role-management scope is requested**, so those > areas of the directory are out of reach for this connector as configured — relevant if a customer asks for an org > chart or custom HR fields. > > **The connector reads with the account-wide, administrator-facing shape**: requests are scoped with > `customer=my_customer`, the full user projection, and the administrator view. Per §3 and §5, that means **an > ordinary employee cannot connect it** even though consent will succeed, and the lighter domain-public path in §3 is > not what this connector uses. Put "connect as a Workspace administrator" in the customer-facing instructions. > > Other behaviour worth carrying into a handoff: employee change events are delivered through Google's Users push > channel (created / updated / deleted), which per §6 expires and must be renewed and has no group equivalent — group > membership is read per group on demand instead. Listing is cursor-paged with a **page size capped at 200**, which is > right for groups and members and leaves headroom unused for users (§7). The authorize request carries `access_type` > and `prompt`, so the base skill's refresh-token conditions are wired in and should be verified rather than assumed, > and quota-flavoured `403` responses are remapped to `429` so reads back off instead of being read as auth failures. > The connector also supports a **Google service-account credential** minting the user, group and group-member > scopes — that is the domain-wide delegation path in §4, and it needs each customer's super admin. Separately, the > connector carries Google's SAML single-sign-on setup path; that is an Admin-console configuration and has nothing to > do with this OAuth client. > > Scope sets change. This note is dated, not live — **confirm the current set with the connector's owner** before > declaring scopes, writing customer instructions or submitting anything for review. ## 8. Directory-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. **Enable the Directory API on the project**, then make one real `users.list` call — a client that authorizes cleanly against a project without the API fails at the first call with an enablement error. 2. **Authorize once as a super admin, once as a delegated admin holding only the privilege you claim to need, and once as an ordinary employee.** The third is not a negative test you can skip: it is the experience some fraction of your customers will have, and you need to know exactly what it looks like to write the error message. 3. **Test against a multi-domain customer, or at least prove the call uses `customer` and not `domain`** (§5). A single-domain test account hides this defect completely. 4. **Test a group containing a nested group**, with and without `includeDerivedMembership`, and confirm the product's member list is what it claims to be (§5). 5. **Page past the caps**: more than 500 users, more than 200 members. Confirm the client follows `nextPageToken` and does not assume one page. 6. If change detection is a feature, open a `users.watch` channel, confirm the callback receives a notification, confirm the channel is **renewed before expiry**, and confirm it is stopped on disconnect. Then confirm what happens for groups — which is polling (§6). 7. If the product provisions, confirm on a throwaway account that the write privilege is checked separately from the read one, and that the flexible-plan billing note in §7 has been surfaced to whoever owns the customer relationship. 8. Inspect the granted `scope` against what was requested; a granular-consent screen lets an admin drop, say, the member scope, and the failure then appears later as an empty group rather than as a consent error. | Symptom | Cause | | --- | --- | | Consent succeeds, first directory call returns `403` | The consenting user has no admin privilege, or the request uses the admin view for a non-admin (§3) | | `403` for one customer only, everyone else fine | That customer disabled Admin SDK API access, or the base skill's Workspace app-access controls block the client | | Every call fails immediately after a clean registration | Directory API not enabled on the Cloud project (§3) | | Sync returns only some of a customer's people | `domain` used where `customer` was needed on a multi-domain account (§5) | | Members of a group are missing | Nested-group membership without `includeDerivedMembership`, or within the 10-minute propagation delay (§5) | | A new user does not appear in search for hours | Search index lag — up to 36 hours for new data to reach all search results (§6) | | "Last updated" never changes, or changes at random | There is no updated-at field; `lastLoginTime` is documented as possibly inaccurate on a list (§6) | | A renamed user appears twice | Keyed on email rather than the immutable user ID; the old address survives as an alias (§6) | | A long sync cannot be resumed the next week | `pageToken` is valid for three days (§6, platform state) | | `429` that no quota increase fixes | The per-Workspace-account concurrency limit, which Google says cannot be increased (§7) | | Delegation saved by the customer but calls still fail | Domain-wide delegation changes can take up to 24 hours, or a second super admin's approval is pending (§4) | | Nothing works for a `@gmail.com` account | Expected — the Admin SDK has no consumer equivalent (lede) | ## Stop and ask Beyond the base skill's list: - **Nobody has confirmed that a Workspace administrator will be the one connecting.** That is the whole feasibility question; do not register scopes against an assumption about it. - The plan requires **domain-wide delegation** and nobody has confirmed that customers' super admins will do it, or that the scope list is final — changing it later means going back to every customer (§4). - Someone proposes **write scopes** without a named owner for what happens when the connector suspends, renames or deletes the wrong account — this API provisions real identities, and on a flexible plan it also bills (§7). - Someone proposes `cloud-platform` because a method's scope list accepts it (§ platform state). That is a project-wide Cloud scope standing in for a directory read; it is a security conversation, not a shortcut. - The product needs org units, custom schemas, aliases, devices or role management and the declared scope set does not include them — adding one later re-consents every customer. - A customer's security review asks for **per-OU or per-group restriction of the token**. There is no such scope; the narrowing is the admin role they assign (§2, §3), and that answer has to come from you, accurately. - The scope family, the page-size caps or the privilege model do not match the **Directory platform state** section above. ## References Directory-specific only; the base skill carries the generic Google OAuth, verification and Workspace-admin-control references. Verified to resolve on 2026-09-20. - Directory API overview (Admin SDK framing; customer, domain, OU, privilege, role vocabulary) — https://developers.google.com/workspace/admin/directory/v1/guides - Choose Directory API scopes (the full `admin.directory.*` family, with no tier labels) — https://developers.google.com/workspace/admin/directory/v1/guides/authorizing - Prerequisites (delegated administrators and resellers; device-operation exceptions) — https://developers.google.com/workspace/admin/directory/v1/guides/prerequisites - Directory API Python quickstart (the Workspace-domain and administrator-privileges prerequisites) — https://developers.google.com/workspace/admin/directory/v1/quickstart/python - Manage user accounts (`my_customer`, `viewType=domain_public` for non-admins, `makeAdmin` is super-admin only, rename propagation, `lastLoginTime` accuracy) — https://developers.google.com/workspace/admin/directory/v1/guides/manage-users - Search for users (query fields and operators; no timestamp field; the 1-hour / 36-hour index lag) — https://developers.google.com/workspace/admin/directory/v1/guides/search-users - Manage groups — https://developers.google.com/workspace/admin/directory/v1/guides/manage-groups - Search for groups — https://developers.google.com/workspace/admin/directory/v1/guides/search-groups - Manage group members (a member can be a user or a group; nested-membership delay; cycles rejected) — https://developers.google.com/workspace/admin/directory/v1/guides/manage-group-members - Manage organizational units — https://developers.google.com/workspace/admin/directory/v1/guides/manage-org-units - Push notifications (channel setup, HTTPS certificate rules, `token` guidance, Users resource only) — https://developers.google.com/workspace/admin/directory/v1/guides/push - Directory API limits and quotas (403/429 reasons, the uncappable per-account limit, page-size caps, OU and provisioning limits, flexible-plan billing) — https://developers.google.com/workspace/admin/directory/v1/limits - `users.list` reference (parameters — note the absence of any sync token; three-day `pageToken`; scopes) — https://developers.google.com/workspace/admin/directory/reference/rest/v1/users/list - `users.watch` reference (event enum, `viewType`, scopes) — https://developers.google.com/workspace/admin/directory/reference/rest/v1/users/watch - `Users` resource reference — https://developers.google.com/workspace/admin/directory/reference/rest/v1/users - `groups.list` reference (`customer` vs `domain`, `userKey` exclusivity, 200-result cap, scopes) — https://developers.google.com/workspace/admin/directory/reference/rest/v1/groups/list - `members.list` reference (`includeDerivedMembership`, 200-result cap, 60-minute timeout, scopes) — https://developers.google.com/workspace/admin/directory/reference/rest/v1/members/list - `orgunits.list` reference (no pagination; `CHILDREN` / `ALL` / `ALL_INCLUDING_PARENT`) — https://developers.google.com/workspace/admin/directory/reference/rest/v1/orgunits/list - `channels.stop` reference (closing a push channel) — https://developers.google.com/workspace/admin/directory/reference/rest/v1/channels/stop - Troubleshoot Directory API issues (the documented 403 reasons, and `customer` vs `domain` guidance) — https://developers.google.com/admin-sdk/reseller/v1/support/directory_api_common_errors - Troubleshoot Directory authentication and authorization — https://developers.google.com/workspace/admin/directory/v1/guides/troubleshoot-authentication-authorization - Control API access with domain-wide delegation (super admin only, console path, client ID + scopes, 24-hour propagation, multi-party approval) — https://knowledge.workspace.google.com/admin/apps/control-api-access-with-domain-wide-delegation - Prebuilt administrator roles (which roles work through the Admin API) — https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles - Administrator privilege definitions (Users and Organizational Units privileges grant Directory API permissions) — https://knowledge.workspace.google.com/admin/users/administrator-privilege-definitions - Create access credentials, including the service-account and delegation path — https://developers.google.com/workspace/guides/create-credentials - Using OAuth 2.0 for server-to-server applications (service accounts and delegated authority) — https://developers.google.com/identity/protocols/oauth2/service-account ## greenhouse-oauth-app URL: https://docs.unified.to/skills/greenhouse-oauth-app/SKILL.md # Greenhouse API Credentials **Read this first: Greenhouse has no self-serve developer portal where you register an app and walk away with a client ID and secret.** There is no "create app" button for the redirect-based OAuth flow. Credentials for the Harvest v3 **Authorization Code** flow — the one a multi-tenant connector needs, where a customer clicks "Connect" and authorizes you — are issued by hand, over email, by Greenhouse Partner Support, and only after a **signed Greenhouse partnership agreement**. The partner program requires **at least one mutual customer** before it will consider you, and reviews applications in a **monthly batch**. If the user's goal is "get Greenhouse OAuth credentials this afternoon," the honest answer is that it is not available at any price without that partnership, and the run should stop at §4. What *is* self-serve is different and worth understanding before you promise anything: **each individual customer can create their own Harvest v3 (OAuth) credentials inside their own Greenhouse account** — a client ID and secret used with the **client credentials** grant, no redirect, no consent screen, valid only for that one organization. A platform can absolutely run on this: you ask each customer to generate a pair and paste it in. It is not OAuth as an app-registration exercise; it is a per-tenant secret the customer hands you. Decide which of the two models you are building against before doing anything else (§1) — most of the expensive mistakes here are people building the wrong one. Three more things that cost real time: the **Site Admin rule** (every list endpoint 403s unless the authorizing user is a Site Admin — §6), **redirect URIs you cannot edit yourself** (§5), and the **v1/v2 sunset date that has already passed** (§ Platform state). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Which API surface** | Harvest, Job Board, Ingestion, Assessment or Onboarding — they do not share credentials (§1) | | **Partner or custom** | Do you have a signed Greenhouse partnership agreement, or are you collecting per-customer credentials? (§1, §4) | | **Integration name**, 128×128 logo | Shown to customers on the authorization screen; Partner Support asks for both (§4) | | **Environment** | `testing` (your own sandbox only) or `production` (live for mutual customers) — you name this in the request | | **Redirect URI(s)** | Every callback host, final, up front — changing them later is another email (§5) | | **Scope set** | The exact `harvest::` strings (§6) | | **A named mutual customer** | The partner program requires at least one; be ready to name them (§3) | | **Static egress IPs** | Only if a customer turns on IP restriction for their credential (§7) | | **Greenhouse tenant for testing** | A sandbox needs the right subscription tier (§3) | ## Quick Start 1. Establish which **API surface** and which **credential model** this actually needs (§1). Most wrong turns start here. 2. Confirm whether you already have credentials worth reusing — re-issuing orphans every existing connection (§2). 3. If partner OAuth: apply to the partner program and get the agreement signed. This gates everything else (§3). 4. Request credentials — by email for partner OAuth, or in the customer's Dev Center for per-customer keys (§4). 5. Register **every** callback host in that same request; you cannot self-serve them later (§5). 6. Pin down scopes *and* the authorizing user's Greenhouse permission level — both are required, neither substitutes (§6). 7. Check rate limits, sandbox access and IP restrictions before you promise a launch date (§7). 8. Verify with a real authorize → callback → refresh → refresh-again round trip (§8). 9. Hand the secret to a human, never to source control (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Harvest v1 and v2 were documented as "deprecated and unavailable after August 31, 2026."** That date is **in the past as of this writing**, yet Greenhouse's own support and developer docs still state it in the future tense and still document a v1/v2 → v3 transition path. **Do not assume either way.** Before planning work, test a v1/v2 Basic auth call against a real tenant and check the current text of the Harvest API overview article. If a customer's legacy API key still works, treat that as borrowed time, not as a supported path. - **Harvest v3 is OAuth-only.** Greenhouse states plainly that once v1/v2 endpoints are gone, "OAuth will be the only supported authentication method for the Harvest API." Two grants, two audiences: **Authorization Code** for partners, **Client Credentials** for a customer's own integration. - **A transition shim exists** (or existed): a v1/v2 API key can be exchanged at `https://harvest.greenhouse.io/auth/token` for a JWT usable as a Bearer token against `/v3`. Note the host — it is **not** the OAuth token endpoint (`https://auth.greenhouse.io/token`). Confirm this shim still answers before relying on it. - **Partner OAuth credentials are issued by email**, by `partner-support@greenhouse.io`, after a signed partnership agreement. There is no portal, no console, and no way to self-register a client ID. - **Scope changes are also an email.** Partners cannot edit their own scopes. - **Customers self-serve their own credentials** in Configure → Dev Center → API Credential Management, choosing **Harvest** (v1/v2 key) or **Harvest v3 (OAuth)** (client ID + secret). - The partner OAuth guide was last updated **2026-09-18** — two days before this verification, so it is current, and also a sign this area is actively changing. If the Dev Center or the partner docs do not look like this, stop and report what you actually see. ## 1. Which API surface, and which credential A reader arriving here is likely holding the wrong credential. Greenhouse runs several unrelated APIs and none of their credentials interchange: | Surface | Credential | Host | Who gets it | | --- | --- | --- | --- | | **Harvest v3** (core ATS read/write) | OAuth 2.0 — Authorization Code (partners) or Client Credentials (customers) | `harvest.greenhouse.io`, tokens at `auth.greenhouse.io` | Partners via agreement; customers self-serve | | **Harvest v1/v2** (legacy) | API key, HTTP Basic, key as username with an **empty password** | `harvest.greenhouse.io` | Customer self-serve — but see Platform state | | **Job Board** | **Board token** (public, in the job board URL) for all GET; a separate secret Job Board API key, Base64-encoded, only for the application-submission POST | `boards-api.greenhouse.io/v1/boards/` | Anyone; the token is not a secret | | **Candidate Ingestion** (sourcing partners) | OAuth 2.0 *or* Basic with a Partner API Key plus an `On-Behalf-Of` email header | `api.greenhouse.io/v1/partner/`, OAuth at `api.greenhouse.io/oauth/authorize` and `/oauth/token` | Sourcing partners only, credentials issued by Greenhouse | | **Assessment** (test providers) | Partner-issued API key, HTTP Basic; **Greenhouse calls you**, not the reverse; key must be under 171 characters | The partner's own endpoints | Assessment partners; the customer passes the key to their Greenhouse account manager | | **Onboarding** | HTTP Basic (username + password); requires a Greenhouse Onboarding or Welcome subscription | separate product | Customers with that subscription; docs sit behind a login | Two traps that follow directly from this table: - **The Job Board token is not an API key.** It is public, it appears in the customer's job board URL, and it identifies the organization. Asking a customer for "your Greenhouse token" gets you one of the two, unpredictably. Ask for the board token and the Harvest credential by their full names, separately. - **Ingestion and Assessment are separate integrations, not modes of Harvest.** A platform that supports more than one surface ships them as **separate connectors with separate credentials**. Do not try to make one Greenhouse app cover candidate ingestion and Harvest; the credentials, hosts and partner tracks are different. > **Product fact — dated.** As of **2026-09-20**, Unified.to's Greenhouse connector authenticates in two ways against > the Harvest API at `https://harvest.greenhouse.io/`: **OAuth 2.0** using the authorization endpoint > `https://auth.greenhouse.io/authorize` and token endpoint `https://auth.greenhouse.io/token`, with client > credentials carried as HTTP Basic on the token call and a `bearer {token}` API header; and an **API-key** mode > using the legacy Basic scheme (key as username, empty password). Its OAuth scope set is the granular > `harvest::` strings, grouped per unified object — for example candidates, applications, jobs, > job posts, interviews, scorecards, notes, users, offices, departments, attachments and custom fields, each with > `:list` for reads and `:create` / `:update` / `:destroy` for writes. Several write scopes are recorded as **not > obtainable** from Greenhouse (scorecard writes in particular, and destroy on jobs, users and offices). The > connector also asks each customer for a **Job Board token** as a second, separate input alongside the Harvest > credential, and it treats access tokens as expiring in **one hour**. Separate connectors exist for the **Assessment** > API and the **Ingestion** API, each with its own credential. **Confirm all of this with the connector's owner > before acting on it** — connector configuration changes independently of this skill. ## 2. Reuse the existing credentials, or request new ones Re-issued partner credentials mean a **new client ID, and every existing customer authorization is bound to the old one** — every customer re-authorizes. Reuse what exists for: adding a redirect URI, changing scopes, rotating a compromised secret, or diagnosing a failure. All four are handled on the existing client (§4, §5, §6). Request a **new** client only when the user explicitly wants one: a separate product, a replacement for a compromised client, or a deliberate move from Ingestion to Harvest. Say which path you are taking before you start. For **per-customer** credentials the calculus is different and much gentler: each customer's pair is independent, so rotating one affects exactly one tenant. Rotation there is a customer-side action with a built-in grace period (§4). ## 3. Accounts: partner program, and a tenant to test against **The partner program** (`https://www.greenhouse.com/integration-partner`) is the gate for Authorization Code credentials. What the run needs to know: - **At least one mutual customer is required.** Not "helpful" — required. A pre-revenue product with no Greenhouse customer has no route in. An active website with a privacy policy is also required. - **Applications are reviewed on the first Monday of each month** (first Tuesday if that is a holiday), with a response within about 7 business days of the review. Plan in months, not days. Urgent cases with a named mutual customer can request an out-of-cycle review via `partneronboarding@greenhouse.io`. - Onboarding brings Partner Portal and sandbox access, then a build-and-submit-for-review step, then directory listing and co-marketing. Not every application is accepted. - The agreement itself is a **business and legal decision**. Do not fill in revenue, volume, security or compliance claims on the user's behalf — collect the questions and hand them back. **A tenant to test against.** Greenhouse sandboxes are gated by subscription tier (Greenhouse's support article currently names the **Pro** tier; other internal notes describe it as an Expert-tier benefit — verify against the customer's actual plan rather than either claim). A sandbox is created **empty**, not cloned from production, so you add your own test data. Critically: **API keys, board tokens and job board URLs are not interchangeable between sandbox and production.** Generate a separate set for each, and never assume a working sandbox credential says anything about production. Anything requiring a human — signing the agreement, email verification, two-factor enrolment, requesting a sandbox from an account manager — hand back rather than looping. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the redirect URIs from §5 and the scope strings from §6 — or a ready-to-send email body for §4, then continue once they report back with the client ID. ## 4. Obtaining the credentials ### Path A — Partner OAuth (Authorization Code), for a multi-tenant connector There is no form. You email `partner-support@greenhouse.io`, **after** the partnership agreement is signed, with exactly five things: 1. **Integration name** — the product name customers will recognize. 2. **Environment** — `testing` (your own sandbox only) or `production` (live for mutual customers). These are separate credential sets; ask for both if you need both. 3. **Required scopes** — the exact `harvest::` strings (§6). Ask for what you call today, not what you might call next year; adding scopes later forces every customer to re-authorize (§6). 4. **Redirect URI(s)** — every one, final (§5). 5. **A 128×128 logo file** — shown to customers on the authorization screen. Greenhouse replies with a **Client ID** and **Client Secret**. Greenhouse's guide also asks you to confirm the final working `redirect_uri` back to Partner Support once your callback endpoint is live — a step that is easy to skip and leaves the registration half-configured. **The flow itself**, once you hold credentials: - Authorize: `GET https://auth.greenhouse.io/authorize` with `response_type=code`, `client_id`, `redirect_uri`, `scope` (space-separated), and `state` (documented as optional, but treat it as mandatory — it is your only CSRF defence here). **PKCE is not part of the documented flow.** - Exchange: `POST https://auth.greenhouse.io/token`. Client authentication is **HTTP Basic** with `client_id:client_secret` Base64-encoded. Greenhouse's own examples pass `grant_type=authorization_code` and `code` as **query parameters on the POST with an empty body** — not as a form-encoded body. A client that only knows how to post a form body may fail here; verify which shape your client sends before blaming the credentials. - Response: `{ token_type, access_token, refresh_token, expires_at }`. Note **`expires_at`** — an ISO-8601 timestamp, not the `expires_in` integer most OAuth clients expect. The client-credentials flow returns `expires_in` instead. A client that assumes one shape will mis-compute expiry against the other. - Refresh: same endpoint, `grant_type=refresh_token` and `refresh_token`, again as query parameters with Basic auth. **Lifetimes, all short, all load-bearing:** | Token | TTL | What breaks | | --- | --- | --- | | Authorization code | **1 minute** | Any human step, queue hop or retry between callback and exchange kills it | | Access token | **1 hour** | Routine; refresh proactively | | Refresh token | **14 days**, and **rotated on every refresh** | An idle connection dies at day 15 and the customer must re-authorize from scratch | The 14-day idle death is the single most common way a Greenhouse connection silently stops working. A connector that only refreshes lazily — when a customer happens to trigger a sync — will lose every low-traffic tenant. Refresh on a schedule. And **store the new refresh token from every refresh response**; a client that reuses the original fails on the second refresh. ### Path B — Customer-created Harvest v3 (OAuth) credentials, client credentials grant This is what a customer does in their own account, and the path available today without a partnership: 1. **Configure → Dev Center → API Credential Management → Create new API credentials.** 2. Choose API type **Harvest v3 (OAuth)**. 3. Save, then configure the scopes the integration needs (§6). 4. Greenhouse shows a **Client ID** and a **Client Secret**. **The secret is shown once**; the client ID stays visible. 5. Token: `POST https://auth.greenhouse.io/token`, HTTP Basic with `client_id:client_secret`, body `application/x-www-form-urlencoded` containing `grant_type=client_credentials` and optionally `sub=`. 6. Call `https://harvest.greenhouse.io/v3/...` with `Authorization: Bearer `. There is no refresh token — request a new access token when the old one expires or 401s. Two details that decide whether this survives a year: - **The `sub` parameter picks the acting user.** With no `sub`, requests act as the Integration Service User (ISU) auto-created with the credential. With `sub=`, they act as that user — and inherit that user's Greenhouse permissions, which is exactly how you hit the Site Admin rule in §6. - **Create the credential from an ISU account, not a person's account.** Greenhouse says so explicitly: a credential tied to an individual dies when that individual is deactivated. This is the most common self-inflicted outage here. **Rotation** is built in: the credential's client-secret tab generates a new secret and the old one remains usable for up to about a week (Greenhouse also documents rotation as immediately invalidating the previous secret in one place and offering a grace window in another — verify in the UI before you cut over, and keep a rollback). Revoking a credential cuts access immediately for everyone using it; revoked credentials can be re-enabled. ### Path C — Legacy Harvest v1/v2 API key Same Dev Center path, API type **Harvest**. The key is shown **once**. Used as HTTP Basic with the key as the username and an **empty password** (so the header is Base64 of `KEY:` — the trailing colon is not optional, and omitting it is a classic silent 401). Read the Platform state section before building anything new on this. Creating or managing any of these requires a Greenhouse user at **Basic and above who can manage ALL organization's API credentials**. A customer contact without that permission will see no Dev Center option and will report that the feature "doesn't exist." ## 5. Redirect URLs For **partner OAuth only** — the client-credentials path has no redirect. Register **every** callback host in your initial request to Partner Support. You cannot add or edit them yourself; each change is another email and another wait. For Unified.to these are one per data center — confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` The `redirect_uri` you send must **match a registered value exactly**. A mismatch fails before the user ever sees a consent screen, with an HTTP 400 and `{"error": "invalid_request", "error_description": "'redirect_uri=...' is not configured for 'client_id=...'"}` — shown to your *customer*, as raw JSON, in their browser. Register the whole set on day one; discovering a missing region later costs a review cycle, not a config change. ## 6. Scopes — and the permission rule that overrides them Greenhouse scopes are granular and space-separated in the authorize URL, shaped `harvest::`: `harvest:candidates:list`, `harvest:applications:create`, `harvest:jobs:update`, `harvest:attachments:destroy`, `harvest:custom_fields:list`, and so on. Each endpoint in the Harvest v3 reference names the scope it requires; take the strings from there rather than guessing the pluralization. **Ask for the narrow set.** Not because Greenhouse polices it heavily, but because of the change mechanics: - **Adding a scope requires every existing customer to re-authorize.** Existing tokens stay valid but never gain the new permission. This is a customer-visible migration, not a config tweak — the reason to get the scope list right in your first request to Partner Support. - **Removing a scope takes effect immediately and globally**, enforced by a real-time check on every request, even for unexpired access tokens. Refresh tokens keep working and simply issue reduced tokens. - **Partners cannot change their own scopes** — it is an email to `partner-support@greenhouse.io` every time. - Some scopes are **not available at all.** Greenhouse partner support has told integrators that scorecard *write* scopes, and destroy on jobs, users and offices, cannot be granted. If a required capability comes back refused, that is a product answer, not a registration mistake — report it rather than retrying. **The rule that outranks all of the above: scopes are necessary but not sufficient.** The authorizing user's Greenhouse permissions cap what any token can do, and Greenhouse states that **all list endpoints require a Site Admin authorizing user.** A connector that gets perfect scopes and a non-admin authorizer will 403 on effectively every read. Some endpoints go further and require a specific job-level permission on top. For **partner** connections the customer chooses at consent time between a **service-account authorization** (Site Admins only; creates an organization-level service user that owns the connection and outlives any individual) and a **user-linked authorization** (tied to that person; actions appear as theirs, and it dies with their account). For a platform integration, **steer customers to the service account** — and note that only a Site Admin can pick it. Customers manage and revoke these under **Configure → Dev Center → Connected Integrations**, which is also where you send someone whose connection "just stopped." ## 7. Rate limits, IP allowlisting, sandbox, listing - **Rate limits** are a **fixed 30-second window**, applied **separately for custom and partner integrations**, with token requests metered on their own **60-second** window. Greenhouse publishes no committed number — the documented example header shows `X-RateLimit-Limit: 75`, which is an illustration, not a guarantee. Read `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` from **every** response and throttle from those. On `429`, honour `Retry-After` rather than retrying immediately. Do not hardcode a number from any blog post. - **IP allowlisting is customer-controlled.** When creating credentials a customer may tick "Restrict access to specific IP addresses" and enter IPs or CIDR ranges; only those origins are then accepted. A security-conscious customer can therefore break your connector without telling you. If your egress is autoscaled or serverless with no stable addresses, surface this before launch — it is an infrastructure decision, not a support ticket. - **Sandbox**: subscription-gated, empty on creation, with credentials and board tokens entirely separate from production (§3). - **Listing**: directory inclusion, co-marketing and referrals come with the partner program, after an integration review. Greenhouse notes that not every marketplace integration uses Harvest v3, so the directory is not evidence of what any given partner is built on. ## 8. Verify end-to-end Do not stop at "the token came back." Test what a customer's tenant actually does: 1. Authorize through your platform's real connect flow against a **second** organization — a sandbox or a mutual customer's tenant, not the one that built the credential. 2. Exchange the code **immediately**. If anything in your pipeline can delay it past 60 seconds, that is a bug now. 3. Confirm the token response shape your client parses (`expires_at` vs `expires_in`) matches the grant you used. 4. Make one real read — a list endpoint is the right choice precisely because it exercises the Site Admin rule. 5. **Refresh, then refresh again using the token returned by the first refresh.** This is what catches a client that ignores rotation, and it is a two-minute test for a failure that otherwise appears a day later. 6. Repeat step 4 as a **non-admin** user if any customer will connect as one. Expect a 403, and design for it. 7. Check `X-RateLimit-Remaining` on a real sync, not a single call. | Symptom | Cause | | --- | --- | | HTTP 400, `unauthorized_client`, "not allowed to perform the authorization code grant" | Client ID exists but is not configured for this flow — Partner Support must fix it (§4) | | HTTP 400, `redirect_uri ... is not configured` | Callback not registered exactly; shown to the customer as raw JSON (§5) | | `?error=invalid_scope` on the redirect back | You requested a scope your client is not permitted to use (§6) | | "Authorization code expired at ..." | More than 60 seconds between callback and exchange (§4) | | "Authorization code has already been exchanged" | Double-submitted callback, or a retry after a successful exchange | | 401 "not authorized to access 1 or more of the requested scopes" at exchange | The code carries scopes the client cannot request (§6) | | **403 on every list endpoint, 200 on singletons** | Authorizing user is not a Site Admin (§6) — not a scope problem | | 403 for one customer only | That customer's user permissions, or their IP restriction (§7) | | Connection dies after a quiet fortnight | 14-day refresh-token TTL; refresh on a schedule (§4) | | Second refresh fails, first succeeded | Client is not storing the rotated refresh token (§4) | | Worked yesterday, 401 today, nothing changed | Customer rotated or revoked the credential, or the ISU/person the credential was tied to was deactivated (§4) | | 401 on a legacy key that "looks right" | Missing trailing colon in the Basic header (`KEY:`) (§4 Path C) | | Sandbox works, production 401s | Credentials and board tokens are not interchangeable across environments (§3) | | `429` under normal load | 30-second fixed window; read the headers rather than guessing a number (§7) | ## 9. Hand off — never commit the key - **Do not** write a client secret, API key, partner key or board API key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the human running this, for their secret store or console. The Job Board *token* is public and may be recorded; the Job Board *API key* is not — keep them apart. - If a code change is needed (a callback host, a scope string, a token-endpoint shape), keep it secret-free and say plainly what the human must set out of band. - Report a secret once so it can be pasted into the secret store, say clearly that it is now in the transcript and can be rotated, then move on. - Close with: which API surface and grant you ended up on; the client ID; where the secret was delivered; the authorize and token endpoints; the exact scope strings requested and any Greenhouse refused; the environment (testing vs production); the Site Admin / service-account requirement to pass to customers; the refresh cadence the 14-day TTL demands; and whatever is still waiting on Greenhouse. ## Stop and ask Hand back to a human rather than guessing when: **there is no signed partnership agreement** and the ask is Authorization Code credentials (say so in the first reply — do not start a registration that cannot complete); the partner application needs a named mutual customer, revenue, security or compliance claims; someone proposes re-issuing partner credentials on a live integration (that re-authorizes every customer); a scope comes back refused by Greenhouse; IP allowlisting requires decisions about static egress; a customer cannot find Dev Center (they lack the credential-management permission); the v1/v2 sunset state on a live tenant contradicts the Platform state section; or anything about Ingestion, Assessment or Onboarding is being treated as interchangeable with Harvest. ## References - Greenhouse API overview (all surfaces) — https://support.greenhouse.io/hc/en-us/articles/10568627186203-Greenhouse-API-overview - Harvest API overview and v1/v2 deprecation — https://support.greenhouse.io/hc/en-us/articles/360029266032-Harvest-API-overview - Harvest v3 authentication (both grants, transition shim) — https://harvestdocs.greenhouse.io/docs/authentication - Partner OAuth 2.0 Authorization Code guide — https://harvestdocs.greenhouse.io/docs/harvest-partner-oauth - Generate token reference — https://harvestdocs.greenhouse.io/reference/generate-token - Handling scope changes — https://harvestdocs.greenhouse.io/docs/handling-scope-changes - Rate limiting — https://harvestdocs.greenhouse.io/docs/api-rate-limiting - Create Harvest API credentials for an integration — https://support.greenhouse.io/hc/en-us/articles/5888163769883-Create-Harvest-API-credentials-for-an-integration - Manage Harvest API credentials permissions — https://support.greenhouse.io/hc/en-us/articles/115000521723-Manage-Harvest-API-credentials-permissions - Connect to Harvest v3 partner integrations (customer side) — https://support.greenhouse.io/hc/en-us/articles/41437121183899-Connect-to-Harvest-v3-partner-integrations - Harvest v3 API migration overview — https://learn.greenhouse.io/harvest-v3-api-migration-overview - READ endpoint migration guide — https://harvestdocs.greenhouse.io/docs/step-by-step-migration-instructions - Harvest v3 API reference — https://harvestdocs.greenhouse.io/reference/get_v3-candidates - Job Board API — https://docs.greenhouse.io/job-board.html - Job board URL and board token — https://support.greenhouse.io/hc/en-us/articles/360020776251-Job-board-URL-for-Greenhouse-hosted-job-board - Candidate Ingestion API — https://docs.greenhouse.io/candidate-ingestion.html - Assessment API — https://docs.greenhouse.io/assessment.html - Use a sandbox — https://support.greenhouse.io/hc/en-us/articles/17053185557787-Use-a-sandbox - Greenhouse integration partner program — https://www.greenhouse.com/integration-partner - Greenhouse APIs landing page — https://www.greenhouse.com/api ## hibob-oauth-app URL: https://docs.unified.to/skills/hibob-oauth-app/SKILL.md # HiBob (Bob) API Credentials **Read this first: HiBob has two live credential models, and the one a multi-tenant platform is most likely allowed to use is not OAuth.** 1. **Service user ID + token, over HTTP Basic auth.** Created by a Bob admin **inside each customer's own Bob account**, in the Service Users section. Needs no relationship with HiBob at all. HiBob's own wording: service users "aren't tied to actual employees and can't log in to Bob. They exist only to authenticate API requests using their ID and token." This is a **per-customer credential** — there is no tenant discovery, no consent screen and no refresh. For a platform connecting many customers' Bob accounts, the deliverable per customer is an **admin runbook** (§3), not a registration. 2. **OAuth 2.0 authorization code, with a client ID and secret issued by HiBob.** HiBob states it plainly and repeatedly: "OAuth 2.0 app integrations are available for approved HiBob Marketplace and technology partners only." There is no self-serve registration form. The Developer Portal at `developers.hibob.com` is reached by **invite** — HiBob's own quick start says to "Create a Developer Portal account at https://developers.hibob.com using the received invite", after your team signs the T&Cs. If the ask is "get HiBob OAuth credentials this week" and no partnership exists, the honest answer is that it is not available, and the run should stop at §5. A third model, **API Access Tokens**, is dead. HiBob removed the ability to create them on 2024-06-15 and stopped supporting them on 2024-10-31. Any runbook that tells a customer to open **System Settings > REST API** and generate a token is describing a tile that no longer exists. Note the tension, and say it out loud rather than resolving it silently: HiBob's API reference tells "Marketplace partners **and third-party vendors**: Use OAuth for partner integrations" — while OAuth is gated on partnership. HiBob's own migration guide, however, explicitly contemplates a customer's **vendor-built custom integration** running on a service user, and tells customers to ask that vendor to move to "the Basic authorization method". So the service-user route is documented and legitimate for a non-partner vendor; the OAuth route is where you end up if you want a Marketplace listing and a consent flow instead of an admin runbook. Three things cost real time whichever model you land on. **A service user starts with no permissions at all** and a credential with the wrong permission group returns `200 OK` with fields quietly missing, or an empty response — never an auth error (§4); this is the single most misdiagnosed Bob problem. **`POST /people/search` has no pagination** and is capped at 50 requests per minute, so a naive full-directory sync is both a huge payload and a rate-limit problem (§10). And **repeated `401`/`403` responses get your egress IP blocked by HiBob's WAF for five minutes** — on a multi-tenant platform, one customer's bad credential can take out every other customer's sync (§10). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Which credential model** | Per-customer service users, partner OAuth, or both (§1) | | **Partner status** | Is there an accepted HiBob Marketplace/technology partnership and Developer Portal access? (§5) | | **New app or edit to an existing one** | A new client ID orphans every existing OAuth installation (§2) | | **App name, icon (160×160, 1:1), description, "Built by"** | Shown in the Bob Marketplace listing (§6) | | **Installation mode** | "Install from Bob Marketplace" or "Install from your landing page" — this decides whether you get `state` back (§6) | | **Redirect URI(s)** | Every callback host, exact strings; must work for logged-out users (§7) | | **Scope set** | The exact HiBob scope strings the connector needs (§8) | | **Which Bob admin** at each customer | Only a Bob admin can create a service user and its permission group (§3) | | **Which employee fields the integration reads** | Drives the permission group, and the sensitive-field View+Edit rule (§4) | | **Does the customer use Bob Sandbox?** | A separate paid environment on a different API host (§10) | ## Quick Start 1. Establish which credential model is actually reachable (§1). Most wrong turns start here. 2. If per-customer service users: decide reuse vs. a fresh credential per customer (§2), then run the **admin runbook** with each customer's Bob admin (§3). 3. Pin the permission group to the exact fields you read, including the sensitive-field rule (§4). 4. If partner OAuth: apply to the partner program — this gates the Developer Portal, the app, and everything after it (§5). 5. Create the app in the Developer Portal: basic info, installation mode, scopes, redirect URI, webhooks (§6). 6. Register **every** callback host, exactly (§7). 7. Select scopes from HiBob's published mapping, minimum set only (§8). 8. Capture development credentials, then production credentials at go-live; know what rotation breaks (§9). 9. Check rate limits, the WAF block and sandbox before promising a sync cadence (§10). 10. Verify end to end against a real tenant (§11) and hand off without committing a secret (§12). ## Platform state (verified 2026-09-20 — re-verify before trusting) If the Developer Portal, Bob's Service Users screen or the docs do not look like this, stop and report what you actually see rather than clicking on. - **OAuth 2.0 is partner-gated, not self-serve.** "OAuth 2.0 app integrations are available for approved HiBob Marketplace and technology partners only." Customer-built integrations are told to use service users. There is no public app-registration form. - **The Developer Portal is invite-based.** HiBob's quick start: open a Bob **partner** account at `app.hibob.com`, then create a Developer Portal account at `developers.hibob.com` "using the received invite". Your team must first sign the T&Cs. Technical contact `partnersupport@hibob.io`; business contact `partnerships@hibob.io` (HiBob's API Terms give `partnerships@hibob.com` for the "become a partner" route). - **The partner application is a form on HiBob's site.** `https://www.hibob.com/partner/` lists four programs; the Tech Marketplace one ("Have a key workplace tool… Apply to join our Marketplace") links to a hosted application form. **HiBob publishes no tier table, no mutual-customer bar, no fee and no review SLA** for it — do not invent one, and plan in months. - **Marketplace apps must use OAuth only.** The Developer Portal quick start: "Use only OAuth 2.0. Other authentication methods are not allowed for Marketplace apps." - **API Access Tokens are gone** — creation removed 2024-06-15, support ended 2024-10-31. Service users replaced them, and the stated reason is that the old tokens inherited a specific employee's permissions. - **Token lifetimes are short and the docs contradict themselves on one of them.** Access token: **5 minutes**. Refresh token: the "Token management best practices" section says **30 days**; the token-response table on the same page says the refresh token "will expire if it isn't used in 60 days". **Treat 30 days as the number**, refresh well inside it, and do not build on the 60. - **Refresh tokens rotate.** HiBob's FAQ: "Each successful call to the refresh endpoint returns a new `refresh_token`, and the previous one stops working once you've used it." - **`state` is not returned in "Install from Bob Marketplace" mode.** HiBob: "in that mode Bob starts the OAuth flow directly — you never send the initial authorization request, so there is nothing to attach `state` to and none is returned on your callback." `state` exists only in "Install from your landing page" mode. Any connector whose callback routes on `state` must register the landing-page mode (§6). - **There is no uninstall webhook.** "HiBob does not send a separate uninstall webhook or notification." You detect an uninstall when a refresh returns `401`. - **Scopes and audience are locked at install.** "Updating app permissions (re-consent) is not supported. Uninstall and reinstall is required to change scopes or people data access." New custom fields inside an already-approved scope are included automatically. - **Field-level permissions have rolled out to nearly all customers.** The permission section formerly called **People** is now **People's data > People's fields**, and access can be granted per field rather than per category. Sensitive fields (SSN, full date of birth, age) still require **both View and Edit** to be returned by the API. - **Bob's MCP server now uses OAuth on the signed-in Bob user's permissions**, and its documentation moved to the Bob Help Center. That is a separate end-user product surface, **not** a credential route for a server-to-server connector. Do not present it as one. - **The Help Center (`help.hibob.com`) blocks automated fetching** (403 to scripted requests on 2026-09-20). The customer-facing articles — *Manage service users*, *Create a service user permission group*, *View API audit logs* — are linked from HiBob's developer docs and are readable in a browser. Cite them by title; a human must open them. ## 1. Which credential, and who issues it | Credential | Who issues it | How it is sent | Reach | | --- | --- | --- | --- | | **Service user ID + token** | A Bob admin, inside the customer's own Bob account | HTTP Basic: `Authorization: Basic base64(:)` against `https://api.hibob.com/v1/` | Exactly one Bob company. Data limited by the permission group's **Features**, **People's fields** and **Access data for** settings (§4) | | **OAuth 2.0 access token** | HiBob, to approved partners, as `client_id` + `client_secret`; the customer then installs the app | `Authorization: Bearer `, same API host | One Bob company per installation, keyed by `companyId`. Limited by approved **scopes** *and* the **audience** the customer picked at install | | **API Access Token** | — | — | **Dead.** Creation removed 2024-06-15, support ended 2024-10-31 | Two consequences worth stating before anyone starts building: - **Neither credential is multi-tenant by itself.** A service user reaches one company because it lives in that company. An OAuth installation reaches one company because HiBob issues one token pair per install, and puts `companyId` in the access token as the tenant key. The difference is who does the work: with service users the customer's admin does it and emails you two strings; with OAuth the customer clicks Install and your callback does it. - **Only OAuth gets you a Marketplace listing.** If the goal is "appear in Bob's Marketplace so customers can install us in two clicks", that is the partner program (§5) and nothing else will do. ## 2. Reuse or create new **Partner OAuth.** A new app means a **new client ID**, and every existing installation is bound to the old one — every customer uninstalls and reinstalls. Reuse the existing app for: adding a redirect URI, changing scopes (note that a scope change still forces every *customer* to reinstall — §Platform state), rotating a compromised secret, or diagnosing a failure. Register a new app only for a genuinely separate product or a deliberate migration. Say which path you are taking before touching the portal. **Service users.** Much gentler: each customer's credential is independent, so regenerating one affects exactly one tenant. Prefer **one dedicated service user per integration per customer**, with its own permission group — HiBob recommends exactly this ("create a dedicated permissions group for each service user"). Never ask a customer to reuse the service user their payroll vendor already has; you inherit its permissions and its blast radius. ## 3. The customer runbook — creating a service user This is the deliverable for each customer on the service-user model. Give it to their **Bob admin**; you cannot do any of it from outside. HiBob's own five steps, with the parts that actually go wrong called out. 1. **Create the service user.** In Bob, go to the **Service Users** configuration page — HiBob's docs say only that "Service Users are created and managed in the Service Users section in Bob" and link the Help Center article *Manage service users* for the click path; a path of Settings → Integrations → Automation → Service Users is reported from the field but is **not** confirmed on a HiBob page, so have the admin confirm what they see. Create a new service user and **copy the ID and the token immediately** — HiBob: "You can only copy the token when it's generated. If lost, generate a new one and update your code accordingly." Give it a description naming your integration; the Service Users table shows creator, last session, last token refresh and assigned permission groups, and exports to CSV/XLSX. 2. **Create a dedicated permission group** and add the service user to it. **By default, service users have no access permissions.** A brand-new group has none either. A credential created and handed over at this point authenticates perfectly and returns nothing useful. 3. **Grant permissions**, in three separate places (§4 explains the traps): - **Features** — switches on whole areas of Bob (for example **Features > Reports**, or **People > Add new people to the company** if you create employees). - **People's data > People's fields** — **View** / **Edit** per category *or* per field, and **View history** on the category row only. - **People's data > Access data for** — *whose* records the service user may see. Default is all **active** employees, via a **Lifecycle status equals Employed** condition. To read terminated or former employees the admin must **Select people by condition** and remove that condition. 4. **Test** from HiBob's API reference "Try It!" pane: pick the Base URL (Production or Sandbox), choose **Basic** auth, put the **service user's ID in the username field and the token in the password field**. HiBob's own note on that page is the tell: "If you receive an **empty response** it likely means the permissions do not allow you to access the data you are trying to fetch." 5. **Hand over the ID and the token.** They are equivalent to a password — see §12 for how not to leak them. **What to tell the customer about lifetime.** HiBob documents **no expiry** for a service user token: it works until someone refreshes it or deletes the service user. Deleting the service user breaks every integration using it. The Service Users table records a "last token refresh" date, so rotation is a deliberate admin action, not a clock. Ask for a named owner at the customer, because an admin quietly deleting an unlabelled service user is a common cause of a connection that "suddenly stopped". **A service user is not an employee and consumes no licence** — that is HiBob's stated reason for the model, and it is the right answer when a customer objects to "creating another user". ## 4. Permissions: the section that prevents the misdiagnosis **Bob answers with `200 OK` and simply leaves out what the credential may not see.** HiBob's People read contract names this **silent omission** and tabulates it: | Situation | HTTP | Result | | --- | --- | --- | | Field requested, service user lacks category or field permission | `200` | Field **absent** | | Field requested, unknown or invalid field ID | `200` | Field **absent** | | `fields[]` empty or omitted | `200` | **Default field set** — categories `root`, `about`, `employment`, `work`, still subject to permissions | | Filter `fieldPath` not `root.id`/`root.email`, wrong operator, empty values | `400` | Error — filters fail loudly, fields do not | So: **fields fail silently, filters fail loudly.** After every `200`, compare what you asked for against what came back. HiBob's own checklist: verify the field ID in metadata → verify its `categoryId` → verify **View** on that category or field under **People's fields** → verify **Access data for** covers that employee. Five rules that follow, in the order they bite: 1. **The default field set is small.** `POST /people/search` with no `fields[]` returns only the `root`, `about`, `employment` and `work` categories. To read basic employee data at all, the permission group needs **View** on those four categories (or the specific fields inside them). Anything else — personal contact details, payroll, custom tables — is a separate grant. HiBob's Help Center article on this is *Permissions for Default Employee Fields in People Search API*. 2. **Field-level, not category-level.** Permissions now live under **People's data > People's fields**; categories are containers and act as defaults, and a single field can be opened without opening its category. Ask customers for the **exact fields** you read, not whole categories — HiBob asks partners to write setup instructions that way. 3. **Sensitive fields need View *and* Edit.** SSN, full date of birth, age and similar are enforced by hard-coded logic: "the service user must have both **View and Edit** on that field" for the value to come back over the API. A read-only integration still has to ask for Edit on those fields, which is a conversation to have up front rather than in an incident. During migration, a service user that had View but not Edit on them lost access entirely. 4. **History is a separate permission.** **View** returns the current effective row; **View history** (available on the **category** row only, never per field) is what unlocks the historical table endpoints — work, employment, salaries, lifecycle. 5. **Employee-table endpoints tell you what they hid.** When columns are filtered, the response carries an `X-Has-Restricted-Columns: true` header and a `restricted_columns` object splitting them into `no_view_permission` and `no_view_history_permission`. Read it — it is the one place Bob distinguishes "no value" from "not allowed". The people search endpoints do **not** give you this; there you are on your own with the checklist above. **Field IDs are stable identifiers, not paths.** `work.department` looks like `category.field` but an admin can move that field to another category and the ID will not change — only `categoryId` in metadata does. Always resolve the category from `GET /company/people/fields`, never from the ID string, before telling a customer which box to tick. **On OAuth the same problem arrives wearing a different hat.** Scopes are only the first gate; the customer also picks an **audience** at install — All company, select by condition, or select by name. HiBob's guidance to partners is blunt: "A request to list employees may return a subset of the total number of employees. Never assume a successful API response contains the full organization," and an employee outside the audience returns `404` (or `403`) even with valid scopes. The audience cannot be changed without uninstall and reinstall, so tell customers in your install instructions which setting your product needs. ## 5. Becoming a partner (the only route to OAuth) `https://www.hibob.com/partner/` carries four programs; the one that leads to OAuth is **Tech Marketplace** ("Have a key workplace tool that helps organizations operate more efficiently and scale in snap? Apply to join our Marketplace"), which links to a hosted application form. HiBob's API Terms add: "If you would like to become an official partner of HiBob, please contact partnerships@hibob.com, or submit a 'Become a partner' form on our website." What the run needs to know: - **HiBob publishes no eligibility bar, no fee, no timeline.** No mutual-customer minimum, no security-questionnaire list, no SLA appears on the public pages. Do not invent any of it, and do not fill in revenue, volume, security or compliance claims on the user's behalf — collect the questions and hand them back (§Stop and ask). - **Acceptance is what unlocks everything else**: a Bob **partner company** at `app.hibob.com`, a Developer Portal invite, the T&Cs, and with them the app, the scopes, the test-install flow and eventually the listing. - **The API Terms are worth reading before signing anything.** They license API use "in your test environment for internal development and testing" and state you "shall have a 'read only' access" absent express permission, reserve the right to set and change rate limits without notice, and require notification of a security incident within 48 hours. Anything your product does that writes to Bob is a point to confirm, not to assume. Anything a human must do — signing T&Cs, accepting the invite, opening the partner Bob company, submitting the certification video — hand back rather than looping. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact ordered click path with the literal values to paste — the redirect URIs from §7 and the scope strings from §8 — or a ready-to-send application body for §5, and continue once they report back with the client ID. ## 6. Create the app in the Developer Portal Sign in at `developers.hibob.com`, then **My apps → Create app**. What the form asks, and what carries more weight than it looks: - **App icon** (160×160 px preferred, 1:1), **App name** as it appears in the Marketplace, **App description** ("1–2 sentences"), **Built by**. - **Installing your app** — the installation mode, and **this is the decision to get right the first time**: | Mode | What happens when the customer clicks Install in Bob Marketplace | `state` | | --- | --- | --- | | **Install from Bob Marketplace** | The OAuth consent flow starts in Bob; Bob calls your redirect URI with a code | **Not returned** — you never sent the authorize request | | **Install from your landing page** | Bob sends the customer to your HTTPS landing page; after your onboarding you start OAuth from your **App Installation URL** | **Returned**, because you sent it | A connector that binds the callback to a pending connection via `state` — which is how most multi-tenant platforms route a callback to the right customer — **must** use *Install from your landing page*. In Marketplace mode you have to exchange the code, store the tokens against `companyId`, and attach them to a customer account after the user signs in on your side. Both modes still list your app in the Marketplace. - **OAuth → redirect URI** (§7) and **Manage scopes** (§8). "Expand each topic and select the actions your app needs"; each scope "includes current and future data". - **Webhooks** (optional): endpoint URL, a **Validate** click that must get a `200` back, and a generated **Secret** to copy. Bob auto-subscribes each installing company to the app's configured webhooks during authorization. Partner webhook delivery is a different model from customer webhooks — route events by `companyId`. - **Test**: **OAuth → Test your app installation → Test from Bob Marketplace → Install** verifies consent and redirect; for the full journey, your partner Bob company shows Developer Portal apps under **Bob products → Marketplace → Manage apps → In development** with a **Dev mode** badge. Dev-mode apps are visible **only on your partner company**. Uninstall during development is a button in the same portal panel; in production customers uninstall in Bob. - **Submit for technical review**: HiBob asks for a certification video, "5–6 minutes", demo data only, with voiceover or captions, on an unlisted link, **recorded within 14 days of submission**, showing the OAuth install flow for your mode, the initial data sync, the relevant lifecycle or event-driven flows, and the uninstall. ## 7. Redirect URIs HiBob's description: the redirect URI "is where the authorization server sends the user after they approve the installation in Bob", and **it must work for users who are not logged in yet** — Bob may land a customer on your callback before they have an account with you. The authorization `code` expires in **5 minutes**, so exchange it server-side immediately, store the tokens keyed by `state` (landing-page mode) or by `companyId`, and do the account linking afterwards. Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes that each cost a cycle: - The same `redirect_uri` must appear in the installation URL **and** in the `authorization_code` token exchange, where it is a required body parameter. HiBob does not publish its matching rules — assume exact string matching, no trailing-slash forgiveness. - **Whether one app accepts several redirect URIs is not documented publicly.** The portal field is described in the singular. If it turns out to accept only one and your platform needs four, that is a finding to report — a separate app per data center, or a single shared callback host, is an architecture decision, not something to improvise. - Missing the redirect URI in the portal produces a named error at install time: `hibob.marketplace.error.bad.request.redirect.url.missing` (§11). ## 8. Scopes Scopes are **space-separated** in the installation URL. HiBob's own example query string spells the parameter `scope=` while the parameter table immediately below it says `scopes` is required — **the documentation is inconsistent on the same page**. Send what the portal's generated App Installation URL contains and verify against a real install rather than reasoning about it; sending both keys is a defensible belt-and-braces move, but test it. HiBob publishes a complete **Scopes mapping to endpoints** page — read the row for each endpoint you actually call. The families an HRIS/ATS/time-off connector reaches for: | Scope | Covers | | --- | --- | | `employee_data:read` / `employee_data:write` | `/v1/profiles`, people search, custom tables, employment data; write covers profiles, work history, training, avatars | | `employee_data.history:read` | Bulk people endpoints — employment, lifecycle, work history | | `employee_data.sensitive:read` / `:write` / `.history:read` | Bank accounts, salaries, equities, variable pay, payroll history | | `company.metadata:read` / `:write` | Company lists, field definitions, configuration metadata | | `employers:read` | Employer API — employers, work locations, their metadata | | `timeoff:read` / `timeoff:write` / `timeoff.sensitive:read` / `timeoff.calendars:write` | Requests, balances, whosout; private-policy data is its own scope | | `attendance:write` | Time entries, clock operations, summaries | | `projects:write` | Attendance projects and project tasks | | `tasks:write` | Task read **and** write | | `documents:read` / `documents:write` | Employee documents, folders, eSign | | `goals:read` / `goals:write` | `goals:read` is only goal-cycles search; **goal and key-result search live under `goals:write`** | | `hiring:write` / `hiring.integrations:write` | The whole Bob Hiring search surface — job ads, openings, candidates, applications, interviews, evaluations, offers | | `job_catalog:read`, `job_catalog.skills:read` / `:write` | Job families, roles, profiles, competencies, skills | | `reports:read`, `workforce_planning:read` / `:write`, `learning.integrations:write`, `payrollhub.*` | Reports, positions and budgets, LMS, payroll hub | Two traps specific to this catalog: - **Several read operations only exist under a `:write` scope.** `hiring:write` is documented as covering "Search and read hiring data" with no `hiring:read` twin; `tasks:write` covers the GETs; `projects:write` covers project reads; goal search sits under `goals:write`. A read-only integration will therefore be asking a customer to approve write-shaped scopes. Say so in your install instructions before a security reviewer finds it. - **Scopes never widen what the audience allows** (§4). Approve the minimum set, and remember that changing it later forces every existing customer to uninstall and reinstall. ## 9. Capture the credentials ### Partner OAuth From the Developer Portal you get **development credentials** first — `client_id`, `client_secret`, and an **App Installation URL for testing** generated from your app ID, redirect URI and scopes (it already carries `mode=dev`). At go-live, **OAuth → Production credentials** gives the production **Client ID** and **Client secret** and the production App Installation URL. Record, alongside them: - Installation URL parameters: `app_id`, the scope list, `redirect_uri`, optional `state`, and `mode=dev` **only** for development credentials. - Token and refresh endpoint: `POST https://auth.app.hibob.com/oauth2/v1/apps/token`, `Content-Type: application/x-www-form-urlencoded`. HiBob recommends HTTP Basic with `client_id:client_secret`; sending them as body parameters is also documented. - API host: `https://api.hibob.com/v1/…` with `Authorization: Bearer `. ``` { "access_token": "...", "token_type": "Bearer", "expires_in": ..., "refresh_token": "...", "id_token": "..." } ``` Four facts to write down where whoever operates this will see them: - **`app_id` is not `client_id`.** HiBob says so twice. `app_id` appears only in the installation URL and plays no role in the token exchange. `client_id` is issued at registration and is what the token endpoint wants. - **`companyId`, not the installer's email, is the tenant key.** It arrives as a custom claim inside the access token and in webhook payloads, and it is stable across re-installs. HiBob: "Do not use the installer's email alone as the tenant key." - **Store the rotated refresh token every time.** If refreshes work for weeks and then start failing with `401` on `/token`, you are reusing an old one. - **Development and production credentials are not interchangeable.** "A token created with development client ID & client secret cannot be refreshed with production credentials, and vice-versa." **Rotating the client secret invalidates every previously issued refresh token, and every customer must reinstall.** HiBob spells this out under compromised credentials. A compromised access or refresh token affects one company; a compromised client secret affects all of them, and the instruction is to contact `partnersupport@hibob.io` with the App ID. Never rotate without explicit go-ahead and a cutover plan. ### Service user Two strings per customer: the **ID** and the **token**, combined as `ID:token`, Base64-encoded, sent as `Authorization: Basic `. That is the whole scheme — no subdomain, no tenant parameter, no refresh. The API host is the same `https://api.hibob.com/v1/` for every customer, which means **the credential is the only thing identifying the tenant**: mixing two customers' service users up is a data-leak-shaped bug with no host to catch it. The token cannot be re-read after creation. A lost token is replaced, not recovered. ## 10. Rate limits, the WAF, sandbox **Rate limits are per endpoint, per minute**, and HiBob publishes a table for each module. The ones that shape an HRIS sync: | Endpoint | Limit / minute | | --- | --- | | `POST /people/search` | 50 | | `POST /people/{identifier}` | 100 | | `GET /profiles` | 40 | | `PUT /people/{identifier}` (update), `POST /people` (create), terminate | 10 | | Docs API | "No rate limits are implemented at the moment" | Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix epoch); exceeding a limit returns `429`, and HiBob's error page shows `Retry-After` alongside the rate-limit headers on that response. Back off to the reset, do not retry blind. **`POST /people/search` has no pagination.** No `limit`, no `cursor`, no `next_cursor` — one call returns *every* employee the credential can see. HiBob's prescribed pattern for large companies is client-side batching: first a lightweight call with `fields: ["root.id"]` and no filter to discover IDs, then repeated calls with the full field list and a single `root.id` `equals` filter carrying **50–200 IDs**. Search accepts at most **one** filter object, at most **400** field IDs, and only `root.id` / `root.email` as `fieldPath` with `equals`. Terminated employees require `showInactive: true` **and** a permission group whose **Access data for** condition does not restrict to Employed. Budget accordingly: at 50 calls/minute, a connector that spends two search calls per page of employees tops out at about 25 pages a minute, before anything else on the account competes for the same budget. **The WAF block is the multi-tenant hazard.** Separately from rate limits, HiBob blocks the **source IP for 5 minutes** when it sees, within 10 seconds, more than 50 responses of `401` from the same IP, or more than 50 `401`s against the same Authorization header, or more than 50 `403`s from the same Authorization header. HiBob's instruction is explicit: "Do **not** retry failed `401` or `403` requests. Instead, stop calling the API for those credentials and check them with the client." On a platform where hundreds of tenants share an egress IP, a single customer whose service user was deleted can generate enough `401`s to take every other tenant offline for five minutes. Fail a connection fast and mark it broken; do not let a retry loop anywhere near a `401`. **Sandbox is a customer purchase, not a developer perk.** Bob's Sandbox is a separate environment on a separate host — HiBob's OpenAPI lists `https://api.hibob.com/v1` (Production) and `https://api.sandbox.hibob.com/v1` (Sandbox) — and "the Sandbox capability is available only for accounts that have purchased Sandbox". A connector that hardcodes the production host cannot connect a customer who wants to test against their sandbox. For partners, the test environment is instead your **partner Bob company** in Dev mode (§6); there is no free developer tenant otherwise. **Audit.** Bob exposes Public API usage in-product ("View API audit logs" in the Help Center). When a customer disputes what your integration read, that is where the conversation goes, not your logs. ## 11. What this platform's connector expects **Dated observation — 2026-09-20.** The connector supports **both** credential models. Treat every line here as a snapshot and **confirm it with the connector's owner before registering anything**; connector configuration changes more often than a vendor's docs do. - **Service-user mode** asks the customer for two inputs, labelled *Service User ID* and *Service User Token*, and points them at Bob's Settings → Integrations → Automation → Service Users. The two values are sent as HTTP Basic with the ID as username and the token as password — which is exactly HiBob's documented `base64(ID:token)` scheme. The API host is `https://api.hibob.com/v1/`. - **OAuth mode** starts installation at `https://app.hibob.com/api/marketplace/apps/install`, with a second "Development Environment" variant that appends `mode=dev`, and exchanges and refreshes at `https://auth.app.hibob.com/oauth2/v1/apps/token` with a form-encoded POST. It requires a developer-supplied **App ID** in addition to client ID and secret, and sends it as `app_id`. It sends `redirect_uri` and `state`, and emits the scope list under **both** `scopes` and `scope` with spaces percent-encoded — a direct response to the doc inconsistency in §8. It records the access-token lifetime as **5 minutes** and the refresh-token lifetime as **30 days**, matching HiBob's best-practices section. - **Its scope map is per object and per direction**, and it already reflects the read-via-`:write` quirk: `hiring:write` for every ATS object, `projects:write` for projects and project tasks, `tasks:write` for ticketing objects, `goals:write` for goals, plus `employee_data:read`/`:write`, `employee_data.sensitive:read` for bank accounts, `employers:read` for company, `company.metadata:read`/`:write`, `timeoff:read`/`:write` and `documents:read`. - **Employee listing already follows HiBob's prescribed batching**: an ID-only search first, then a filtered detail search over that page's IDs with `humanReadable: "APPEND"`. It sets `showInactive` only when terminated data is requested. - **It records a partner-application link and `partnersupport@hibob.io` as the partner contact** — consistent with the application route on HiBob's own partner page. - **Four things to check with the owner before trusting an OAuth install.** (1) Which **installation mode** the Developer Portal app is registered under — the connector sends and expects `state`, which HiBob does not return in *Install from Bob Marketplace* mode (§6). (2) Whether the **rotated refresh token is persisted on every refresh** (§9). (3) Whether `app_id` is kept out of the **token exchange**, where HiBob does not want it (§9). (4) Whether a `401` anywhere in the stack can trigger a retry, given the WAF rule (§10). - **No sandbox host is configured.** A customer on Bob Sandbox (`api.sandbox.hibob.com`) cannot be connected as things stand (§10). ## 12. Verify end-to-end Getting a token back proves almost nothing here. Test what a customer's tenant actually returns. 1. **Authenticate and read one employee.** `POST /people/search` with `fields: ["root.id"]` and no filter. Count the results and **compare the count to the customer's headcount**. A short list is a permission-group problem, not an empty company. 2. **Ask for every field you map, then diff the response against the request.** Missing keys on a `200` are the silent omission (§4). Walk the checklist — metadata field ID → `categoryId` → **View** on that field or category → **Access data for**. 3. **Test one sensitive field** if you map any (SSN, full DOB, age). It comes back only with **View and Edit**. 4. **Test terminated employees** with `showInactive: true`, and confirm the permission group's **Access data for** condition was loosened — the flag alone is not enough. 5. **Read one employee-table endpoint** and check for `X-Has-Restricted-Columns` / `restricted_columns`. 6. **On OAuth: refresh, then refresh again with the token the first refresh returned.** That is the two-minute test for a client that ignores rotation. Then confirm that a job running longer than five minutes re-checks expiry *during* the loop, not just before it. 7. **On OAuth: run a full install from a second Bob company**, and confirm your callback routes it to the right customer using `companyId` (and `state`, if your mode returns one). 8. **Watch `X-RateLimit-Remaining` during a real sync**, not a single call. | Symptom | Cause | | --- | --- | | `200 OK` but fields you asked for are missing | Silent omission: no **View** on that field/category, or an invalid field ID. Not an error, not a mapping bug (§4) | | Empty response from a "Try It!" or a first call | HiBob's own note: "the permissions do not allow you to access the data you are trying to fetch" (§3) | | Employee list is a subset of the company | Service user: **Access data for** scope. OAuth: the **audience** the customer chose at install (§4) | | Terminated employees missing although `showInactive: true` | The permission group still carries **Lifecycle status equals Employed** (§3) | | SSN / full date of birth / age always absent | Those fields need both **View and Edit**, even for a read-only integration (§4) | | Historical table rows empty, current values fine | **View history** on the category was never granted (§4) | | `403` on every call to one module | Missing scope or Feature permission — **or the source IP is not on the company's IP trust list** (§4, §10) | | `404` on a specific employee, valid scopes | That employee is outside the installed audience — HiBob documents `404` (or `403`) for this (§4) | | `404` on a whole module | That module is not purchased/activated for the company (e.g. Time & Attendance, Bob Hiring) | | `401` on every call, credential looks right | Wrong Basic composition (`ID:token`, in that order), or the service user was deleted/refreshed (§9) | | All tenants fail for ~5 minutes at once | WAF block from repeated `401`/`403` on a shared egress IP. Stop retrying auth failures (§10) | | `429` | Per-endpoint per-minute limit. Back off to `X-RateLimit-Reset` (§10) | | Refresh worked for weeks, now `401` on `/token` | The rotated refresh token was not persisted, or 30 days of inactivity elapsed, or the customer uninstalled — there is no uninstall webhook (§9) | | Refresh fails well inside 30 days | Development credentials refreshed with production ones, or vice-versa (§9) | | Long sync fails partway with `401` | The access token expired 5 minutes in; refresh *during* the loop (§9) | | `hibob.marketplace.error.bad.request.scopes.missing` at install | No scope defined and saved before the installation URL was copied (§8) | | `hibob.marketplace.error.bad.request.redirect.url.missing` | Redirect URI not defined and saved before copying the URL (§7) | | `hibob.marketplace.error.not.found.app.not.exists` | `&mode=dev` missing while using development credentials (§9) | | Bob login screen instead of the consent screen | Not signed in to the test Bob account before starting the install (§6) | | No `state` on the callback | *Install from Bob Marketplace* mode does not return one — by design (§6) | | Customer wants different scopes or a wider audience | No in-place update exists; uninstall and reinstall (§Platform state) | | Customer says the Service Users screen is not there | They are not a Bob admin (§3) | ## 13. Hand off — never commit the secret - **Do not** write a client secret, a service user token, a refresh token or a webhook secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the human running this, for their secret store or console. - A service user **ID** is an identifier and may be recorded; the **token** is a password. They arrive in the same message from the customer — separate them before you paste anything anywhere. - If a secret has already been pasted into a transcript, say so plainly and say it can be rotated (service user: refresh the token; OAuth: rotate in the Developer Portal, accepting that every customer reinstalls). - If a code change is needed (a callback host, a scope string, a sandbox host), keep it credential-free and say what the human must set out of band. - Close with: which credential model; for OAuth, the app name, `app_id`, client ID, whether these are development or production credentials, the installation mode, the exact scope string and the redirect URIs registered; for service users, which customer, which Bob admin created it, which permission group and which fields it was granted; the rate-limit budget the connector will actually get; and what is still waiting on HiBob or on the customer. ## Stop and ask Hand back to a human rather than guessing when: **there is no accepted HiBob partnership** and the ask is OAuth credentials — say so in the first reply, do not start a registration that cannot complete; the partner application asks for commercial, security or compliance claims; someone proposes moving live per-customer service-user connections onto partner OAuth (every customer reconnects, and the audience/scope choice becomes theirs); a customer must grant **Edit** on sensitive fields for a read-only integration and nobody has approved that; the only testable tenant is a customer's production Bob and the task involves writes; the Developer Portal accepts only one redirect URI and the platform needs four; the app's installation mode conflicts with how the callback is routed (§6); a customer uses Bob Sandbox and the connector has no sandbox host; the `state` / `scope` vs `scopes` inconsistencies in HiBob's docs have to be resolved by testing rather than reading; or the portal, Bob's Service Users screen or the docs do not match the **Platform state** section. ## References Verified 2026-09-20 — every URL below returned HTTP 200. The Bob **Help Center** (`help.hibob.com`) returns `403` to automated requests, so its articles — *Manage service users*, *Create a service user permission group*, *Permissions for Default Employee Fields in People Search API*, *View API audit logs*, *Use Bob MCP Server* — are cited by title only and must be opened in a browser; each is linked from the HiBob developer pages below. - Developer hub — https://apidocs.hibob.com/ - Getting started with Bob's API (customers → service users, partners → OAuth) — https://apidocs.hibob.com/docs/getting-started - Start developing with the API (module map, `.md` export trick) — https://apidocs.hibob.com/reference/getting-started-with-bob-api - API Service users (the five-step setup, permission groups, FAQ) — https://apidocs.hibob.com/docs/api-service-users - Authorization (Basic header construction) — https://apidocs.hibob.com/reference/authorization - Permissions — https://apidocs.hibob.com/reference/permissions - Categories and permissions — https://apidocs.hibob.com/docs/categories-and-permissions - Employee data API: field-level permissions (sensitive fields, `restricted_columns`) — https://apidocs.hibob.com/docs/employee-data-api-field-level-permissions - People read API contract (silent omission, default field set, no pagination, batching) — https://apidocs.hibob.com/docs/people-read-api-contract - People API (required permissions, per-endpoint rate limits) — https://apidocs.hibob.com/reference/people - Search for employees — https://apidocs.hibob.com/reference/post_people-search - OAuth 2.0 for partners (flow, tokens, rotation, troubleshooting, FAQ) — https://apidocs.hibob.com/reference/oauth-20 - Scopes mapping to endpoints — https://apidocs.hibob.com/reference/scopes-mapping-to-endpoints - Marketplace partner integrations (OAuth only) — https://apidocs.hibob.com/docs/marketplace-partner-integrations-oauth-only - Quick start: add and submit an app — https://apidocs.hibob.com/docs/add-and-submit-an-app - Test apps in Bob Marketplace (Dev mode) — https://apidocs.hibob.com/docs/test-apps-in-bob-marketplace - Managing customer installations (`companyId`, `state`, uninstall detection) — https://apidocs.hibob.com/docs/managing-customer-installations - Getting started with partner webhooks — https://apidocs.hibob.com/docs/getting-started-with-partner-webhooks - Getting started with webhooks (customer model, v2) — https://apidocs.hibob.com/reference/getting-started-webhooks - Rate limiting (headers, WAF blocking rules) — https://apidocs.hibob.com/reference/rate-limiting - Rate limiting best practices — https://apidocs.hibob.com/docs/rate-limit - Error handling (status codes, error shapes, `Retry-After`) — https://apidocs.hibob.com/reference/error-handling - Testing (Try It!, Production vs Sandbox base URL, empty-response note) — https://apidocs.hibob.com/reference/testing - Pagination — https://apidocs.hibob.com/reference/pagination-1 - Employer API (required permission vs scope, IP trust list) — https://apidocs.hibob.com/reference/employers - Transition from API Access tokens (the retired model, and vendor-built integrations on service users) — https://apidocs.hibob.com/docs/transition-from-api-access-tokens - Enhanced service user management (audit fields on the Service Users table) — https://apidocs.hibob.com/changelog/enhanced-service-user-management - Changelog (subscribe before trusting any of the above for long) — https://apidocs.hibob.com/changelog - API Terms of Use (read-only clause, rate-limit discretion, 48-hour incident notice, partner contact) — https://apidocs.hibob.com/docs/api-terms-of-use - Documentation index for machine reading — https://apidocs.hibob.com/llms.txt - HiBob partner programs and the Tech Marketplace application — https://www.hibob.com/partner/ - HiBob Developer Portal (invite required) — https://developers.hibob.com/ - Bob (partner company sign-in, Marketplace, Service Users) — https://app.hibob.com - HiBob feature list, including Sandbox — https://www.hibob.com/features/ - HiBob Marketplace / integrations directory — https://www.hibob.com/integrations/ ## highlevel-oauth-app URL: https://docs.unified.to/skills/highlevel-oauth-app/SKILL.md # HighLevel OAuth2 App Registration Get a working HighLevel (GoHighLevel / LeadConnector) OAuth2 client — a developer marketplace account, an app, a redirect URL, scopes, client ID and secret — for a platform that connects many customers' HighLevel accounts. The form-filling is easy. Three things are not. **Target User (Agency vs Sub-Account) is chosen at app creation and cannot be changed afterwards** (§3) — pick wrong and you rebuild the connect flow under a new client ID. **A Private app is capped at 5 agencies** (§8) — install six and the sixth customer simply cannot install. And **most HighLevel material on the internet is about API v1**, which reached end-of-support on 31 December 2025 and used a static API key; v2 is OAuth, and a v1-shaped runbook will send you down a dead path (§ Platform state). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name** | Unique across the marketplace; shown on the OAuth consent screen and in the listing | | **HighLevel account / registration email** | The agency account that will own the developer profile (§2) | | **Redirect URL(s)** | Every callback host your platform serves — read §5 first, the field may take only one | | **Target User: Agency or Sub-Account** | Irreversible. §3. For a multi-tenant connector the answer is normally Sub-Account | | **Scope set** | The scopes the connector actually sends (§6) | | **Public or Private app** | Private caps you at 5 agencies (§8) | | **Logo, description, screenshots, support email/phone, demo video** | Needed for a Public listing review (§8) | | **New app or edit to an existing one?** | See §1 — the default answer is "existing" | ## Quick Start 1. Confirm a **new app** is actually needed — live customer connections are bound to the current client ID (§1). 2. Sign in at the developer marketplace, or have an agency admin create the developer profile (§2). 3. Create a **sandbox (App Test) account** — it is provisioned immediately and keeps testing off real data (§2). 4. Decide **Target User** and **Who can install** before clicking Create. Target User cannot be changed later (§3). 5. Create the app: name, type Private (for now), Target User, Who can install, bulk install (§4). 6. Set the **Redirect URL** exactly, and find out whether the pane accepts more than one (§5). 7. Select scopes on the app; they must cover everything the authorize request sends (§6). 8. Generate the **Client Keys** and copy the client secret immediately — it is shown once (§7). 9. Note the install cap and plan the public listing or security review (§8). 10. Verify with a real install → callback → token → refresh → one read call, from a sandbox (§10). 11. Hand the credentials over — never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) Nearly everything below changed in the last two years. Check the developer changelog before following any older write-up, including cached knowledge, a blog post or an internal doc: - **API v1 reached end-of-support on 31 December 2025.** Existing v1 integrations keep working with no support or updates, and the ability to generate new API keys is being removed from Agency and Sub-account settings. v1 used a static per-account API key with no OAuth at all — which is what most third-party tutorials, YouTube walkthroughs and older automation docs still describe. **v2 requires OAuth 2.0** for a marketplace app (or a Private Integration Token for a single sub-account; see §7). - **Every v2 API call carries a `Version` request header.** Two schemes coexist: date-based (`2021-04-15`, `2021-07-28`, `2023-02-21`) and named, starting with **`v3`, released 11 June 2026**. All are listed as supported, retirement dates "TBD". A retired version stops being accepted, so a pinned header is a dated liability. - **Private apps created on or after 18 November 2025 are capped at 5 agencies.** At 6+, new installs are blocked ("Install unavailable. Please contact the app developer."); existing installs keep working. Sub-accounts do not count separately — one agency is one count no matter how many sub-accounts, and bulk installs count once. - **App versioning is live**: Draft → In Review → Live → Disapproved/Deprecated, one Draft at a time, at most 5 versions per app. Install limits apply at the **app** level, not per version. - **The OAuth consent screen lists every requested scope** with a plain-language explanation, and shows an explicit warning for sensitive scopes such as user creation/modification. It applies to new installs and re-authorizations, on both the standard and grey-labeled (white-label) install links. - **Webhook signing is mid-migration**: the legacy RSA `X-WH-Signature` header **is deprecated on 1 September 2026**; the current header is Ed25519 `X-GHL-Signature`. If you consume webhooks, verify the new one (§9). - **Sandbox accounts are self-serve and immediate**, but limited: 2 sub-accounts per sandbox, sandbox Private Integration Tokens are throttled to roughly 25 requests / 10 seconds and 10,000/day, accounts are temporary (on the order of months) and data may be reset or purged without notice. - **The changelog carries "api path removed without deprecation" entries**, repeatedly and recently. Treat HighLevel endpoints as less stable than the docs' tone suggests, and subscribe someone to the changelog. If the developer portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one **A new app means a new client ID, and every existing customer connection is bound to the old one.** Every customer would have to re-install. Reuse the existing app (edit in place, or clone it as a new Draft version) for: adding a scope, changing the redirect URL, publishing publicly, or diagnosing an install failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a second brand or region, or — the one case where you have no choice — **a change of Target User** (§3), which the platform does not allow on an existing app. Say which path you are taking before you touch anything. ## 2. Account and sandbox - The developer marketplace lives at **`https://marketplace.gohighlevel.com`**; sign in or sign up there. An agency admin who picks "Sell on Marketplace" in their HighLevel account gets a developer profile created automatically from their existing details. - Apps are owned by the developer profile behind that account. For a platform integration, use a shared or service-owned account the team controls — not a person who might leave. Record whose account it is. - **Sandbox (App Test) account** — created from the developer portal with **+ Create App Test Account**, provisioned immediately, works as a standalone HighLevel account. Use one for install testing so nothing touches a customer's live sub-account. Respect the fair-use limits in **Platform state**; it is not a staging environment for real work. Hand control back to the user, saying exactly what is needed, for anything only they can do: signup email verification, 2FA, accepting developer terms, payment details on the agency account. Do not retry a blocked step in a loop. Before accepting developer or marketplace terms, skim for anything a founder would want flagged — revenue share, exclusivity, data-use commitments, reselling restrictions. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (§5 redirect URL, §6 scope list), then continue once they report back with the client ID. ## 3. Agency vs Sub-Account — the decision that defines the app This is the HighLevel concept everything else hangs off, and the one that is expensive to get wrong. HighLevel accounts nest: an **Agency** (also called Company) owns many **Sub-Accounts** (formerly, and still in the API, "Locations"). An access token is scoped to **one or the other**, and the token response says which: `userType: "Company"` with a `companyId`, or `userType: "Location"` with both a `locationId` and a `companyId`. Sub-account endpoints — contacts, opportunities, businesses, forms, tags — want a Location token. An agency token called against them does not quietly return a filtered view; it fails. Three fields on the app decide what you get: | Field | Values | Notes | | --- | --- | --- | | **Target User** | Agency / Sub-account | **Cannot be modified once set.** HighLevel's own guidance: roughly 95% of apps should be Sub-account | | **Who can install** | Both Agency & Sub-account / Agency only | Controls marketplace visibility and who may press install | | **Bulk installation** | Yes / No | Mandatory "Yes" for new marketplace apps; lets an agency install across many sub-accounts at once | What comes back at install time: - **Sub-account target, sub-account admin installs** → a **Location** token. Usable immediately. This is the happy path. - **Sub-account target, agency admin installs (including bulk)** → a **Company** token, flagged as a bulk installation. It carries no `locationId`. To touch sub-account data you must exchange it, per sub-account, at `POST https://services.leadconnectorhq.com/oauth/locationToken` — form-encoded `companyId` + `locationId`, the agency token as the bearer, and the `Version` header. That returns a Location token with its own refresh token. - **Agency target** → a Company token for agency-level work (creating sub-accounts, SaaS configuration, agency users). **For a multi-tenant platform whose unit of connection is one customer CRM: Target User = Sub-account, Who can install = Both Agency & Sub-account.** That gives a Location token directly on the common path, one connection per sub-account, per-sub-account rate limits (§8), and a consent screen the sub-account admin understands. But "Both" means **you will also receive Company tokens**, from every agency-side or bulk install. A connector that assumes a `locationId` is present stores a connection that authenticates but reads nothing. Handle it: on a Company token, discover the installed sub-accounts (the installed-locations endpoint, or the `AppInstall` webhook, which carries `locationId`, `companyId` and `installType`) and mint a Location token per sub-account. If the platform cannot do that yet, keep "Both" anyway — restricting to Agency only would make agency installs the *only* kind — and say plainly in your summary that agency-initiated and bulk installs are unsupported until the exchange is implemented, so support recognises the failure when it arrives. Choosing **Agency** as Target User to "get everything at once" is the classic mistake: it makes every connection agency-wide, changes the consent audience, needs a token exchange for every ordinary read anyway — and it cannot be undone without a new app and a re-install by every customer. ## 4. Create the app In the developer portal: **My Apps → Create App**. The fields that carry weight: 1. **App name** — unique, and public once listed. 2. **App type** — **Private** while you build and test; **Public** is listed in the marketplace and installable by anyone *after approval*. HighLevel's own advice is to start Private and switch when stable. Read §8 first: Private is capped at 5 agencies, and the cap applies to apps created on or after 18 November 2025. 3. **Target User** and **Who can install** — §3. Target User is permanent. 4. **Bulk installation** — Yes for new marketplace apps. Then, under the app's **Advanced Settings**: | Pane | Holds | | --- | --- | | **Auth** | Scopes, Redirect URL, and the **Install Link** (standard and white-label) | | **Secrets → Client Keys** | Client ID + client secret (§7) | | **Secrets → Shared Secret Key** | Only for signed user-context tokens in marketplace modules — not needed for a data connector | | **Profile** | Listing content: logo, description, screenshots, support channel | | **Versions** | Draft / In Review / Live lifecycle (§8) | | **Webhooks** | One webhook URL for the app (§9) | ## 5. Redirect URL The authorization code is delivered to the redirect URL, which must be **HTTPS** and must match what your OAuth flow sends, exactly — small differences break installs and the error surfaces at connect time, not at save time. For a platform that serves several regions, the callbacks are one path per data center: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` **Check how many the pane accepts before you promise anything.** HighLevel's app-creation guide describes entering "your URL in the Redirect URL field", in the singular, and does not document a multi-value list; the public-listing requirements say "redirect URLs configured", in the plural. Open the Auth pane and look. If it takes only one, a multi-region platform needs **one app — and therefore one client ID and secret — per callback host**, which is a materially different piece of work and a different answer for the user. Report what you find; do not assume either way. Customers on a **custom API domain** call back to their own host. Those customers bring their own HighLevel app and credentials — do not add customer domains to the shared app. ## 6. Scopes Scopes are selected on the app, in the Auth pane, from a dropdown; the authorize request then sends a space-delimited `scope` parameter. **What you send must be covered by what the app declares** — and, with versioning in play, by what the **Live** version declares, not by the Draft you just edited. Scope strings are `.` (`contacts.readonly`, `contacts.write`) with some nested forms (`locations/tags.readonly`). Each one in HighLevel's catalogue is tagged with an access type — **Sub-Account**, **Agency**, or both — which is the same Agency/Location split as §3: a sub-account-level scope is useless on an agency token. `users.readonly` / `users.write` are among the few that exist at both levels. Read the scopes reference rather than guessing a name. A CRM-shaped connector typically needs: | Scope | For | | --- | --- | | `contacts.readonly` / `contacts.write` | Contacts and leads, plus their tasks and notes | | `opportunities.readonly` / `opportunities.write` | Opportunities (deals) and pipelines | | `businesses.readonly` / `businesses.write` | Businesses (the company-shaped object inside a sub-account) | | `locations/tags.readonly` / `locations/tags.write` | Sub-account tag catalogue | | `users.readonly` / `users.write` | Users — also the cheapest identity call after connect | | `forms.readonly` | Forms and form submissions | Ask for the minimum. Scope justification is a **review requirement** for a public listing and for the private-app security review, the consent screen now spells each scope out to the installing admin, and sensitive scopes (user creation and modification in particular) raise a visible warning that costs you installs. > **Connector facts, as of 2026-09-20 — confirm with the connector's owner before registering anything.** > This platform's HighLevel connector **installs at the sub-account (Location) level**: it sends customers to the > standard marketplace install link (`https://marketplace.gohighlevel.com/oauth/chooselocation`) or its white-label > equivalent on `marketplace.leadconnectorhq.com`, and it reads the `locationId` out of the token response and scopes > every subsequent API call to that one sub-account. It does **not** implement the agency → location token exchange, > so a Company token from an agency-side or bulk install yields a connection with no sub-account to talk to. > It requests, per object: `contacts.readonly`, `contacts.write`, `businesses.readonly`, `businesses.write`, > `locations/tags.readonly`, `locations/tags.write`, `opportunities.readonly`, `opportunities.write`, > `users.readonly`, `users.write`, `forms.readonly`, with `users.readonly` alone for the identity/login flow. > The app it is pointed at must declare all of these on its **Live** version. > It exchanges and refreshes with a **form-encoded POST carrying client ID and client secret in the body**, sends > **no PKCE**, and sends **neither `user_type` nor `redirect_uri`** although HighLevel documents both on the token > and refresh requests. It pins `Version: 2021-07-28` on every API call. It also passes one **extra query parameter > on the install link, carrying a per-workspace "Version ID"** — HighLevel's public docs do not document a version > parameter on the install link (`versionId` appears only in the App Install webhook payload), so treat it as > unverified and ask the owner what it is for before copying the pattern. > A non-OAuth path also exists: a sub-account **Private Integration Token** plus the sub-account ID. > The connector does **not** subscribe to HighLevel webhooks today (§9). ## 7. Capture the credentials In **Advanced Settings → Secrets → Client Keys → Add**, name the key pair and save. HighLevel generates: - **Client ID** and **client secret**. **The secret is shown once** — copy it immediately; it cannot be re-read. The pane takes *named* key pairs, which is what makes a rotation without downtime possible (add the new pair, deploy, retire the old); confirm that in the UI before promising it, and never retire a key pair until the new one is live everywhere, because every token refresh runs through it. Record alongside them: - Install link (standard and white-label), taken from the **Show** button in the Auth pane rather than hand-built - Token exchange and refresh: `POST https://services.leadconnectorhq.com/oauth/token` - Agency → sub-account exchange: `POST https://services.leadconnectorhq.com/oauth/locationToken` - API base: `https://services.leadconnectorhq.com` - The `Version` header value the connector sends, and whether that is a deliberate choice - Target User, Who can install, app type (Private/Public), and which app **version** is Live - Whether these are sandbox or production credentials **Token behaviour, and the two ways it bites:** - **Access tokens last about 24 hours** (`expires_in` 86399/86400). Refresh; do not re-install. - **Refresh tokens rotate.** A refresh token is valid for a year *unless used*; the moment you use it, it is invalid and the response carries a replacement, itself good for a year. So the new refresh token **must** be persisted on every refresh, and two workers refreshing the same connection concurrently will burn each other's token and leave the connection dead. Serialise refreshes per connection. - HighLevel documents `user_type` (`Company` or `Location`) and `redirect_uri` on **both** the code exchange and the refresh. If your client omits them and it works today, that is undocumented tolerance, not a guarantee. - The token response is where you learn what you got: `userType`, `locationId`, `companyId`, `userId`, and a bulk-install flag. Branch on it at connect time (§3) instead of discovering the problem on the first read call. **Private Integration Tokens are the non-OAuth alternative, and are not a multi-tenant answer.** A sub-account admin generates a scoped, static token in their own settings; it never expires on its own and does not auto-refresh. That is fine for one customer's internal sync or for sandbox testing, and it is HighLevel's recommended replacement for retired v1 API keys. It is not a way to skip app registration: every customer would have to create and paste a password-equivalent, per sub-account, with no consent screen and no central revocation. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 8. Install limits, review, listing — and rate limits - **Private app: 5 agencies.** One agency counts once regardless of sub-accounts; bulk installs count once; counts update in real time as installs and uninstalls happen, and the cap is per app across all versions. You get an amber warning at 4–5 and a hard block at 6+. **For a multi-tenant connector this is a ceiling on customers**, so raise it with the user before anything ships. - **Two ways out.** Publish **Public (listed)** — Start Public (listed) review, set App type = Public, submit — or, only *after* you have exceeded the cap, request a **Security Review** that lifts the cap while the app stays Private. The security review asks for an end-to-end demo video, a video justifying every requested scope, and a written rationale for staying private. Material changes later (scopes, domains, data flows) can trigger a re-review, and new installs may be restricted until it passes. - **Public listing review** checks branding and naming (no implying an affiliation you do not have), an accurate description including any costs, error-free install and uninstall, HTTPS redirect URLs, least-privilege scopes **with justification**, a demo video, and at least one real support channel with a 24-hour first response. No published turnaround — do not promise a customer a date. - **Agencies can block you.** An agency can switch on "show only approved apps to sub-accounts" and disapprove individual apps; a disapproved app stops being installable **and is uninstalled from sub-accounts that already have it**. A connector that works at one customer and silently dies at another is usually this, and there is no API error that says so. - **Rate limits: a burst of 100 requests / 10 seconds and 200,000 requests / day, counted per marketplace app per resource** — a resource being one sub-account or one agency. Per-resource means one noisy customer cannot throttle another, and it is another argument for sub-account tokens: agency-token traffic for many sub-accounts all lands in one bucket. Responses carry `X-RateLimit-Max`, `X-RateLimit-Remaining`, `X-RateLimit-Interval-Milliseconds`, `X-RateLimit-Limit-Daily` and `X-RateLimit-Daily-Remaining`; read them rather than guessing. ## 9. Webhooks (if they are in scope) Webhooks in HighLevel are **a property of the app, not a per-sub-account subscription**: you set one webhook URL in the app settings, select the scopes that cover the events you want, and every sub-account that installs the app starts delivering those events. There is no per-location subscribe/unsubscribe call to make or clean up, which also means you cannot have two apps' worth of events split by customer. `AppInstall` is the one to wire first even if you consume nothing else — it fires on every install with `locationId`, `companyId`, `installType`, `appId` and `versionId`, and it is how an agency/bulk install tells you which sub-accounts you must mint Location tokens for (§3). `AppUninstall` is how you learn a connection is dead before your next 401. Verify signatures. HighLevel sends `X-GHL-Signature` (Ed25519, current) and the legacy `X-WH-Signature` (RSA-SHA256), **with the legacy header deprecated on 1 September 2026**; the public keys are published in the webhook integration guide. Reject anything that fails verification. ## 10. Verify end-to-end A registration that half-worked is worse than one that stopped early — the failure shows up later as a customer who cannot connect. Prove it: 1. Install into a **sandbox** sub-account through your platform's real connect flow, using the credentials you just captured, and complete the callback. 2. Read the consent screen: the scopes listed should be exactly the ones you registered, no more. 3. Inspect the token response: `userType`, and whether `locationId` is present. **Also test the other path** — have an agency admin install (or bulk install) and confirm your platform either exchanges for Location tokens or fails loudly rather than storing a token it cannot use. 4. Force a **refresh**, then confirm the *new* refresh token was persisted and a second refresh still works. 5. Call one endpoint per object the connector maps, with the `Version` header, and confirm a write as well as a read if writes are supported. | Symptom | Cause | | --- | --- | | Connect succeeds, every read 401s or 4xxs, no `locationId` stored | An agency installed it: you hold a Company token and never exchanged it for a Location token (§3) | | 401 on sub-account endpoints with a token that "works" elsewhere | Agency token against sub-account endpoints — wrong token type, not a scope problem (§3) | | Install page refuses: "Install unavailable. Please contact the app developer." | Private-app cap — 6th agency (§8) | | App invisible to a sub-account admin | "Who can install" is Agency only, or the agency's approved-apps list excludes you (§3, §8) | | A customer's connections die and the app disappears from their sub-accounts | The agency disapproved the app; existing installs are uninstalled (§8) | | Scope missing at consent, although you added it | It was added to a Draft version; the Live version is what installs use (§ Platform state) | | Redirect mismatch / code never arrives | The callback host is not the one registered on the app (§5) | | `invalid_grant` on refresh | The rotated refresh token was not persisted, or two workers refreshed concurrently (§7) | | Everything 401s after ~24h | The client is not refreshing; access tokens last about a day (§7) | | Calls fail with a version error, or an endpoint behaves like a different API | Missing or stale `Version` header — the version is per request (§ Platform state) | | 429s | Burst limit of 100/10s per app per resource; read the `X-RateLimit-*` headers (§8) | | Webhooks stop verifying | Still checking the legacy RSA signature header after its 1 Sep 2026 deprecation (§9) | ## 11. Hand off — never commit the secret - **Do not** write the client secret, a private integration token or a shared secret key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is genuinely needed (a callback host, a scope list, a `Version` header value), keep it secret-free and say plainly what the human must set out of band. - Close with: app name, the developer account that owns it, and **Target User / Who can install / app type**; client ID; where the secret was delivered; the install link, token, location-token and API base URLs; the exact scope strings on the Live version; the install cap and listing/review status with dates; the `Version` header in use; and anything left for the user to do, listed plainly. ## Stop and ask Hand back to a human rather than guessing when: Target User would have to change (it needs a new app and a re-install by every customer); the redirect pane turns out to accept a single URL and a multi-region platform therefore needs one app per region; a Private app is at or near the 5-agency cap and someone must choose between publishing publicly and a security review; a review form asks for compliance, legal, security or volume claims, or a demo video you would have to fake; a customer's agency has blocked or must approve the app; someone proposes private integration tokens instead of OAuth for multi-tenant use; rotating or retiring a client key pair on a live app is on the table; or the developer portal does not match the **Platform state** section above. When the portal behaves in a way the docs did not describe, say what you found rather than picking the option that lets you keep going. ## References Official HighLevel docs and support articles only; every URL below returned HTTP 200 on 2026-09-20. Note that the docs site serves an empty shell for unknown paths, so a 200 alone does not prove a page exists — each of these was checked for real content. The developer portal itself requires sign-in. - Developer marketplace portal — https://marketplace.gohighlevel.com/ - OAuth 2.0 (install flow, token exchange, refresh, token types, location token) — https://marketplace.gohighlevel.com/docs/Authorization/OAuth2.0 - Scopes catalogue with Sub-Account / Agency access types — https://marketplace.gohighlevel.com/docs/Authorization/Scopes - Access tokens for Target User = Sub-Account (agency install, `/oauth/locationToken`, bulk) — https://marketplace.gohighlevel.com/docs/Authorization/TargetUserSubAccount/ - Access token generation: agency vs sub-account scenarios — https://marketplace.gohighlevel.com/docs/Authorization/AccessTokenUseCase - Private Integration Tokens — https://marketplace.gohighlevel.com/docs/Authorization/PrivateIntegrationsToken/ - Getting started (marketplace developer path) — https://marketplace.gohighlevel.com/docs/oauth/GettingStarted - Create a marketplace app (app type, target user, scopes, redirect URL, client keys) — https://marketplace.gohighlevel.com/docs/oauth/CreateMarketplaceApp/ - App distribution model — https://marketplace.gohighlevel.com/docs/oauth/AppDistribution - App testing guide — https://marketplace.gohighlevel.com/docs/oauth/AppTestingGuide - Create a sandbox (App Test) account — https://marketplace.gohighlevel.com/docs/oauth/SandboxAccount - Private Integration Tokens for sandbox accounts (sandbox rate limits) — https://marketplace.gohighlevel.com/docs/oauth/SandboxPIT/ - App review guidelines (public listing requirements) — https://marketplace.gohighlevel.com/docs/oauth/AppReviewGuidelines/ - How to update your app (versioning, Draft/Live, version limits) — https://marketplace.gohighlevel.com/docs/oauth/HowToUpdateYourAPP - Policy: private app install limits and security review — https://marketplace.gohighlevel.com/docs/MarketplacePolicies/PrivateAppInstallLimits/ - Policy: sandbox fair use — https://marketplace.gohighlevel.com/docs/MarketplacePolicies/SandBoxFUP - Rate limits and `X-RateLimit-*` headers — https://marketplace.gohighlevel.com/docs/other/rate-limits/ - API versioning and the `Version` header — https://marketplace.gohighlevel.com/docs/Versioning - API changelog — https://marketplace.gohighlevel.com/docs/Changelog - OAuth FAQs (token lifetimes, refresh rotation, webhook setup) — https://marketplace.gohighlevel.com/docs/oauth/Faqs - OAuth API reference (v3) — https://marketplace.gohighlevel.com/docs/ghl/oauth/oauth-2-0-v-3 - Get location access token from an agency token — https://marketplace.gohighlevel.com/docs/ghl/oauth/get-location-access-token - Get locations where the app is installed — https://marketplace.gohighlevel.com/docs/ghl/oauth/get-installed-location - Webhook integration guide (signature headers and public keys) — https://marketplace.gohighlevel.com/docs/webhook/WebhookIntegrationGuide - `AppInstall` webhook payload — https://marketplace.gohighlevel.com/docs/webhook/AppInstall - `AppUninstall` webhook payload — https://marketplace.gohighlevel.com/docs/webhook/AppUninstall - API v1 end-of-support and migration — https://help.gohighlevel.com/support/solutions/articles/48001060529-highlevel-api - OAuth consent screen for marketplace apps (install link formats) — https://help.gohighlevel.com/support/solutions/articles/155000005002-api-security-oauth-consent-for-marketplace-apps - Marketplace app distribution type (target user is permanent) — https://help.gohighlevel.com/support/solutions/articles/155000002141-marketplace-app-distribution-type - Agency control over which apps sub-accounts may install — https://help.gohighlevel.com/support/solutions/articles/155000001163-managing-marketplace-app-permissions-white-label-agency-control - Getting started with the developer marketplace — https://help.gohighlevel.com/support/solutions/articles/155000000136-how-to-get-started-with-the-developer-s-marketplace ## hubspot-oauth-app URL: https://docs.unified.to/skills/hubspot-oauth-app/SKILL.md # HubSpot OAuth2 App Registration Get a working HubSpot OAuth2 client — developer account, app, redirect URLs, scopes, client ID and secret — for a platform that connects HubSpot on behalf of many customers. This is mostly careful reading and careful form-filling. Two steps are expensive to get wrong: **scope placement** (HubSpot fails the install if a scope your authorize URL sends is not declared on the app) and the **25-install cap** on unlisted marketplace-distribution apps (it silently stops customer #26 from connecting, long after anyone is watching). Slow down at both. ## Inputs to collect before you start Ask for these in one batch rather than one at a time. Never invent them. | Input | Notes | | --- | --- | | **App name** | What appears on HubSpot's consent screen | | **Account / registration email** | The HubSpot account that will own the app; for an existing app, the owner account on record | | **Redirect URLs** | Every callback host your platform serves (§4) | | **Scope set** | The scopes your connector actually uses (§5) | | **Logo, privacy policy URL, terms URL, support email** | Needed for a Marketplace listing (§7) | | **New app or existing app?** | See §1 — the default answer is "existing" | ## Quick Start 1. Confirm a **new app** is actually needed — existing customer connections are bound to the current `client_id` (§1). 2. Sign in to (or create) the HubSpot account that owns the app; hand control back for email/2FA verification (§2). 3. Create a **developer test account** for install testing (§2). 4. Create the app with the HubSpot CLI (projects platform); pick `oauth` and the right distribution (§3). 5. Register **every** redirect URL your platform uses (§4). 6. Put only the always-sent scopes in `requiredScopes`; everything else in `optionalScopes` (§5). 7. `hs project upload`, then read client ID / client secret from the app's **Auth** tab (§6). 8. Check the install cap and decide about a Marketplace listing (§7). 9. Verify with a real authorize → callback round trip (§8), then hand the credentials over — never commit them (§9). ## Platform state (verified 2026-09-18 — re-verify before trusting) HubSpot changed all three of the things this task depends on during 2026. Check the changelog before following any older write-up, including cached knowledge or a stale internal doc: - **Legacy public apps can no longer be created** — sunset 2026-05-26 (new accounts) / 2026-06-23 (existing accounts). Existing legacy public apps keep working untouched. New apps are built on the **projects platform** (current version `2026.09`) via the HubSpot CLI — there is no "create app" button anymore. - **Separate app developer accounts are legacy** — on 2026-03-09 legacy developer accounts were migrated into standard HubSpot accounts. App work now lives under **Development** in a standard account's nav. - **Unlisted marketplace-distribution apps are capped at 25 installs** (enforced since 2025-09-22) until a Marketplace listing is approved. Private distribution is capped lower: 10 accounts, or 100 for Solution Partners. If the portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one **A new app means new credentials, and every existing customer connection is bound to the old `client_id`.** Creating one because the portal looks unfamiliar breaks every live connection. Reuse the existing app (sign in, edit in place) for: adding a scope for a new object type, adding a redirect URL for a new region, rotating a leaked secret, or diagnosing an install failure. Its client ID, owner account and app ID live in your platform's secret config / console — look them up there, not by guessing from the portal listing. Register a **new** app only when the user explicitly wants one: a second app for a new region or brand, a replacement for a compromised app, or a projects-platform migration they have asked for. Say which path you are taking before you act. ## 2. Account: sign in or sign up - **Sign in** at `https://app.hubspot.com/` as the account that owns the app, then open **Development** in the left nav. Legacy apps live under **Development → Legacy apps**. - **Sign up** (only when there is no account, or the user wants a separate one) for a free HubSpot account at `https://app.hubspot.com/signup-hubspot/`. Use the registration email the user gave you. Never invent identity details — company size, phone number, job title, expected volume. Ask, or leave optional fields blank. - **Super Admin** permission is required to install apps and to read the developer API key. - **Developer test account** — free, ~90-day enterprise trial, up to 10 per account, cannot sync with other accounts: **Development → Testing → Test Accounts → Create developer test account**. Use one for install testing so nothing touches a real customer portal. Hand control back to the user, saying exactly what is needed, for anything only they can do: CAPTCHA, email verification links, SMS/authenticator codes, accepting developer terms. Do not retry a blocked step in a loop. Before accepting developer terms, skim them for anything a founder would want to know about — exclusivity, revenue share, per-call fees, data-use commitments, restrictions on reselling access. Boilerplate needs no commentary; anything unusual gets flagged before you tick the box. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (§4 redirect URLs, §5 scope lists), then continue once they report back with the client ID and app ID. The CLI steps in §3 you can run yourself once `hs` is authenticated. ## 3. Create the app (projects platform, CLI) ```bash npm install -g @hubspot/cli@latest # v7.6.0+ required for app projects hs init # or: hs account auth (CLI 7.4+, global config) hs project create # choose the "App" template ``` At the prompts: - **Distribution** → `marketplace` for a multi-tenant connector any HubSpot account can install. `private` only for a one-off or bring-your-own-app customer install (10-account cap). - **Authentication** → `oauth`. `static` has no client secret and cannot serve multiple accounts. - **Features** → skip cards, workflow actions and settings unless the user asked for them. A data connector needs API access and, optionally, webhooks. Then edit `app-hsmeta.json` — this file, not a UI form, is the app config: ```jsonc { "uid": "my_app", // globally unique within the project "type": "app", "name": "My App", // shown on the consent screen "description": "…", "logo": "./logo.png", "auth": { "type": "oauth", "redirectUrls": [ /* §4 */ ], "requiredScopes": [ /* §5 */ ], "optionalScopes": [ /* §5 */ ], "conditionallyRequiredScopes": [] } } ``` Fill `supportEmail`, `documentationUrl` and `supportUrl` with values the user supplies — they are required for a Marketplace listing later, and a guess there becomes a public claim. Then: ```bash hs project upload # creates a build and registers the app hs project open # opens the project; click the app → Auth tab ``` ## 4. Redirect URLs Register **every** callback host your platform serves, so a customer connecting in any region works without a second app. Get the list from the platform owner or the console rather than assuming; for Unified.to the callbacks are one path per data center: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes: - HubSpot allows multiple redirect URLs per app and they stay editable — but a wrong one fails at connect time, not at save time. Re-read each one character by character against the source list. - Customers on a **custom API domain** call back to their own host. Those customers bring their own HubSpot app and credentials — do **not** add customer domains to the shared app. - `http://localhost` is accepted by HubSpot for local testing only; never leave one on a production app. ## 5. Scopes — required vs optional (this is the part that breaks) HubSpot splits an app's scopes three ways, and the split must match what your authorize URL actually sends: | App setting | HubSpot behavior | | --- | --- | | `requiredScopes` | Must be granted **and** must appear in the `scope` query parameter of every authorize URL | | `optionalScopes` | Sent in `optional_scope`; HubSpot drops any the customer's **account tier** cannot grant | | `conditionallyRequiredScopes` | Required only when included in the install URL | The rule that catches people: HubSpot forgives an optional scope the customer's tier lacks, but it does **not** forgive a scope the app never declared — an undeclared scope in `optional_scope` fails the authorize request. So declare every scope the connector can ever ask for, and keep `requiredScopes` down to the ones sent on *every* install. A multi-object connector typically sends a small fixed `scope` (enough to identify the account) and varies the rest per workspace. **Unified.to's HubSpot connector does exactly that**: `scope=oauth crm.objects.owners.read` on every authorize URL, with every per-object scope in `optional_scope`. So its app config is: - **`requiredScopes`: exactly `oauth` and `crm.objects.owners.read`.** Anything else marked required is never sent in `scope`, and HubSpot then refuses the install for every customer. - **`optionalScopes`: the full union of the per-object scopes.** As of 2026-09-18 that is: `content`, `crm.extensions_calling_transcripts.read`, `crm.lists.read`, `crm.lists.write`, `crm.objects.companies.read`, `crm.objects.companies.write`, `crm.objects.contacts.read`, `crm.objects.contacts.write`, `crm.objects.deals.read`, `crm.objects.deals.write`, `crm.pipelines.orders.read`, `crm.pipelines.orders.write`, `crm.schemas.custom.read`, `files`, `media_bridge.read`, `sales-email-read`, `scheduler.meetings.meeting-link.read`, `settings.users.teams.read`, `tickets`. That list drifts as object coverage grows — confirm the current set with the connector's owner before you submit, and add any scope the connector has started sending. Request only what the connector actually uses: over-broad scope sets are the most common reason a Marketplace listing is rejected, and reviewers read the justification. List what you chose and why in your summary so the choice can be challenged. ## 6. Capture the credentials From `hs project open` → the app → **Auth** tab (legacy app: **Development → Legacy apps → → Auth**), capture: - **Client ID** and **client secret** - **App ID** - Authorize URL `https://app.hubspot.com/oauth/authorize` (EU variant `https://app-eu1.hubspot.com/oauth/authorize` — HubSpot-side routing, not a separate app) and token URL `https://api.hubapi.com/oauth/v3/token`. Older integrations pin `…/oauth/v1/token`; confirm which one the connector uses rather than switching it as a side effect of this task. - The exact scope strings as HubSpot spells them, not as the docs prose describes them - Whether these are test or production credentials **Developer API key** — required for HubSpot's webhook-subscription endpoints, passed as `?hapikey=`. Find it under **Settings → Integrations → API key** (Super Admin only) in the account that owns the app. Only fetch it if webhooks are in scope for this request. Treat the secret as live credential material: report it once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on without belaboring it. **Rotating an existing app's secret breaks every token refresh until the new value is deployed everywhere** — never rotate without explicit human go-ahead and a cutover plan. ## 7. Install limits and Marketplace listing - Unlisted marketplace-distribution app → **25 installs**. For a multi-tenant connector that is a hard ceiling on customer connections, so raise it with the user before the app ships. - Lifting it means submitting a **Marketplace listing**: reviewed by HubSpot's Ecosystem Quality team, initial response in about 10 business days, whole process up to 60 days. - Unlisted or unverified apps also show customers an extra confirmation step at install. If asked to submit the listing, describe the platform accurately — it connects HubSpot's API on behalf of mutual customers, who authorize access themselves through OAuth — and tie each requested scope to a concrete function. **Stop and ask** before answering anything you would have to invent: security or compliance questionnaires, certifications, retention periods, subprocessors, breach procedures, customer counts, volume projections, demo videos, legal attestations, or a named responsible individual. A plausible-sounding guess about compliance is worse than an unanswered question, and it is expensive to walk back once submitted. After submitting, record the expected turnaround, the reference number, and where the decision will arrive. ## 8. Verify end-to-end A registration that half-worked is worse than one that stopped early, because the failure surfaces later as a customer who cannot connect. Prove it before closing out: 1. Install the app on the **developer test account** (app → Distribution tab → test install; tick the unverified-app confirmation). 2. Run a real connect through your platform's own flow — console "add connection", or the authorize URL your connector builds — against credentials matching the ones you just captured, and complete the callback. 3. Confirm the connection stored an access **and** a refresh token, and that one read call succeeds. Common failures and their real cause: | Symptom | Cause | | --- | --- | | `invalid scope` at authorize | A scope sent in `optional_scope` is not declared on the app | | Redirect mismatch | The callback host for that region is missing from `redirectUrls` | | Install blocked for one customer | Install cap reached, or the installing user is not a Super Admin | | Refresh fails after a change | Secret rotated without deploying the new value everywhere | ## 9. Hand off — never commit the secret - **Do not** write the client secret or developer API key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is genuinely needed (an app ID, a new callback host), keep it secret-free and say plainly what the human must set out of band. - Close with: what was created and in which environment; client ID; where the secret was delivered; authorize and token URLs; the exact required and optional scope strings; install-cap and listing status with dates; and anything left for the user to do, listed plainly. ## Stop and ask Hand back to a human rather than guessing when: the account is gated behind verification you cannot complete; the existing production app would have to change in a way that could break live connections (secret rotation, removing a redirect URL, moving a scope from optional to required); a listing form asks for compliance, legal or volume claims; or HubSpot's portal does not match the **Platform state** section above. When a portal behaves in a way the docs did not describe, say what you found rather than picking the option that lets you keep going. ## References - Developer platform overview — https://developers.hubspot.com/docs/apps/developer-platform/overview - Create an app — https://developers.hubspot.com/docs/apps/developer-platform/build-apps/create-an-app - App configuration (`app-hsmeta.json`) — https://developers.hubspot.com/docs/apps/developer-platform/build-apps/app-configuration - OAuth quickstart — https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/oauth/oauth-quickstart-guide - Account types (standard / test / sandbox) — https://developers.hubspot.com/docs/getting-started/account-types - Legacy public app creation sunset — https://developers.hubspot.com/changelog/legacy-public-app-creation-sunset - Marketplace install limits — https://developers.hubspot.com/changelog/new-marketplace-distribution-app-install-limits - Marketplace listing requirements — https://developers.hubspot.com/docs/apps/developer-platform/list-apps/listing-your-app/app-marketplace-listing-requirements - Webhooks API guide (developer API key) — https://developers.hubspot.com/docs/api-reference/latest/webhooks/guide ## icims-oauth-app URL: https://docs.unified.to/skills/icims-oauth-app/SKILL.md # iCIMS API Credentials **Read this first, because it will probably change the plan: iCIMS has no self-serve developer portal, no "create app" button, and — for the API that matters here — no OAuth consent screen at all.** Every iCIMS API credential is configured by a human at iCIMS and handed over through a partner manager, an integration specialist or a support consultant. iCIMS says so in its own Developer Acceptable Use Policy, in the one sentence that ends most of the questions this skill gets asked: > "ICIMS will be responsible for the configuration of both API credentials and user access. API credentials and user > access shall be issued to the Partner and must not be shared with any third-party without ICIMS' express consent." And on the customer side, an iCIMS staff answer on iCIMS' own public developer forum, to a customer who had a working login and expected it to work against the API: > "The response given is correct. You will need API credentials for your integration build. **That will require a > customer purchase of an integration license.**" So there are two honest routes to a working credential, and the choice is commercial before it is technical: 1. **Become an iCIMS Technology Partner** — apply, sign the Master Partner Agreement and the Technology Partner Addendum, and pay the annual program fee. As of the fee schedule effective **1 July 2026**, published openly by iCIMS: **Connect tier $7,500/yr, Premier tier $12,500/yr**. The unpaid "None" tier exists, and its *only* listed benefit is "Custom Integrations Built for Customer" — no partner designation, no persistent developer-site access, no sandbox, no integration verification, no Marketplace listing. In other words, **a repeatable integration that connects many customers' iCIMS instances is a paid tier; a one-off build for a single named customer is not** (§3). 2. **Build per customer, on that customer's own licence.** iCIMS calls this a "Customer Integration with ICIMS": built on one mutual customer's specification, "non-repeatable," and explicitly **no sandbox is provided**. Each such customer must have bought an integration licence, and each gets one integration account with its own 10,000-requests-per-day allowance (§4, §7). Do not start a registration that cannot complete. Establish which route applies (§1, §3) before anything technical. Once you hold credentials, four things cost real time, and only one of them is authentication: the **Customer ID**, which is part of every URL path and must be collected from each customer before the first call can be built (§5); the **region**, because the token host and the API host both change between US, EU and Canada and there is no universal front door (§5); the **Integration User group and Security Rules**, which decide whether a technically valid credential returns the customer's data, a subset of it, or nothing at all — with no error (§6); and the **10,000 requests per day licence limit**, which is a contract term, not a throttle (§7). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Which route** | Paid Technology Partner tier, or per-customer builds on each customer's licence? (§1, §3) | | **Partner status** | Is there a signed Master Partner Agreement and Technology Partner Addendum? Which tier? (§3) | | **Named mutual customer** | Required for the per-customer route; iCIMS states a mutual customer must be in place before development begins (§3) | | **Which auth method iCIMS has agreed to** | Basic, HMAC, or OAuth 2.0 client credentials — iCIMS configures it; do not assume (§1) | | **The customer's Customer ID** | Goes in the URL path of every call. Communicated "at the time of the live integration project" (§5) | | **Region** | US, EU or Canada — different token host *and* different API host (§5) | | **Environment** | Temporary Sandbox, Premium Site, or production. Separate credentials and separate hosts (§3, §5) | | **Which profile types the customer has enabled** | iCIMS: "not all clients have all profile types enabled" (§6) | | **The integration account's group and security profile** | Search needs the Integration User group; field visibility follows Security Rules (§6) | | **Static egress IPs** | iCIMS allowlists partner IPs at sandbox setup for some integration types (§7) | | **Expected daily call volume** | The licensed limit is per integration, per day, and overage is a purchase (§7) | | **Whether the integration uses AI/ML** | Contractually must be disclosed to iCIMS and to each customer *before* deployment (§9) | ## Quick Start 1. Establish which **route** is legitimately available, and whether the paid partner tier is required for what is actually being built (§1, §3). Every wasted month starts here. 2. Confirm whether credentials already exist worth reusing; re-issuing is a human process with a real wait (§2). 3. If partner route: apply, sign the agreements, pay the tier fee, get the Temporary Sandbox (§3). 4. Ask iCIMS for the credentials **by exact name**, and for the auth method in writing (§4). 5. Collect the **Customer ID** and the **region** per customer before building a single URL (§5). 6. Stop looking for consent scopes. Pin down the **Integration User group** and the **Security Rules** on the integration account instead (§6). 7. Check the **daily call limit**, the search cache, the retry rule and the no-browser-calls rule before promising a launch date (§7). 8. Verify against a **second** customer instance, and against a deliberately under-privileged account (§8). 9. Hand the secret to a human, never to source control (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **There is no self-serve developer portal.** `developer.icims.com` redirects to the iCIMS Developer Community, whose home page (last modified **Sep/17/2026**) says: "New users may request access to ICIMS Developer Community by submitting a New User Request form." Most of the technical reference sits behind that login. - **The authoritative authentication page is login-walled.** `/platform/services/connecting-and-authentication` returns **HTTP 403** to an anonymous reader. The 2023-era deep link to the OAuth page (`developer.icims.com/authenticating-api-calls-0auth20`, still quoted in forum threads) now redirects to the community home page rather than to an OAuth page. **Nothing in this skill about token lifetimes, scope strings or the full OAuth contract should be taken as verified** — see "What could not be verified publicly". - **Credentials are configured by iCIMS, not by you.** Developer Acceptable Use Policy, last modified **28 October 2025**, quoted above. - **The partner programme is paid and tiered, and the figures are public.** Fee Schedule and Technology Partner Benefits & Requirements, both effective **01 July 2026**: Connect **$7,500**/yr, Premier **$12,500**/yr, unpaid "None" tier limited to custom per-customer builds. Premier additionally requires "Turnkey or 2+ Verified Integrations" and can buy a Premium Site for **+$10,000**/yr. - **The API is licensed per customer, per integration.** The Integration Terms page (last modified **Nov/06/2023**) describes what a customer's integration licence includes: "One integration account that can be secured with BASIC or HMAC authentication" and "Up to 10,000 web services requests per day." Note what that sentence does **not** mention: OAuth. - **OAuth 2.0 nevertheless exists and is in use.** `login.icims.com` publishes a live OpenID configuration (fetched anonymously on 2026-09-20) with `token_endpoint: https://login.icims.com/oauth/token` and `client_credentials` among `grant_types_supported`; `login.icims.eu` and `login.icims.ca` publish their own. A public forum thread shows the exact request — form-encoded `audience=https://api.icims.com/v1/`, `grant_type=client_credentials`, `client_id`, `client_secret` — and an iCIMS staff reply: "Client Secret is the one provided to you by your engaged support consultant." **It is a machine-to-machine grant. There is no user consent screen and no authorization code.** - **A live, unauthenticated API call advertises only two schemes.** On 2026-09-20, `GET https://api.icims.com/customers/1769/jobs/1707` returned **401** with `WWW-Authenticate: Basic realm="…", x-icims-v1-hmac-sha256 realm="…"` and the body `{"errors":[{"errorMessage":"You must provide authentication details in the Authorization Header using either Basic HTTP Authentication or x-icims-v1-hmac-sha256 HTTP Authentication.","errorCode":6}]}`. **Bearer is not advertised.** Whether a given customer's instance accepts a bearer token is a per-customer configuration question for iCIMS, not something to assume from this header or from anyone's working example. - **Field and record visibility follows the platform, not the API.** Profiles API reference (public, last modified **Jun/28/2024**): "if a user does not have access to a field in the Platform, they will not be able to return that field via ICIMS Profiles API. These permissions are based on the credentials provided in the Authorization header." - **Search requires a specific group.** Search API reference (public, last modified **Jul/24/2025**): "In order to utilize the Search API, a user must be part of the Integration User group." Same page: results are capped at 1,000 per call, and search results are **cached for 15 minutes by default** unless you pass `staleness=0`. - **Browser and desktop calls are contractually prohibited.** AUP: "ICIMS endpoints cannot be accessed from mobile applications, Developer workstations or web browsers. This is a design requirement that you must address in any solution being developed in the ICIMS Marketplace to avoid rejection." - **The Developer Terms of Use were last modified 30 June 2026** and are materially heavier than most vendors': SOC 2 Type II or ISO 27001 (or equivalent) with the report produced on request, iCIMS-initiated security assessments and penetration testing, 24-hour breach notification to `generalcounsel@icims.com`, minimum-necessary data access, no onward disclosure of customer data, and **no training or fine-tuning any model on customer data without express prior written consent**. - **Rate-limit *fees* are real.** The public FAQ: "Yes, ICIMS does institute limitations, and integration fees." If the developer community, the partner legal page or the fee schedule do not look like this, stop and report what you actually see. ## 1. Which credential, and who is allowed to hold it A reader arriving here is usually holding the wrong thing, or expecting a flow that does not exist. | Surface | Credential | Hosts | Who gets it | | --- | --- | --- | --- | | **REST API — HTTP Basic** | API username + password on an *integration account* configured by iCIMS | `api.icims.com` / `api-eu.icims.com` / `api-ca.icims.com`, always under `/customers/{customerId}/…` | Issued by iCIMS to the partner or to the customer's developer, on a purchased integration licence | | **REST API — HMAC** | `x-icims-v1-hmac-sha256` signing key | Same | Same. Named in the Integration Terms alongside Basic as what an integration licence includes | | **REST API — OAuth 2.0, client credentials** | `client_id` + `client_secret`, exchanged with an `audience` parameter for a bearer token | Token at `login.icims.com` / `login.icims.eu` / `login.icims.ca`; API on the matching `api*` host | Issued by an iCIMS support consultant / integration specialist. **No consent screen, no authorization code, no redirect** | | **Prime / connector-framework endpoints** (background screening and similar "Prime" integrations) | Signed **JWT** with `sub`, `iss`, `iat`, `exp`, shared signing key, plus **IP allowlisting** | Per-integration endpoints, allocated with the sandbox | Partners on a Prime integration, from the ISV enablement team when the sandbox is allocated | | **Developer Community site login** | Personal named account, requested through the New User Request form | `developer.icims.com` | Anyone iCIMS approves. **This is documentation access, not API access** | Consequences worth saying out loud before anyone starts work: - **"iCIMS credentials" is ambiguous and the ambiguity costs a week.** A customer contact will often send their own Talent Platform login. It will not work: the public forum contains this exact mistake repeatedly, with the 401 `"Invalid Username or Password credentials provided."` and the staff answer that an integration licence is required. Ask by exact name for the **API username and password** (or the **OAuth client ID and client secret**) **and the Customer ID**, separately. - **There is no authorization-code flow to build.** Do not design a "Connect with iCIMS" consent screen. The customer does not click through anything on iCIMS' side; they (or iCIMS) hand you a credential out of band, and you collect their Customer ID in your own UI. - **The credential is bound to a platform *user account* and therefore to that account's permissions.** There is no app identity with its own permission set. See §6 — this is the single biggest source of "it works for us and returns nothing for them." - **Sandbox and production are different instances with different hosts and different credentials.** A sandbox credential says nothing about production. - **Do not build the browser-side version.** The AUP forbids calling iCIMS endpoints from browsers, mobile apps or developer workstations; it is a stated rejection criterion at validation. > **Product fact — dated.** As of **2026-09-20**, this platform's iCIMS connector supports **two** authentication > modes and ships **no shared credentials** — every workspace supplies its own, which in practice means its own > relationship with iCIMS. The first mode collects **three** values from the customer, in this order — **API > Username, API Password, Customer ID** — and sends the first two as **HTTP Basic** on every request. The second > mode is labelled OAuth 2.0 but is a **two-legged client-credentials exchange**: it POSTs form-encoded > `grant_type=client_credentials` with the workspace's client ID and client secret **as request parameters** (not an > HTTP Basic header) plus the region's `audience` value to the region's token endpoint, and then sends the returned > token as `Authorization: Bearer …`. It records **four** regional profiles: **United States** (`api.icims.com`, > token `login.icims.com`, audience `https://api.icims.com/v1/`), **Europe** (`api-eu.icims.com`, > `login.icims.eu`), **Canada** (`api-ca.icims.com`, `login.icims.ca`) and **Sandbox** (`api.dev.icims.com`, > `login.dev.icims.com`). It records a per-object permission map using strings of the shape > `irecruiter::` over `people`, `jobs`, `applicantworkflows` and `onboardworkflows`, and its > customer-facing note says the three values come from "your iCIMS integration specialist" and that API access must > be requested from the customer's iCIMS rep beforehand. Its reads are built on the **Search API** — one search for > IDs, then a fetch per ID — with a page size of 100. **Confirm all of this with the connector's owner before acting > on it** — connector configuration changes independently of this skill. ## 2. Reuse the existing credentials, or request new ones There is no console in which to rotate anything, so every change is a human request with a real wait. Reuse what exists for: adding a customer, changing the permissions on an existing integration account, diagnosing a failure, or raising the call limit — none of those need a new credential. Request a **new** credential only when the user explicitly wants one: a new environment (sandbox and production are separate by design), a replacement for a compromised secret, or a genuinely separate integration. Two iCIMS-specific wrinkles pull in opposite directions and are worth stating before anyone decides: - **Each customer's integration licence carries its own 10,000-call daily allowance** (§7). Volume does not pool across customers, and it does not pool across a partner's whole estate the way a single shared client ID would. - **Fees and credentials are linked contractually.** Under the Technology Partner Addendum, invoices unpaid 30 days past due let iCIMS "terminate the Integration for any and all integrated Subscribers." A lapsed partner fee is not a billing footnote; it is an outage for every customer at once. ## 3. The gate: Technology Partner Program, tiers and sandboxes This section applies when the **platform** wants a repeatable integration across many customers. **Entry point:** apply through iCIMS (`https://community.icims.com/s/apply`, linked from the partner pages), then sign the **Master Partner Agreement** plus the **Technology Partner Addendum**. Both, plus the Fee Schedule and the tier table, are published on `https://www.icims.com/partner/gc/` — read them before quoting anything to the user. **The tiers, effective 01 July 2026** (from iCIMS' published Benefits & Requirements table): | | None | Connect | Premier | | --- | --- | --- | --- | | Annual program fee | n/a | **$7,500** | **$12,500** | | Custom integrations built for one customer | yes | yes | yes | | Partner designation, persistent developer-site access, enablement team | — | yes | yes | | **Partner Integration Verification** | — | yes | yes | | **Temporary Sandbox** | — | yes | yes | | Marketplace listing with lead form | — | standard | prioritized | | Eligible to build a Rapid Activation integration | — | — | yes | | Eligible to purchase a Premium Site | — | — | **+$10,000/yr** | | Requirements | — | apply online, sign agreements | + "Turnkey or 2+ Verified Integrations" | **The build-and-approve sequence**, as iCIMS publishes it: review the developer resources and the existing Marketplace integrations → design against the documented process and standard fields → **reach out to your iCIMS Partner Manager to schedule a call with the iCIMS Developer Support Team**, who review the business use case → **once approved**, iCIMS issues validation requirements and sets up a sandbox → build and test → submit for validation. **Validation** is a real review, not a form: an end-to-end **video** of the working integration recorded in the iCIMS-provided sandbox, plus screenshots proving 400-with-a-user-friendly-message handling for a **duplicate request** and for a **missing required field**, plus a written statement of every field read and written by profile type and access level, plus the information a customer needs to enable the integration (authentication method, IPs to allowlist, package IDs, optional features, partner credentials, Customer ID). iCIMS adds the rule that matters most to a multi-tenant platform: **"values that will differ across client platforms shouldn't be hardcoded."** **Revalidation** can be demanded later — within 90 days of notice, or 1 year for a newly mandatory feature — and failing it can mean removal from the Marketplace and calls "suspended at ICIMS' sole discretion." **Sandboxes**, from the Addendum and the AUP: - **Temporary Sandbox** — free with Connect/Premier, **12 weeks**, extensions at iCIMS' discretion, up to **5 users** from your organisation, provisioned "generally within five (5) business days," no live data, **no iCIMS support**, and explicitly **not** for demos, workflow exploration or screenshots. iCIMS repeats the point on the public getting-started page: "The ICIMS Sandbox is prohibited for customer integration testing and customer demo usage, and is subject for partner removal." - **Premium Site** — $10,000/yr, Premier only, 3 technical + 5 demo users, ~10 business days, dummy data only, no customisation, no system-administrator access, and only iCIMS-validated integrations may be demoed on it. Buying one **replaces** the Temporary Sandbox rather than adding to it. - **Per-customer route: no sandbox at all.** iCIMS states that customer-specific integrations are built directly on each customer platform. What this section deliberately does not tell you, because iCIMS does not publish it: how long approval takes, what the security questionnaire contains, whether a unified-API layer is accepted as a Technology Partner, and whether any given customer's instance will be configured for OAuth. **All four are questions for iCIMS, and three of them are business decisions.** Do not fill in security, compliance or commercial claims on the user's behalf — collect the questions and hand them back. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an ordered path with the literal values to paste, or a ready-to-send message to the partner manager or to `developerhelp@icims.com`, then continue once they report back. ## 4. Asking iCIMS for the credentials There is no form and no self-service. Who you ask depends on the route: | Situation | Ask | | --- | --- | | Partner, integration already approved | Your **iCIMS Partner Manager** / the **Developer Support Team** | | Partner, technical problem with a sandbox or credential | `developerhelp@icims.com` (the Technology Partner help desk named in the Addendum) | | Building for one customer | The **iCIMS resource already engaged with that customer** — the partner application page tells developers without an iCIMS contact to get it from their customer contact | | Documentation access only | The **New User Request form** on the developer community | **Put all of this in the first message**, because each round trip is business days: - That you are requesting **REST API credentials for an integration**, and for which **environment** (sandbox or production) — one request per environment. - **Which authentication method** is being configured: Basic, HMAC or OAuth 2.0 client credentials. Ask iCIMS to state it in writing; do not infer it from a working example on a different customer. - The **integration name and purpose**, and the named customer if this is a per-customer build. - The **Customer ID(s)** the credential must be entitled to reach, and the **region** (§5). - The **egress IP addresses** to allowlist, if the integration type requires it (§7). - The **group and permissions** the integration account needs — say "Integration User group" explicitly, and name the profile types and fields the integration reads and writes (§6). This is the step everyone skips and everyone pays for later. **What comes back.** iCIMS' public FAQ: "To make a call you must first establish your authentication method with ICIMS. ICIMS should then communicate back user credentials to allow inbound access to make calls." Expect the values by email or through a support case, expect no console to re-read them in, and store them immediately. Rotation is another request to the same human. ## 5. The Customer ID, the region, and the redirect URI that does not exist ### There is no redirect URI to register with iCIMS The documented iCIMS OAuth grant is **client credentials**. No browser leaves your platform, no consent screen is shown, and **no callback URL is registered with iCIMS**. If someone asks which redirect URIs to send to iCIMS, the answer is none — and if an iCIMS contact asks you for one, stop and find out which product they actually mean (the Prime/JWT and webhook-style integrations have their own endpoint and IP-allowlist requirements, §7). For completeness, and because they get asked for in the same conversation, this platform's own callback hosts — one per data centre — are: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` > **Product fact — dated.** As of **2026-09-20**, the connector's "connect" step for iCIMS never sends the customer > to an iCIMS page. It collects the **Customer ID** in the platform's own UI, immediately returns to the platform's > own callback carrying that value where an authorization code would normally go, and then performs the > client-credentials token call server-side. The token it stores is the access token, and its "refresh" simply > re-runs the same client-credentials exchange — there is no refresh token in this flow. Consequently the four > callback hosts above are internal to the platform and are **not** registered anywhere at iCIMS. **Confirm this > with the connector's owner before acting on it.** ### The Customer ID **Every iCIMS API path contains the customer's numeric Customer ID**, immediately after `/customers/`. iCIMS' own public example: `https://api.icims.com/customers/1769/jobs/1707/?fields=bgcpackagetype`. You cannot build a single URL without it, which is why it must be collected on the connect form rather than discovered from a token. - **Where it comes from:** iCIMS' FAQ says "Client ID's will be communicated at the time of the live integration project. This information is also included in the payload of the outbound ICIMS messages." In practice the customer or the iCIMS implementation contact tells you; it is also visible in the customer's own platform URLs. - **Never hardcode it, and never hardcode anything else that varies per customer.** iCIMS makes this an explicit validation requirement (§3). - **A wrong Customer ID does not fail cleanly.** The unauthenticated probe in Platform state shows the API answering for an arbitrary customer number with a generic 401 rather than "no such customer", so a typo looks like an auth problem, not a routing one. ### The region Three production regions, each with its **own token host and its own API host**, and a separate sandbox estate: | Region | API host | Token host | `audience` | | --- | --- | --- | --- | | United States | `api.icims.com` | `login.icims.com` | `https://api.icims.com/v1/` | | Europe | `api-eu.icims.com` | `login.icims.eu` | the EU equivalent — confirm with iCIMS | | Canada | `api-ca.icims.com` | `login.icims.ca` | the CA equivalent — confirm with iCIMS | | Sandbox | issued with the sandbox | issued with the sandbox | issued with the sandbox | All three `login.*` hosts published a live OpenID configuration when probed anonymously on 2026-09-20. The API hosts resolve and answer. **The `audience` values for EU and CA are not published on any public iCIMS page** — the only one visible anywhere public is the US value, in a forum post. Get the others from iCIMS in writing rather than pattern-matching, and treat a pattern-matched guess as the most likely cause of an `access_denied` you cannot explain (§8). Sandbox URLs are issued with the sandbox. A public forum exchange shows exactly this going wrong — a developer with a valid sandbox, Customer ID, client ID and secret getting `access_denied` because they were pointing at the wrong host, and iCIMS replying "You should have been provided a sandbox URL as well." ## 6. There are no consent scopes — groups, Security Rules and the silent failure **This is the section to read twice.** iCIMS has no consent screen, no scope checklist a customer approves, and no token response that tells you what you may do. What the integration can see and change is decided entirely inside the customer's iCIMS configuration, by the permissions on the account behind your credential. iCIMS states the model plainly in its public Profiles API reference: > "The interaction of Profiles through the Profiles API will generally follow the same conventions as if a user were > to access the information through the ICIMS Talent Platform. … if a user does not have access to a field in the > Platform, they will not be able to return that field via ICIMS Profiles API. **These permissions are based on the > credentials provided in the Authorization header.**" **For a field to come back on a GET, iCIMS requires all five of these to be true:** 1. It **has a value**. 2. It is **not hidden by attribute configuration settings**. 3. It is **not hidden by Security Rules**. 4. It is **not hidden by Search Locks**. 5. It is **not hidden by Profile layout configuration** — the tab the field sits on must not be hidden. **For a POST or PATCH**, the same list applies, plus the field must pass field-specific validation and must not be **locked** by attribute configuration. iCIMS notes two consequences that look like bugs and are not: a profile locked in attribute configuration can be **created but not updated**; and POST/PATCH validate *all* required fields for the profile's folder and roles, so an update can fail because of a field you never touched. **Why this is the classic ATS silent failure.** A missing permission does not produce a 403 with an explanation. It produces a field that simply is not in the JSON, or a profile type that returns nothing, or a search that finds no records. Any mapping that treats "absent" as "the customer has no data" will confidently report an empty ATS. **Three specific traps:** - **The Integration User group gates search entirely.** iCIMS: "In order to utilize the Search API, a user must be part of the Integration User group." A connector that lists records by searching first — most do — returns nothing at all for a customer whose integration account was created outside that group. Ask for it by name when the account is configured (§4). - **Permission problems can surface as syntax errors.** One documented search error is "The following filter is either not valid **or hidden**: [filter]" — the same message for a typo and for a field the account may not see. - **Not every customer has every profile type.** iCIMS: "Note that not all clients have all profile types enabled." The documented set is `people`, `jobs`, `companies`, `newhirecategories`, `talentpools`, `rooms`, `connectevents`. An integration that assumes a profile type exists will fail on a subset of customers for reasons no credential change can fix. **Practical consequences.** Ask each customer's iCIMS administrator, in writing, for an integration account in the Integration User group with visibility of the specific profile types and fields you need, and make that list part of your onboarding documentation — it is the same list iCIMS makes you produce at validation anyway (§3). When a customer reports missing data, your first question is about Security Rules, Search Locks and tab visibility, not about the credential. Because there are no scopes, **adding a feature never requires re-consent** — it requires the customer's administrator to widen the integration account's permissions. Own that conversation. ## 7. Limits, caching, retries, and what the contract forbids **The daily limit is a licence term.** From the Integration Terms page, for what an integration licence includes: | Limit | Value | | --- | --- | | Web service requests per day ("Allowable Daily Limit") | **10,000**, per integration | | Burst tolerance | Up to **5×** the daily limit on a given day without being denied | | Enforcement | iCIMS averages your **previous 90 days**; exceed the average and you must buy an increase within 30 days or be hard-limited | | Call Limit Increase | Purchased in blocks of **10,000** requests/day; iCIMS reserves the right to cap how many a single integration may buy | | Search results per call | **1,000**, paged by filtering on the last returned ID | | Search cache | **15 minutes by default**; `staleness=0` forces a live search, and the cache is shared across users running the same search | And the parts that change the arithmetic: - **Exceeding the limit halts the integration.** The AUP: "Exceeding the daily limit may halt functionality of that Integration until the next calculation period, resulting in a service interruption of the Integration." Not a throttle — a stop, until tomorrow. - **The limit is per customer's integration, not per partner.** That is gentler than a shared per-client-ID budget, but it means capacity is a *customer-by-customer* conversation and an ID-then-fetch read pattern (one search plus one call per record) burns the allowance far faster than it looks. Count calls per sync before promising a frequency. - **Retries must use exponential back-off.** The AUP permits retry logic "provided that Developer only uses retry logic that leverages an exponential back-off approach." A fixed-interval retry loop is a policy violation, not just bad manners. - **Search is not a real-time API.** iCIMS: "At this time, ICIMS does not support real-time usage of the Search API. The Search API is currently optimized for synching data between two different systems in the background." If your product promises live search over iCIMS, that promise is at odds with the documentation. - **Mind the 15-minute cache when polling for changes.** A sync that filters on an updated-date and does not set `staleness` can miss records written in the last quarter of an hour and then never revisit them, because the next poll's window has already moved past. - **No browser, mobile or workstation calls** (AUP, §1). - **iCIMS may change the API with ~30 days' notice**, and the Addendum makes adapting your problem, at your expense, with a 30-day remediation window before suspension. iCIMS may also **disable an integration immediately, without notice**, on suspicion of fraud, abuse or a security threat. - **IP allowlisting exists for some integration types**, applied at sandbox setup from the list your partner contact supplies, with additions by request. If your egress is autoscaled or serverless with no stable addresses, surface that before launch — it is an infrastructure decision, not a support ticket. - **Listing** on the iCIMS Marketplace requires an approved integration, the tier that includes listing, and iCIMS' sole-discretion approval. You may not describe the integration as complete, certified or validated — or hold yourself out as an iCIMS partner — before iCIMS says so in writing. ## 8. Verify end-to-end Do not stop at "the token came back," and do not stop at "one customer works." 1. Confirm **which auth method** the customer's instance is actually configured for. If you are using a bearer token, confirm with iCIMS in writing that it is enabled for that Customer ID — the live 401 advertises only Basic and HMAC (Platform state). 2. Make one real read against a **second** customer instance, ideally in a **different region**, using hosts and the `audience` resolved from configuration rather than hardcoded. 3. Run a **search**, not just a fetch by ID. Search is the path that requires the Integration User group, so it is the test that catches the most common permission failure (§6). 4. Repeat step 3 with `staleness=0` and compare against the cached result. If your sync depends on freshness, prove it now rather than explaining a 15-minute data gap later. 5. Read a profile you know has values in fields you care about, and **diff the returned field set against what the customer sees in their platform**. Missing fields here mean Security Rules, Search Locks or a hidden tab — not a bug in your mapper. 6. Repeat step 5 as a **deliberately under-privileged** account and confirm your code reports a permission problem rather than reporting "no data." 7. Exercise a create and an update, and confirm you handle the two documented traps: a **locked profile** that accepts POST but refuses PATCH, and a PATCH that fails on required fields you did not send (§6). 8. Count the calls a full sync makes for a realistic customer and compare against **10,000/day** (§7). 9. Confirm your retry path backs off exponentially and has no fixed-interval loop. | Symptom | Cause | | --- | --- | | `401` with `WWW-Authenticate: Basic …, x-icims-v1-hmac-sha256` | No credentials sent, or bearer sent to an instance not configured for OAuth (§1, Platform state) | | `401 "Invalid Username or Password credentials provided." (errorCode 6)` | A Talent Platform user login used as an API credential; no integration licence / integration account (§1, §4) | | `401 {"error":"access_denied","error_description":"Unauthorized"}` on the token call | Wrong client ID/secret, wrong `audience`, wrong regional token host, or credentials not yet activated by iCIMS (§5) | | Token works in sandbox, `access_denied` in production (or vice versa) | Separate instances, separate credentials, separate hosts; the sandbox URL is issued with the sandbox (§3, §5) | | Works for US customers, fails for EU/Canada | Hardcoded token host, API host or `audience` instead of per-customer configuration (§5) | | 404s on paths that should exist | Wrong or missing Customer ID in the path (§5) | | **Search returns nothing, no error** | Integration account is not in the **Integration User group** (§6) | | **A field the customer can see is simply absent from the JSON** | Hidden by Security Rules, Search Locks, attribute configuration or a hidden profile tab — iCIMS returns absence, not an error (§6) | | "The following filter is either not valid or hidden: [filter]" | Either a genuinely wrong filter name, or a field this account may not see (§6) | | A whole profile type returns nothing for one customer | That customer does not have that profile type enabled (§6) | | POST succeeds, PATCH refuses on the same profile | Profile is locked in attribute configuration — documented behaviour, mirrors the platform (§6) | | PATCH fails complaining about fields you never sent | Required-field validation runs across the profile's folder and roles (§6) | | Sync misses records written minutes ago | 15-minute default search cache; pass `staleness=0` or widen the window (§7) | | Integration stops for the rest of the day | Allowable Daily Limit hit; functionality halts until the next calculation period (§7) | | Calls fine for 90 days, then throttled or invoiced | Rolling 90-day average exceeded the Allowable Daily Limit (§7) | | Requests rejected from a new region or new worker pool | Partner egress IPs allowlisted at sandbox/integration setup (§7) | | Integration disabled with no warning | Emergency disabling on suspicion of fraud, abuse or security threat (§7) | | Customer cannot get credentials at all | No integration licence purchased, or no engaged iCIMS resource (§1, §4) | ## 9. Hand off — never commit the secret - **Do not** write an API password, client secret, HMAC key or JWT signing key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. iCIMS makes this contractual: under the Developer Terms of Use, credentials are Confidential Information, "Developer's account is personal to Developer and may not be shared with, transferred to, or used by any other person or entity," and under the AUP credentials "must not be shared with any third-party without ICIMS' express consent." - **A Customer ID is not a secret, but it is customer-identifying.** Keep it out of public issues and marketing examples anyway. - If a code change is needed (a regional host, the Customer ID plumbing, retry behaviour), keep it secret-free and say plainly what the human must set out of band. - Report a secret once so it can be pasted into the secret store, say clearly that it is now in the transcript and can be rotated by asking iCIMS, then move on. - **Say the compliance obligations out loud**, because they are easy to breach by accident and they are in the signed agreements: 24-hour breach notification to iCIMS; SOC 2 Type II or ISO 27001 evidence on request; iCIMS' right to run security assessments and penetration tests; minimum-necessary data access; no onward disclosure of customer data; advance written notice to each customer that their data leaves iCIMS; and **no model training or fine-tuning on customer data — even de-identified or aggregated — without that customer's express prior written permission, which iCIMS can demand evidence of.** If the platform exposes iCIMS data to an AI feature, the Technology Partner Addendum also requires disclosing that in writing to iCIMS and to each customer **before deployment**. - Close with: which route you ended on and whether iCIMS has agreed in writing; the tier and its fee, if any; which auth method each customer is configured for; the Customer IDs and regions in play; where the secret was delivered; the permissions the customer's administrator must grant, in words they can act on (Integration User group, profile types, fields); the daily call budget against your expected volume; and whatever is still waiting on iCIMS. ## Stop and ask Hand back to a human rather than guessing when: **the platform is a connector, middleware or unified-API layer with no signed iCIMS partner agreement** and the ask is a repeatable multi-customer integration — say in the first reply that the repeatable route is a paid tier and the free tier covers only custom per-customer builds; the partner agreement, fee, security assessment or validation engagement needs business, security or compliance claims; a customer cannot produce an integration licence; iCIMS has not confirmed **in writing** which authentication method a customer's instance is configured for, or the `audience` value for a non-US region; a customer's data is missing and the fix requires changing their Security Rules, Search Locks or account group (that is their administrator's call, not yours); expected volume approaches 10,000 calls/day for any customer; the integration will expose iCIMS data to an AI or ML feature, or train on it; egress IP allowlisting requires decisions about static addresses; or the developer community, the partner legal page and the fee schedule no longer match the Platform state section above. ## What could not be verified publicly iCIMS' technical reference is largely behind a login, and this skill was written without one. Everything above came from iCIMS-owned pages that answer to an anonymous reader, from iCIMS' published partner legal documents, and from direct anonymous probes of iCIMS hosts on 2026-09-20. The following are **not** publicly verifiable and are stated here as gaps rather than filled in: - **The authoritative authentication page.** `https://developer-community.icims.com/platform/services/connecting-and-authentication` returns **HTTP 403** to anonymous readers — *login-walled*. So does the Applications index (`/applications/icims-applicant-tracking`) and most of the product-specific documentation. The older OAuth deep link `https://developer.icims.com/authenticating-api-calls-0auth20`, quoted in a 2023 forum thread, now redirects to the community home page instead of to that content. - **Access token lifetime and the exact token response shape.** Not on any public iCIMS page. Drive re-authentication off the response and the 401; do not hardcode a TTL from a blog post. - **Whether OAuth is available to every customer, or only on certain products, contracts or newer instances.** The Integration Terms page names only BASIC and HMAC as what an integration licence includes, and the live 401 advertises only those two — yet OAuth demonstrably exists. **Confirm per customer with iCIMS.** - **The `audience` values for the EU and Canada regions**, and the sandbox token host's configuration (`login.dev.icims.com` returned 403 to an anonymous metadata request). - **Whether iCIMS scopes an OAuth client to specific Customer IDs, and how a scope string is spelled.** The `irecruiter::` shape appears in this platform's connector configuration; it is **not** documented on any publicly reachable iCIMS page. Treat it as unverified. - **Any per-second or per-minute rate limit.** Only the 10,000/day licence limit is public. - **How long partner approval, credential issuance or validation actually take**, and what the security questionnaire contains. - **Whether a unified-API or middleware platform is accepted as an iCIMS Technology Partner at all.** iCIMS publishes no prohibition — unlike some ATS vendors — but it also reserves sole discretion to reject any integration, including one "competitive with and/or that offers overlapping capabilities with the ICIMS Subscription." That is a commercial conversation to have with iCIMS in writing, early. Two iCIMS legal PDFs cited here (the Fee Schedule and the Technology Partner Benefits & Requirements) are published openly on iCIMS' own partner legal page while carrying a "Confidential Business Information" footer. They are quoted as published, and both should be re-fetched rather than trusted from this page — iCIMS may change the fees with 60 days' notice. ## References Every URL below returned HTTP 200 to an anonymous reader on 2026-09-20. - iCIMS Developer Resources home (access is by request; New User Request form) — https://developer.icims.com/ - iCIMS Developer Community — https://developer-community.icims.com/ - Getting Started: Integrating with ICIMS (partner vs customer integration, approval sequence, validation requirements, sandbox restrictions, "values that will differ across client platforms shouldn't be hardcoded") — https://developer-community.icims.com/getting-started/integrating-icims - Partner Application Process (apply to be a partner; User Request Form for customer-behalf developers) — https://developer-community.icims.com/getting-started/partner-application-process - Developer FAQ (rate limitations and integration fees; Customer IDs communicated at project time; the `/customers/{id}/…` example; `X-HTTP-Method-Override` for PATCH) — https://developer-community.icims.com/faq - Integration Terms (BASIC or HMAC on one integration account; 10,000 requests/day; 5× burst; 90-day averaging; Call Limit Increases) — https://developer-community.icims.com/integration-licenses - Developer Terms of Use, last modified 30 June 2026 (credentials as Confidential Information; no account sharing; SOC 2/ISO 27001; security assessments; 24-hour breach notice; minimum-necessary data; AI/ML training restrictions) — https://developer-community.icims.com/terms-use - Developer Acceptable Use Policy, last modified 28 October 2025 ("ICIMS will be responsible for the configuration of both API credentials and user access"; exponential back-off; no browser/mobile/workstation calls; Temporary Sandbox and Premium Site limits) — https://developer-community.icims.com/acceptable-use-policy - Profiles API reference (permission model, the five GET visibility conditions, POST/PATCH rules, profile types, locked profiles, role validation) — https://developer-community.icims.com/applications/applicant-tracking/profiles-api - Search API reference (Integration User group requirement; 1,000 results per call; paging; `staleness` and the 15-minute default cache; not for real-time use; error messages) — https://developer-community.icims.com/applications/applicant-tracking/search-api - Developer forum — https://developer-community.icims.com/forum - Forum: "Authentication" (iCIMS staff: API credentials require a customer purchase of an integration license; Prime JWT; IP allowlisting at sandbox setup) — https://developer-community.icims.com/forum/authentication - Forum: "OAuth JWT Not WOrking" (the exact `login.icims.com/oauth/token` client-credentials request with `audience`; iCIMS staff: the client secret comes from your engaged support consultant) — https://developer-community.icims.com/forum/oauth-jwt-not-working - Forum: "Unable to authenticate with OAuth 2.0" (`access_denied` caused by the wrong sandbox URL) — https://developer-community.icims.com/forum/unable-authenticate-oauth-20 - Developer Community user request form — https://developer-community.icims.com/user-request-form - Developer Community release notes — https://developer-community.icims.com/release-notes - Developer Community contact (`developerhelp@icims.com`) — https://developer-community.icims.com/contact - iCIMS partner legal documents (Master Partner Agreement, Technology Partner Addendum, Fee Schedule, tier tables) — https://www.icims.com/partner/gc/ - Fee Schedule, effective 01 July 2026 (annual program fee by tier; Demo Site $10,000/yr) — https://cdn31.icims.com/wp-content/uploads/2026/07/01133141/Fee_Schedule_01JULY2026.pdf - Technology Partner Benefits & Requirements, effective 01 July 2026 (None / Connect $7,500 / Premier $12,500; what each tier includes) — https://cdn31.icims.com/wp-content/uploads/2026/07/10115540/ICIMS-Technology-Partner-Benefits-and-Requirements-01July2026.pdf - Technology Partner Program Addendum, 01 July 2026 (integration approval, 12-week Temporary Sandbox, Premium Site, emergency disabling, security audits, AI/ML restrictions, fee non-payment terminating integrations) — https://cdn31.icims.com/wp-content/uploads/2026/07/03162830/Technology-Partner-Addendum-01JULY2026.pdf - Master Partner Agreement, 01 July 2026 — https://cdn31.icims.com/wp-content/uploads/2026/07/01112408/Master-Partner-Agreement-01JULY2026.pdf - iCIMS general legal documents (Subscriber Data Security Addendum, Support & Maintenance Policy) — https://www.icims.com/gc/ - iCIMS Partner Experience — https://www.icims.com/community/partner-experience/ - Become an iCIMS Partner (technology vs advisory partners) — https://www.icims.com/community/partner-experience/become-a-partner/ - iCIMS partner application — https://community.icims.com/s/apply - iCIMS Customer Community — https://community.icims.com/s/ - iCIMS Marketplace — https://marketplace.icims.com/ - US OpenID configuration (token endpoint and supported grants, probed anonymously) — https://login.icims.com/.well-known/openid-configuration - EU OpenID configuration — https://login.icims.eu/.well-known/openid-configuration - Canada OpenID configuration — https://login.icims.ca/.well-known/openid-configuration ## instagram-oauth-app URL: https://docs.unified.to/skills/instagram-oauth-app/SKILL.md # Instagram (Meta) OAuth2 App Registration Get a working Instagram OAuth2 client — a Meta app with an Instagram use case, redirect URIs, `instagram_business_*` scopes, and an **Instagram App ID and Instagram App Secret** — for a platform that connects many customers' Instagram accounts. The registration mechanics are not here; they are the same for every Meta product and live in the base skill. Two things are specific to Instagram, and both are decided before you write a line of code. **First, the product split.** Meta ships two mutually-incompatible Instagram configurations with different hosts, different permission strings and different prerequisites, and almost every write-up on the internet describes a third one that Meta shut off in 2024. Pick wrong and you rebuild the connector. **Second, the credentials are not the ones you expect.** A Meta app has an App ID and App Secret, and Instagram Login uses a *different*, separately-displayed Instagram App ID and Instagram App Secret. Handing over the wrong pair produces an authorization error that reads like a redirect-URI problem. ## Built on: `meta-graph-app` **Read `meta-graph-app` first, in full, then come back here.** It owns: - **The Meta developer account, the business portfolio, and the app** — the creation wizard, and the fact that neither the app type nor any use case you add can be undone afterwards. - **Redirect-URI mechanics** — exact matching, HTTPS only, the dashboard's silently appended trailing slash, the requirement that authorize and token exchange send the same value, and this platform's callback list. - **Access levels and the review regimes** — Standard vs Advanced Access and why only app-role users can connect without it, App Review submission contents (screencast, test credentials, policy URLs), Business Verification, Access Verification / Tech Provider, Ongoing Review and the annual Data Use Checkup. Note that `instagram_basic`, `instagram_business_basic`, `instagram_business_content_publish`, `instagram_content_publish` and `instagram_manage_insights` are all on the Access Verification list. - **`appsecret_proof` and the "Require App Secret" switch**, and what rotating an app secret breaks. - **Graph API versioning and the two-year rule**, the current version table, the rate-limit headers, and the generic `#1` / `#4` / `#10` / `#17` / `#100` / `#102` / `#190` / `#200` error codes. If you loaded only this file, you are missing all of the above, and nothing below substitutes for it — in particular, creating the app, registering redirect URIs, getting Advanced Access and rotating the secret are base-skill steps. ## Inputs specific to Instagram The base skill's input table applies in full. Collect these on top of it, in the same batch. | Input | Notes | | --- | --- | | **Which Instagram product** | Instagram Login or Facebook Login for Business (§ Platform state, then §1) | | **Test Instagram account** | A professional (Business or Creator) account you control (§2, §8) | | **Facebook Page** | Only if choosing Facebook Login for Business (§2) | | **Scope set** | What the connector actually calls (§4) | ## Quick Start 1. Read **Platform state** first — the product you pick determines every later step. 2. Confirm a professional Instagram account exists, and a linked Page if you are on the Facebook path (§2). 3. Create the app per the base skill and add the Instagram use case (§3). 4. Register the redirect URIs in the Instagram business login panel (§3). 5. Select the `instagram_business_*` scopes the connector calls, and no more (§4). 6. Make at least one successful call while restricted to app-role users, then submit for App Review (§5). 7. Capture the **Instagram** App ID and App Secret — not the Meta app's (§6). 8. Wire up the 1-hour → 60-day → refresh token chain (§7). 9. Verify with a second Instagram account that has no role on the app (§8), then hand off (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) Read this alongside the base skill's Platform state. The Instagram product lineup changed twice in two years and the stale advice is everywhere. **Instagram Basic Display API is gone.** Meta announced it on **September 4, 2024** and shut it down on **December 4, 2024**; the changelog states that "All requests to the Instagram Basic Display API will return an error message." Meta's own words: after that date "there will no longer be a set of Instagram APIs for consumer developer apps." The old `/docs/instagram-basic-display-api` URL still resolves, but it now lands on the Instagram Platform landing page with no Basic Display content. **If a plan, ticket or tutorial mentions Basic Display, `user_profile`, `user_media`, or personal Instagram accounts, it is describing a dead product — stop and re-scope.** **There are exactly two current products.** Both require an Instagram **professional** account (Business or Creator). Neither works with a personal Instagram account. | | **Instagram API with Instagram Login** | **Instagram API with Facebook Login for Business** | | --- | --- | --- | | Launched | July 23, 2024 | The older, Page-linked path | | Requires a Facebook Page | **No** | **Yes** — IG account must be linked to a Page | | Who logs in | The Instagram user, with Instagram credentials | The Facebook user who manages the Page | | API host | `graph.instagram.com` | `graph.facebook.com` | | Permission prefix | `instagram_business_*` | `instagram_*` plus `pages_*` | | Extra reach | — | Hashtag search, business discovery, basic metrics on *other* accounts | **Pick Instagram Login** unless you need a capability only the Facebook path has (hashtag search, business discovery, metrics on accounts other than the connected one) or unless customers are already Page-linked and expect a Facebook consent screen. Instagram Login removes the single biggest onboarding failure — a customer whose Instagram account is not linked to a Facebook Page, which they often cannot fix themselves. The two are not interchangeable at runtime: different host, different permission strings, different token endpoints. **Scope names were renamed and the old ones are dead.** Announced **September 17, 2024**: `business_basic` → `instagram_business_basic`, `business_content_publish` → `instagram_business_content_publish`, `business_manage_comments` → `instagram_business_manage_comments`, `business_manage_messages` → `instagram_business_manage_messages`. The old values were deprecated **January 27, 2025**. A separate permission, **`instagram_business_manage_insights`**, arrived **March 24, 2025** — it is newer than most tutorials and is missing from at least one of Meta's own summary pages, so do not conclude it does not exist if you cannot find it in a scope list. **Legacy v1.0 Instagram endpoints** were deprecated **January 21, 2025** with a compliance deadline of **May 20, 2025**. If the App Dashboard does not look like this, stop and report what you actually see rather than clicking on. ## 1. Reuse the existing app, or register a new one The base skill's reuse-versus-new decision applies, with one Instagram-specific addition: a new app means a **new Instagram App ID**, and every existing customer connection is bound to the old one. Register a **new** app only when the user explicitly wants one — and note the case that is unique to this product: a deliberate move between the two Instagram products is a **migration, not a config change**. Different host and different permission strings mean the connector code changes and every customer re-authorizes. Say which path you are taking before you touch anything. ## 2. The Instagram account, and the Page Beyond the base skill's developer account and business portfolio, Instagram needs one more account object, and sometimes two: - **Instagram professional account** — Business or Creator. Converting a personal account is a setting inside the Instagram app itself, done by the account owner, not by you. - **Facebook Page** — for **Facebook Login for Business** only. That Instagram account must additionally be linked to a Page, and the person authorizing must have a role on that Page. This is the requirement customers most often cannot satisfy on their own, and the main reason to prefer Instagram Login. Converting an Instagram account to professional is one of the human-only steps the base skill describes: hand it back to the account owner rather than looping on it. ## 3. Add the Instagram use case, and register the redirect URIs Meta folded Instagram-specific app creation into the generic wizard the base skill covers; the old "create an Instagram app" page is now a pointer. At the **Use cases** step, select the Instagram use case — currently surfaced as *"Manage messaging and content on Instagram"*. This is the choice that shapes the dashboard, and per the base skill it cannot be removed later. Then, in the dashboard, customize the use case to use **Business Login for Instagram** (Instagram Login) *or* **Facebook Login for Business**, matching the decision from Platform state. The Instagram Login settings live under **App Dashboard → Instagram → API setup with Instagram business login**, whose panels you will use again in §6. Redirect URIs are registered under **App Dashboard → Instagram → API setup with Instagram business login → 3. Set up Instagram business login → Business login settings**. Add each URL and save; the matching rules and the trailing-slash trap are in the base skill, and this panel is exactly where that trap bites. Two Instagram-specific behaviours on the callback itself: - After a successful authorization Meta appends **`#_`** to your redirect URI. Strip it before exchanging the code. - The authorization code is valid for **1 hour** and is **single use**. A detail worth knowing before you fight it: if you chose messaging, Meta **adds `instagram_business_basic` and `instagram_business_manage_messages` by default**. Prune the list to what the connector actually calls — the base skill explains what an unused permission request costs at review time. ## 4. Permissions and scopes Scope is sent on the authorize request as a comma- or space-separated list. Use the set that matches your product. **Instagram API with Instagram Login** (`graph.instagram.com`): | Scope | Grants | | --- | --- | | `instagram_business_basic` | Profile and media reads; **required**, and required to refresh a token | | `instagram_business_content_publish` | Publish media (the container → publish flow) | | `instagram_business_manage_insights` | Account and media insights — added March 24, 2025 | | `instagram_business_manage_comments` | Read and moderate comments | | `instagram_business_manage_messages` | Instagram Direct messaging | **Instagram API with Facebook Login for Business** (`graph.facebook.com`): `instagram_basic`, `instagram_content_publish`, `instagram_manage_comments`, `instagram_manage_insights`, `instagram_manage_messages`, plus `pages_show_list` and `pages_read_engagement` to find and read the linked Page. **As of 2026-09-20, Unified.to's Instagram connector requests** `instagram_business_basic` as its base/login scope, adds `instagram_business_content_publish` for publishing, and adds `instagram_business_manage_insights` for insights — the `instagram_business_*` set, which means it is built on **Instagram API with Instagram Login**, not the Facebook-Page path. It calls `graph.instagram.com`, sends the token as a bearer header, and does **not** ship shared platform credentials: each customer supplies their own Instagram App ID and Secret. Two auth quirks to raise with the connector's owner rather than assume: as of this date it is configured to authorize against `api.instagram.com/oauth/authorize` and to exchange at `graph.instagram.com/access_token`, while Meta's Business Login guide documents `https://www.instagram.com/oauth/authorize` for authorize and `https://api.instagram.com/oauth/access_token` for the code exchange (see §7) — and it carries no long-lived-token refresh configuration, which matters given the 60-day expiry. **Confirm the current scope set and endpoints with the connector's owner before you register anything.** ## 5. What the base skill's review regimes mean here All of them apply unchanged. Three Instagram-specific points: **Instagram apps do have app modes.** Unlike Business-type apps, an app built through the Instagram use case starts in **Development mode**, so the base skill's app-mode material is live for this product: only users with a role on the app can authorize, and flipping to Live without approved permissions makes the app visible without granting anything. **The ordering trap is explicit for Instagram.** Meta's Instagram App Review page states: "To request Advanced Access to certain permissions, you need to make at least 1 successful API call." So the sequence is: build → add your own Instagram account as an app role/tester → make real calls in Development mode → *then* submit. **A platform constraint Meta calls out in the submission**: "Web or mobile Web is the only platform that currently supports Instagram API with Instagram Login." A reviewer expecting a native mobile flow will not find one. ## 6. Capture the credentials For **Instagram Login**, the values live at **App Dashboard → Instagram → API setup with Instagram business login → 3. Set up Instagram business login → Business login settings**: - **Instagram App ID** → your OAuth `client_id` - **Instagram App Secret** → your OAuth `client_secret` **These are not the Meta app's App ID and App Secret** (Settings → Basic). The Meta pair is a different credential for a different surface; using it against `api.instagram.com` fails in ways that look like a misconfigured app rather than a wrong ID. For **Facebook Login for Business**, the opposite is true — that path uses the Meta app's App ID and App Secret, the ones the base skill describes. Record which product this app is, next to the credentials, or the next person will pick the wrong pair. This is also the one place where the base skill's `appsecret_proof` guidance needs care: verify *which* secret keys the HMAC for your product before enabling "Require App Secret," rather than assuming it is the Instagram App Secret. Also capture, alongside the base skill's list: the authorize, token, long-lived exchange and refresh endpoints (§7), and the API host (`graph.instagram.com` or `graph.facebook.com`). ## 7. Token lifetimes and refresh Instagram Login tokens go through three stages. A connector that stops at stage one dies after an hour; one that stops at stage two dies after 60 days. | Stage | Call | Result | | --- | --- | --- | | Code → short-lived | `POST https://api.instagram.com/oauth/access_token` with `client_id`, `client_secret`, `grant_type=authorization_code`, `redirect_uri`, `code` | Token valid **1 hour** | | Short-lived → long-lived | `GET https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_secret=…&access_token=…` | Token valid **60 days** (`expires_in` 5184000) | | Long-lived → refreshed | `GET https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=…` | New token, another **60 days** | Constraints that decide whether a connection survives: - Instagram's renewal mechanism is unusual for Meta in that a connection *can* be kept alive indefinitely without sending the user back through consent — but **you refresh the access token itself** and must store the *new* value each time. A client that keeps refreshing the original value eventually fails. - The refresh call requires a token that is **at least 24 hours old and not yet expired**, and the token must carry `instagram_business_basic`. There is no grace period after expiry — a connection that goes 60 days without a refresh is dead and the customer must re-authorize. Refresh well before day 60, not on it. - **Authorize host discrepancy, verified today:** Meta's Business Login guide shows the embed URL as `https://www.instagram.com/oauth/authorize`, while Meta's own `oauth-authorize` reference page documents `GET https://api.instagram.com/oauth/authorize`. Both are official and current. Widely-repeated advice names only one or the other. Treat `www.instagram.com/oauth/authorize` as the documented user-facing entry point and test whichever the connector is configured for, rather than assuming the other is wrong. - **Facebook Login for Business has a different token story** (`graph.facebook.com`, `fb_exchange_token`, Page tokens, and no `ig_refresh_token` equivalent). Do not carry any of the table above across to it; `meta-ads-oauth-app` §5 describes how that ladder behaves. ## 8. Verify end-to-end The base skill's round trip and its rule about testing as a user with no role on the app both apply. These are the Instagram-specific steps layered on it: 1. Authorize a **second** Instagram professional account with **no role on the app**, through your platform's real connect flow. 2. Confirm the code exchange succeeds, then the long-lived exchange, then a **refresh** — and that the connector stores the token each stage returns. 3. Make one real read call against `graph.instagram.com` with the pinned version. | Symptom | Cause | | --- | --- | | `Invalid platform app` / app not recognized | Using the Meta App ID instead of the **Instagram** App ID (§6), or the wrong product entirely | | `Invalid scrubbed redirect_uri` / redirect mismatch | URI not registered exactly in the Instagram business login panel (§3) | | Consent succeeds, token exchange fails | The `#_` fragment was not stripped, or the code was already used or is over an hour old (§3) | | Works, then dies after ~1 hour | Never exchanged the short-lived token for a long-lived one (§7) | | Works, then dies after ~60 days | No refresh, or refresh attempted after expiry, or the refreshed token was not stored (§7) | | Refresh returns an error on a fresh connection | Token is under 24 hours old (§7) | | Customer cannot connect: "no professional account" | Personal Instagram account — owner must convert to Business or Creator (§2) | | Customer cannot connect on the Facebook path only | Instagram account not linked to a Facebook Page, or the user lacks a role on the Page (§2) | | A tutorial's endpoints 404 or return "no longer available" | The guide predates December 2024 and targets Basic Display (§ Platform state) | ## 9. Hand off Follow the base skill's handoff rules and add, to its closing list: **which Instagram product** the app is built on; the Instagram App ID and where its secret was delivered (distinct from the Meta app's pair); the authorize, token, exchange and refresh endpoints; the exact `instagram_business_*` scope strings; the API host; and the refresh schedule the platform must run before day 60. ## Stop and ask The base skill's stop-and-ask list applies. Additionally, hand back to a human when: the choice between Instagram Login and Facebook Login for Business is not clearly implied by the capabilities needed, since it determines the connector's host and permission strings; customers' Instagram accounts are personal, or unlinked from a Page on the Facebook path; someone proposes migrating a live app between the two Instagram products, which re-authorizes every customer and changes the code; the plan or ticket assumes Instagram Basic Display or personal accounts; or the connector does not refresh long-lived tokens on a schedule. ## References Instagram specific. The generic Meta developer-account, app-creation, review, credential and Graph API references are in `meta-graph-app`. All verified to resolve 2026-09-20. - Instagram Platform — https://developers.facebook.com/docs/instagram-platform - Instagram Platform overview (products, tokens, permissions) — https://developers.facebook.com/docs/instagram-platform/overview - Instagram API with Instagram Login — https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login - Business Login for Instagram — https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login - Instagram API with Facebook Login for Business — https://developers.facebook.com/docs/instagram-platform/instagram-api-with-facebook-login - Business Login for Instagram (Facebook Login path) — https://developers.facebook.com/docs/instagram-platform/instagram-api-with-facebook-login/business-login-for-instagram - `oauth/authorize` reference — https://developers.facebook.com/docs/instagram-platform/reference/oauth-authorize/ - `access_token` reference (long-lived exchange) — https://developers.facebook.com/docs/instagram-platform/reference/access_token/ - `refresh_access_token` reference — https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token/ - Instagram Platform changelog — https://developers.facebook.com/docs/instagram-platform/changelog - Instagram Platform App Review — https://developers.facebook.com/docs/instagram-platform/app-review - Instagram Platform insights — https://developers.facebook.com/docs/instagram-platform/insights - Create an Instagram app (now a pointer to the generic flow) — https://developers.facebook.com/docs/instagram-platform/create-an-instagram-app - Update on Instagram Basic Display API (Sept 4, 2024; shutdown Dec 4, 2024) — https://developers.facebook.com/blog/post/2024/09/04/update-on-instagram-basic-display-api/ - User and media insights on Instagram API with Instagram Login (Mar 24, 2025) — https://developers.facebook.com/blog/post/2025/03/24/user-and-media-insights-on-instagram-api-with-instagram-login/ ## intercom-oauth-app URL: https://docs.unified.to/skills/intercom-oauth-app/SKILL.md # Intercom OAuth2 App Registration Get a working Intercom OAuth2 client — a development workspace, an app in the Developer Hub with **Use OAuth** ticked, HTTPS redirect URLs, the permission checkboxes, and a client ID and secret — for a platform that connects many customers' Intercom workspaces. Intercom departs from the usual OAuth shape in three ways that cost real time if you assume otherwise. **Scopes are selected per app in the Developer Hub, not requested per authorization** — the authorize URL takes only `client_id` and `state`, so there is no "just add a scope to the URL" fix; a scope change is an app change, and on an approved app it is a re-review. **Intercom has three regional workspaces (US, EU, AU) with three authorization hosts and three API hosts**, and sending an EU customer to the US host costs them an extra region-selection step — or hard-fails if they sign in with Google. And **the API version is pinned on the app**, retroactively, for every token ever issued by it. The fourth thing to know before you start: **a public app must be developed in a development workspace, and development workspaces exist only in the US region** (§2). That is a constraint on where you build, not on who can install. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, description, logo | Shown on the consent screen and, if listed, in the App Store | | **Which workspace owns the app** | An existing development workspace, or a new one (§2) | | **Is this public (customers' workspaces) or private (your own)?** | Private apps skip OAuth entirely — use an access token (§2) | | **Redirect URLs** | Every callback host your platform serves, HTTPS only (§4) | | **Permission set** | Intercom's checkbox labels, not scope strings (§5) | | **Which customer regions you serve** | US / EU / AU — decides §6 | | **API version to pin** | Affects every existing customer token (§8) | | **Listed in the App Store, or unlisted?** | Both need review; listing needs much more (§9) | | **Intercom teammate account with Developer Hub access** | Only a human can sign up, verify email and accept terms | ## Quick Start 1. Confirm a **new app** is needed — a new client ID orphans every existing customer connection (§1). 2. Sign in to, or create, the **development workspace** that will own the app (§2). 3. Create the app in the Developer Hub, then tick **Use OAuth** on its Authentication page (§3). 4. Add **every** redirect URL, HTTPS only, first one is the default (§4). 5. Tick the permission checkboxes the connector actually needs — these are the scopes (§5). 6. Decide the regional story: authorization host per customer, API host per workspace (§6). 7. Copy client ID and client secret from **Basic Information** (§7). 8. Pin the API version deliberately, knowing it applies to tokens already issued (§8). 9. Choose listed vs unlisted and submit for review — both require it (§9). 10. Verify with a real authorize → callback → token → `/me` round trip against a *second* workspace (§10). 11. Hand the credentials over — never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **OAuth is off until you turn it on.** A new app has no OAuth at all. You tick **Use OAuth** on the app's Authentication page in the Developer Hub, which then reveals the Redirect URLs and Permissions sections. Until that tick, there is no client secret to find and the authorize URL will not work. - **Scopes are per app, not per authorize request.** The documented authorize URL carries only `client_id` and `state`. Permissions are checkboxes in the Developer Hub, reviewed at approval. A connector cannot narrow or widen what it asks for at connect time. - **Public apps must be built in a development workspace**, which is free, unlimited in number, **US region only**, capped at 20 users/leads, cannot be converted to production, and watermarks the Messenger. - **Three regions, three hosts.** API: `https://api.intercom.io`, `https://api.eu.intercom.io`, `https://api.au.intercom.io`. Authorization: `https://app.intercom.com/oauth`, `https://app.eu.intercom.com/oauth`, `https://app.au.intercom.com/oauth`. Calling `api.intercom.io` for any workspace is documented to attempt routing to the right region; the authorization host is *not* forgiving in the same way for Google sign-in. - **The token response has no `expires_in` and no `refresh_token`** — it returns `token_type`, `token` and `access_token`, and the docs document no refresh endpoint. See §7 for what this does and does not let you conclude. - **Every public app needs review — listed or unlisted.** Up to **seven business days**. On approval the app becomes *public unlisted*; listing in the App Store is a separate switch. Changes to an approved app (including redirect URLs and permissions) need re-approval. - **Rate limits**: 10,000 API calls per minute per app and 25,000 per minute per workspace, distributed into 10-second windows. If the Developer Hub does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer token belongs to the old one**. Intercom tokens are not portable between apps; every customer would reinstall and re-authorize. Reuse the existing app for: adding a redirect URL, adding or removing a permission, rotating credentials, changing the pinned API version, or diagnosing a failing authorization. On an **approved** app all of those are re-review events (§9) — they are not free, but they are far cheaper than a new client ID. Create a **new** app only when the user explicitly wants one: a separate product, a staging app kept apart from production (Intercom itself recommends a `[Staging]` twin), or a deliberate replacement for a compromised app. Say which path you are taking before you touch anything. ## 2. Workspaces: which one owns the app Intercom apps are owned by a **workspace**, and which kind of workspace you pick is a decision you cannot undo later. - **Public app (this is the multi-customer connector case)** — you **must** create a new **development workspace**: `https://app.intercom.com/admins/sign_up/developer`. Free, create as many as you like, intended for development only. Limits that bite: **US region only**, **cannot be converted into a production workspace**, maximum 20 users/leads (older ones are archived automatically), no outbound email or push, Help Center can never go live, watermarked Messenger. - **Private app (your own workspace's data only)** — you do not need OAuth at all. Sign in at `https://app.intercom.io/admins/sign_in`, create the app on your paid workspace, and use the access token from its Authentication page. If someone is asking for OAuth credentials to read *their own* workspace, say so — it saves the whole review cycle. Intercom's terms forbid asking customers for their access tokens in place of OAuth. - **If your own paid workspace is in EU or AU**, Intercom explicitly advises against developing a private app in a development workspace, because you will not be able to install a development-workspace app into your paid workspace. Develop in the paid workspace instead. - **Test workspace** (a toggle on a paid workspace, Settings → Workspace → General) is a different thing from a development workspace: it inherits your plan and teammates, and is available on the **Advanced or Expert** plans. Useful as the *second* workspace for end-to-end verification (§10). Hand control back to the user for anything only a human can do: signing up, verifying the email, accepting terms, 2FA, and anything that requires being a teammate on the workspace. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste — the redirect URLs from §4 and the permission labels from §5 — then continue once they report back with the client ID. ## 3. Create the app and turn OAuth on 1. Developer Hub → **Your apps** → **New App**: `https://app.intercom.com/a/apps/_/developer-hub`. 2. In the modal, enter the app name and **select the workspace** it belongs to (you can create a development workspace from this dropdown). Creating the app **pre-installs it** into that workspace and issues an access token for it — that token is for your own workspace's data and is not what customers will use. 3. Open the app's **Authentication** page and tick **Use OAuth**. Two sections appear: **Redirect URLs** (§4) and **Permissions** (§5). Nothing about OAuth exists before this tick. 4. Fill in Basic Information — name, description, logo. This is what a customer sees on the consent screen and, if listed, in the App Store. If the app will use **Canvas Kit** (Messenger or Inbox surfaces), four permissions are selected automatically and **cannot be deselected**: *Read and list users and companies*, *Read conversations*, *Read admins*, *Gather App data*. A pure REST connector that does not use Canvas Kit avoids that floor — worth knowing before a reviewer asks why you request them. ## 4. Redirect URLs The redirect URL is where Intercom sends the authorization code. Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes that decide whether this works: - **HTTPS is mandatory.** Intercom will not accept an `http://` redirect — TLS/SSL only. No localhost exception is documented, which is why a dev/staging host belongs in this list rather than a loopback URL. - **Multiple URLs are supported** via *Add redirect URL*. **The first is always the default** — the one used when you do not pass `redirect_uri`. Put production first unless you have a reason not to. - Intercom's guidance is that after approval you select among the registered URLs with the `redirect_uri` parameter, which is how one app covers testing and production. - On an approved app, editing this list is a **re-review** (§9). Register every host you will plausibly need now — a fourth data center added later is a review cycle, not a config change. ## 5. Permissions (these are the scopes) Intercom calls them **Permissions**, presents them as checkboxes on the app's Authentication page, and reviews them at approval. There is no scope parameter in the authorize URL — **whatever is ticked is what every customer grants**. The consequences are worth stating plainly before you tick anything: - You cannot ask a given customer for less. Every install carries the full ticked set. - **Over-privileged scopes are the most common reason apps are initially rejected** in review, per Intercom's own guidance. Tick only what the connector calls. - Adding a permission later changes the consent screen and triggers re-review. Customers who authorized before the change did not consent to the new permission — plan on re-authorization when you widen the set, and verify the behavior before promising otherwise. - **Webhook topics are gated on the matching permission.** If you want a webhook topic, the corresponding permission must be ticked — e.g. user/lead-created topics require *Read and write users*. The checkbox labels are the identifiers here — copy them verbatim into the Developer Hub. Labels relevant to a CRM/ticketing/help-center connector, as Intercom spells them: | Group | Label | | --- | --- | | People & conversation data | `Read and list users and companies`, `Read one user and one company`, `Read and write users`, `Write users and companies` | | People & conversation data | `Read conversations`, `Write conversations`, `Read tickets`, `Write tickets` | | People & conversation data | `Read tags`, `Write tags`, `Read events`, `Write events`, `Read counts`, `Write data attributes` | | People & conversation data | `Read and write custom object instances`, `Read status of jobs`, `Read content data` | | Workspace data | `Read admins`, `Read one admin`, `Update admins`, `Read admin activity logs` | | Workspace data | `Read and List articles`, `Read and Write Articles` | | Workspace data | `Read and List news items and newsfeeds`, `Read and Write news items and newsfeeds` | | Workspace data | `Read data when entered into the app`, `Read and write AI content`, `Read data connectors` | **Product fact — as of 2026-09-20, Unified.to's Intercom connector requests this permission set**: *Read admins*, *Read one admin*, *Read and list users and companies*, *Write users and companies*, *Read and List Articles*, *Read conversations*, *Read tickets*, and *Write tickets* — covering messaging, ticketing, HRIS (admins and teams), knowledge-base articles and collections, and ticket attachments. Reads dominate; the only writes are contacts/companies and tickets. **Confirm the current set with the connector's owner before ticking boxes** — a set captured on a date is a starting point, not an authority, and shipping a permission the connector does not use is exactly what review rejects. ## 6. Regions: US, EU and AU This section matters more than it looks for a connector serving customers in more than one region. **Two different host families, both regional:** | Region | Authorization host (browser) | API host (server) | | --- | --- | --- | | US | `https://app.intercom.com/oauth` | `https://api.intercom.io` | | EU | `https://app.eu.intercom.com/oauth` | `https://api.eu.intercom.io` | | Australia | `https://app.au.intercom.com/oauth` | `https://api.au.intercom.io` | (A bare `GET` to an authorization host without `client_id` returns 400 — that is the endpoint working, not a misconfiguration.) What the docs say about getting it wrong: - **Authorization host.** Send each customer to the host matching their workspace's region. If you use the US host for an EU or AU customer, they are asked to check their credentials and pick the correct region from a dropdown on the sign-in page before continuing — an extra step, not a failure. **The exception: if the customer signs in with Google, the wrong regional host fails rather than prompting.** So "US host for everyone" is not broken-for-everyone; it is broken for the subset of customers using Google sign-in, which is the worst kind of bug to diagnose from a support ticket. - **API host.** Calling `api.intercom.io` is documented to attempt routing the request to the correct region, but the regional hosts exist so you can be explicit. Be explicit: store the workspace's region at connect time. The `/me` response includes the workspace's `region`, which is the cheapest way to learn it right after the token exchange. - **The token exchange** is documented against `https://api.intercom.io/auth/eagle/token`. If you exchange against a regional host instead, verify it rather than assuming symmetry — this is the one place where the three-host pattern is not spelled out in the docs. **One app or three?** Development workspaces are US-only, so a public app is built in the US regardless. The documented model is one public app whose customers authorize on their own regional host. The docs do *not* state this in one sentence, so if the user's plan depends on a single app serving EU and AU customers, verify it against a real EU or AU workspace during §10 rather than taking it on faith. Also worth knowing when a customer asks: regional data hosting is available to **new** workspaces on Advanced/Expert contract plans, and a workspace cannot be migrated between regions — they create a new workspace and move data. **Product fact — as of 2026-09-20, Unified.to's Intercom connector** offers the customer a choice of US, Europe or Australia, pairs each with the matching regional API base and a **region-specific token endpoint**, and — worth flagging — **points all three at the US authorization host**. Per the region note above, that is tolerable for password sign-in and a failure mode for Google sign-in. It also ships **no shared Intercom credentials**, so each deployment brings its own client ID and secret; sends only `client_id` and `state` on authorize, with no scope parameter and no PKCE; and exchanges the code by POSTing JSON containing `client_id`, `client_secret` and `code`. Confirm all of this with the connector's owner before designing around it. ## 7. Capture the credentials Client ID and client secret live on the app's **Basic Information** page in the Developer Hub. They appear only after OAuth is enabled (§3). Capture: - **Client ID** and **client secret** - The authorization host(s) you will use (§6), and the token endpoint `https://api.intercom.io/auth/eagle/token` - Which workspace owns the app, and whether it is a development or paid workspace - The app's pinned API version (§8) **The token response, and what it does not contain.** A successful exchange returns `token_type` (always `Bearer`), `token`, and `access_token` — a duplicate of the same value. There is **no `expires_in` and no `refresh_token`**, and the docs document no refresh or renewal endpoint. What the docs *do* document is how a token ends: the customer uninstalls the app, you regenerate a private app's token in the Developer Hub, or someone `POST`s to `https://api.intercom.io/auth/uninstall` with the token to deauthorize it. **Intercom's own documentation does not state in words that OAuth tokens never expire** — that is a widely repeated claim this skill could not verify at an official source on 2026-09-20. Treat the token as long-lived but revocable: build for "401 means the customer must re-authorize", never for a refresh flow, and if a contractual maximum lifetime matters, ask Intercom directly. **Know when a customer disconnects.** Because there is no refresh cycle to fail, an uninstall is otherwise invisible until the next API call 401s. Intercom lets you register a URL that receives a `POST` with `{"app_id": "..."}` when someone uninstalls or revokes — wire it up if the platform can consume it. **`Unauthorized Code` on exchange.** The `code` in the redirect may end with `=`, and it must be included. A truncated or over-trimmed code is the documented cause of a 401 with that message at the token step. Rotating the client secret invalidates the old one. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 8. The API version is pinned on the app Each app carries an API version, set in the Developer Hub (**API Version** → *Change version*), and an `Intercom-Version` request header overrides it per call. The part that surprises people: **for OAuth apps, the app's version setting governs every access token the app has ever granted, including tokens issued before the change.** Bumping the version in the Developer Hub is a production-wide behavior change for all installed customers at once, not a staged rollout. Intercom's own advice is to ship the header first (so code and version change together), confirm it in production, and only then move the app setting. Two more consequences worth carrying: - **Webhook topics can conflict across versions.** When you change version, topics unavailable in the new version show as *Off* and must be deleted before you can proceed. - **Rollback is supported** — you can switch back to an older version if something breaks. Before changing it, ask whether the platform pins a version per request. A connector that sends `Intercom-Version` on every call is insulated from the app setting; one that does not is not. **Product fact — as of 2026-09-20, Unified.to's Intercom connector sends an explicit `Intercom-Version` header (2.11) on every list, get, create, update and delete call**, so the app-level setting is not what determines its behavior. Confirm with the connector's owner, and note that Intercom's current version was **2.15** as of this check — a pinned 2.11 is a deliberate lag, not necessarily a bug. ## 9. Listing, review, and the unlisted path **Private apps** — your own workspace only, access token, no OAuth, **no review**. Stop here if that is the case. **Every public app needs review**, whether or not you list it: | | Listed | Unlisted | | --- | --- | --- | | Discoverable in the App Store | Yes | No | | Requires review | Yes | Yes | | App Store listing + Start guide pages | Required | Not required | | App Partner Program form | Required | — | | Installation status section | Required | Not required | | Install path | App Store and/or your site | Your product or site only | Mechanics that change what you build: - On approval the app becomes **public unlisted**. Listing is a separate **List in App store** action, so you can beta with real customers before going live. - For listed apps you provide a **Direct installation URL** — a URL *you* host that starts the OAuth flow and then redirects to Intercom's authorize endpoint. On success redirect to `https://app.intercom.com/appstore/redirect?install_success=true`; on failure to the same path with `install_success=false&error_message=`. - **If the app uses any Canvas Kit capability, installation from the App Store listing is mandatory.** - Review takes **up to seven business days**, and submission requires an **end-to-end video** showing install with OAuth working, the app's functionality, and uninstall — plus test-account credentials or setup instructions if your service needs an account. Notifications go to the teammates who created the app and who submitted the review. - **Changes to an approved app need re-approval**, including redirect URLs and permissions. The live app keeps working with its current configuration while the change is pending; a *Review changes* tab shows the diff. Everything in the App Partner Program form — company details, target use cases, points of contact, co-marketing — and anything the review asks about compliance, data handling or volume is a business decision. Collect it from the user; do not compose it (see **Stop and ask**). ## 10. Verify end-to-end Authorizing in the workspace that owns the app proves little — the app is pre-installed there. Test the customer path: 1. Authorize from a **second workspace** (a test workspace on a paid plan, or another development workspace) through your platform's real connect flow. 2. Confirm the redirect arrives with `code` and `state`, that `state` matches what you sent, and that the **trailing `=` on `code`, if present, survives** into the exchange. 3. Exchange, then call `GET https://api.intercom.io/me` with `Authorization: Bearer `. Read the workspace `region` out of the response and store it. 4. Re-issue that same call against the **regional** API host for that workspace and confirm it behaves identically. 5. If you serve EU or AU customers, repeat the whole flow against a workspace in that region using **its** authorization host — including, if you can, a Google sign-in account (§6). 6. Uninstall the app from the second workspace and confirm your platform notices — via the uninstall notification URL if configured, or by a 401 on the next call. | Symptom | Cause | | --- | --- | | Authorize page errors, or the app is not found | **Use OAuth** was never ticked on the Authentication page (§3) | | 400 from the authorization host | Missing `client_id` / `state` — or a bare `GET` with no parameters | | Customer is asked to "check credentials" and pick a region | Sent to the US authorization host for an EU/AU workspace (§6) | | Google sign-in customers cannot authorize at all; password users can | Wrong regional authorization host — Google fails instead of prompting (§6) | | Redirect never happens / URL rejected at save | Redirect URL is not HTTPS, or not registered exactly (§4) | | Lands on the wrong callback host | No `redirect_uri` sent, so Intercom used the **first** registered URL (§4) | | `401 Unauthorized Code` at the token exchange | The `code`'s trailing `=` was dropped, or a stale/reused code (§7) | | Auth succeeds, then 403/permission errors on one endpoint | That permission is not ticked on the app — not fixable per connection (§5) | | Expected webhook topic unavailable | Its matching permission is not ticked (§5) | | Everything 401s after working for months | Customer uninstalled or revoked; re-authorize — there is no refresh (§7) | | Response shapes changed for all customers at once | The app's pinned API version moved (§8) | | 429 with `X-RateLimit-*` headers | 10,000/min per app, 25,000/min per workspace, in 10-second windows | ## 11. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for their secret store or console. - Access tokens are equally sensitive — an Intercom access token is a workspace password. Never ask a customer for theirs; Intercom's terms require OAuth instead, and violating that can cost API access. - If a code change is needed (a callback host, a regional authorization host, a version header), keep it secret-free and say what the human must set out of band. - Close with: app name and the workspace that owns it (development or paid, and its region); client ID; where the secret was delivered; the authorization host(s) and token endpoint in use; the exact permission labels ticked and why; the pinned API version; listed vs unlisted and current review state; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the permission set is not confirmed by the connector's owner (a guessed scope is a rejected review); someone wants to change redirect URLs or permissions on an approved app without understanding it is a re-review and may force customers to re-authorize; a version bump is proposed on an app whose clients do not send `Intercom-Version` themselves; the plan depends on one app serving EU or AU customers and that has not been verified against a real workspace in that region; the App Partner Program form, App Store listing copy, compliance answers, or test-account credentials for the reviewer are needed; someone proposes asking customers for their access tokens instead of OAuth; a customer's regional workspace or plan eligibility is in question (Advanced / Expert, contract); or the Developer Hub does not match the **Platform state** section above. ## References - Authentication overview (access tokens vs OAuth) — https://developers.intercom.com/docs/build-an-integration/learn-more/authentication - Setting up OAuth (redirect URLs, permissions, regional authorization hosts, token exchange) — https://developers.intercom.com/docs/build-an-integration/learn-more/authentication/setting-up-oauth - OAuth scopes — https://developers.intercom.com/docs/build-an-integration/learn-more/authentication/oauth-scopes - Installing and uninstalling apps (install URL, uninstall notifications, `auth/uninstall`) — https://developers.intercom.com/docs/build-an-integration/learn-more/authentication/installing-uninstalling-apps - Set up a workspace / create an app (development workspace limits) — https://developers.intercom.com/docs/build-an-integration/getting-started - About our REST API (regional hosting endpoints) — https://developers.intercom.com/docs/build-an-integration/learn-more/rest-apis - Update your API version — https://developers.intercom.com/docs/build-an-integration/learn-more/rest-apis/update-your-api-version - API changelog — https://developers.intercom.com/docs/build-an-integration/learn-more/rest-apis/api-changelog - App review and publishing — https://developers.intercom.com/docs/publish-to-the-app-store/review-publish-your-app - Listing your app — https://developers.intercom.com/docs/publish-to-the-app-store/listing-your-app - Setting up webhooks — https://developers.intercom.com/docs/webhooks/setting-up-webhooks - Rate limiting — https://developers.intercom.com/docs/references/rest-api/errors/rate-limiting - Intercom developer FAQs (current API version, rate limits) — https://www.intercom.com/help/en/articles/9071694-intercom-developer-faqs - Regional Data Hosting — https://www.intercom.com/help/en/articles/6124430-regional-data-hosting - Changes to your EU/AU hosted workspace — https://www.intercom.com/help/en/articles/8699600-changes-to-your-eu-au-hosted-workspace - Create a test workspace — https://www.intercom.com/help/en/articles/188-create-a-test-workspace-in-intercom - Developer Hub — https://app.intercom.com/a/apps/_/developer-hub - Development workspace signup — https://app.intercom.com/admins/sign_up/developer - Intercom App Store — https://www.intercom.com/app-store - Technology Partner Program — https://www.intercom.com/technology-partner-program ## jobadder-oauth-app URL: https://docs.unified.to/skills/jobadder-oauth-app/SKILL.md # JobAdder OAuth2 App Registration **JobAdder credentials are not self-serve.** There is a public registration form, but it is an application to become a JobAdder API partner, not a signup: JobAdder reviews it, and the client ID and secret you eventually see do not work until *both* the developer account **and** each individual application have been approved by JobAdder. Plan for a wait measured in business days, not minutes, and do not promise anyone working credentials the same day. §2 is the heart of this skill. Three other things cost real time here. **The API base URL is returned per account in the token response** and must be stored per connection — JobAdder runs the API on several regional clusters, so a client that hardcodes `api.jobadder.com/v2` authenticates fine and then fails against every account that does not live there. **Refresh tokens rotate on every refresh and expire after two weeks of disuse**, so an idle connection dies quietly. And **which scopes you are allowed to request is decided from what you wrote on the registration form** — the partner-only scopes are not handed out because you asked for them in an authorize URL. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Company name and website** | The registration form is a partner application; these are what JobAdder reviews (§2) | | **Contact first/last name, email, phone** | Required on the form; the approval email goes to this address | | **Integration type** | Picked from JobAdder's fixed dropdown — see §2 for the list | | **Description of the integration** | Free text on the form. This is what determines which scopes you get (§5) | | **Application name** | Shown to the JobAdder user on the consent screen | | **Redirect URIs** | Every callback host, exact (§4) | | **Scope set** | The objects the connector actually reads/writes (§5) | | **New app, or an edit/rotation of an existing one?** | (§1) | | **Is a separate test application wanted?** | JobAdder expects one account with several apps, not several accounts (§3) | ## Quick Start 1. Confirm a **new** application is needed — a new client ID orphans every existing customer connection (§1). 2. Apply for the JobAdder developer/partner account and wait for approval (§2). 3. Register the application in the developer portal and wait for *its* approval (§3). 4. Add **every** authorised redirect URI, exactly (§4). 5. Choose the narrow per-object scopes, and always include `offline_access` (§5). 6. Capture the client ID and secret from the application (§6). 7. Wire up the token lifetimes and the **per-account API base URL** from the token response (§7). 8. Verify with a real authorize → callback → refresh round trip against a JobAdder account, not the developer login (§8). 9. Hand the credentials over — never commit them (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **A developer portal exists at `https://developers.jobadder.com/`** with "Register as a partner" and a sign-in page. Historically JobAdder issued credentials only by request to their API/partner team; the form is now the front door, but it has not made the process self-serve — it submits an application that a human reviews. - **Two approval gates, not one.** JobAdder's own help content is explicit: *"Once your Developer Account has been approved, you will receive an email to finish activating this"*, and then *"once this has been done and the Application has been approved, you will be able to access your Client ID and Secret"*. Treat an unapproved application's credentials as non-working, not as a bug to debug. - **Scope accessibility is decided at registration.** JobAdder states that *"accessibility will be determined based on the information provided when registering for a developer account"* and asks for correct, factual information about the solution. The partner scopes (`partner_jobboard`, `partner_ui_action`) sit behind this. - **The API Terms reserve broad control**: JobAdder can revoke or change any partner's access at any time and for any reason, and requires **written approval before you publish any partner service**. Publishing or listing is a separate business step from getting credentials. - **OAuth endpoints are stable**: authorize at `https://id.jobadder.com/connect/authorize`, token at `https://id.jobadder.com/connect/token`. The identity server's discovery document advertises `S256` PKCE and the `client_credentials` and `password` grants, but **JobAdder documents only the authorization-code flow with a client secret** — do not build on the undocumented ones without asking JobAdder first. - **One developer account per partner.** JobAdder asks partners to register exactly one developer account, use its application credentials for all customers, and *not* to ask JobAdder users to register their own. If the portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new application means a **new client ID, and every existing customer connection is bound to the old one** — every JobAdder account would have to re-authorize. Reuse the existing application for: adding a redirect URI, changing the scope set, rotating a leaked secret, or diagnosing an authorization failure. Register a **new** application only when the user explicitly wants one: a deliberate test/live split (JobAdder expects this and supports many applications under one developer account), a separate product surface, or a replacement for a compromised app. Because each new app carries its own approval wait (§3), say which path you are taking before you touch the portal. ## 2. Getting access to credentials — the approval gate This is the step that decides the timeline. There is nothing to click that shortcuts it. **Apply.** `https://developers.jobadder.com/register` ("Register as a partner"). The form asks for: - First name, last name, email address, phone number - Company name, company website - **Integration type** — a fixed dropdown. As of 2026-09-20 the options are: Background Check Software, Benefits Administration Software, Compensation Management Software, Employee Engagement Software, Employee Goal Setting Software, Employee Pulse Survey Tools, Employee Recognition Software, Employee Scheduling Software, HR Analytics Software, Human Resource Apps, Learning Management Systems, Mentoring Software, Messaging Software, OKR Software, Onboarding Software, Online Training Software, Payroll Software, Performance Management Software, Pre-Employment Assessment Tools, Reference Checking, Talent Management, Time and Attendance Software, Vacation/Leave Tracking Software, Video Interview Software, Voice Call Software, Workforce Management Software, 360 Degree Feedback Software, Other. - **"Briefly describe your integration plans"** — free text, and the highest-leverage field on the form. JobAdder decides scope accessibility from it. Name the objects you will read and write (candidates, job applications, jobs, companies, contacts, users) and say whether you need job-board or partner-action access. A vague answer buys a vague grant and another round trip. - Agreement to the **JobAdder API Terms** (`https://jobadder.com/api-terms`). **Then wait.** Approval arrives as an activation email to the address on the form. If it does not arrive, the follow-up is with JobAdder's API/partner team — chasing it is the user's call, not something to automate. **A partnership request is a different thing.** `https://jobadder.com/partnership-request/` is the commercial marketplace/partner-ecosystem application. It does not issue credentials, and credentials do not get you listed. If the user wants both, they are two tracks; do not conflate them in your summary. **What only a human can do**, and where to hand back rather than loop: submitting the form, accepting the API Terms, verifying the email, activating the account, and any correspondence with JobAdder about approval or scope. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered path with the literal values to paste — the integration-plans text, the redirect URIs from §4, the scope list from §5 — and continue once they report back with the client ID. ## 3. Register the application Once the developer account is active: sign in at `https://developers.jobadder.com/signin` and register a new application. What it needs: - **Application name** — this is what the JobAdder user sees on the consent screen. Use the product name the customer will recognise, not an internal codename. - **Authorised redirect URIs** — see §4. These must be entered here before they will work. - Supporting URLs (such as a privacy policy) if the form asks; confirm the current fields in the portal rather than assuming this skill's list is complete. Then **wait for the application to be approved**. Credentials exist on the page before approval; they do not work before approval. A brand-new client ID failing at the authorize step is the expected behaviour of an unapproved app, not a misconfiguration to hunt. **Test vs live.** JobAdder's guidance is one developer account with as many applications as you need, typically one test and one live, each with its own client ID and secret. There is a separate sandbox API host (`https://sandboxapi.jobadder.com/v2`) and JobAdder operates a Sandbox cluster alongside the production regional ones — but you never choose that host yourself, because the token response tells you which base URL to use (§7). Ask JobAdder for sandbox/test-account access as part of the registration conversation; it is not a checkbox in the portal. ## 4. Redirect URIs Register **every** callback host your platform serves, in the application's Authorised Redirect URI list. JobAdder matches the `redirect_uri` you send against that list, and the same value must be sent again at the token exchange. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes: - Enter the **full and exact** URI, scheme included. JobAdder's own note is explicit about this. No subdirectory matching, no trailing-slash forgiveness. - The `redirect_uri` sent at the **token exchange** must equal the one sent at **authorize**. Mismatch there produces a failed exchange, not a failed redirect, which is why it gets misdiagnosed as a bad secret. - These are *your* callback hosts. They do not vary per JobAdder account, even though the API base URL does (§7). ## 5. Scopes Scopes are **space-delimited** in the authorize URL. JobAdder splits them per object, with `read`/`write` as blunt catch-alls. | Scope | Grants | | --- | --- | | `read` / `write` | Almost all GET / almost all POST+PUT endpoints. JobAdder explicitly advises **against** these for privacy and security; request the narrow ones instead | | `read_candidate` / `write_candidate` | Candidates | | `read_jobapplication` / `write_jobapplication` | Job applications | | `read_job` / `write_job`, `read_jobad` / `write_jobad` | Jobs and job ads (separate scopes — a job list without ads needs both) | | `read_company` / `write_company`, `read_contact` / `write_contact` | Companies and contacts | | `read_events` / `write_events` | Scheduled events (meetings/interviews) | | `read_interview` / `write_interview` | Job interviews and evaluations — distinct from `read_events` | | `read_opportunity` / `write_opportunity` | Opportunities (pipeline/deals) | | `read_note` / `write_note`, and per-object `*_note` scopes | Notes: candidate, company, contact, job, job application, placement, opportunity, requisition | | `manage_*_custom` | Custom fields, per object | | `read_placement` / `write_placement`, `read_requisition`, `read_submission`, `read_float`, `read_folder` / `write_folder`, `read_usertask`, `write_sms` | The rest of the catalogue | | `read_user`, `read_usergroup` | Users and user groups — needed by almost everything that attributes a record to a consultant | | `partner_jobboard`, `partner_ui_action` | **Partner-only.** Job-board posting/apply, and partner action buttons inside JobAdder. Gated on what you declared at registration (§2) | | `offline_access` | **Required for a refresh token at all.** Must always be combined with other scopes | Rules worth internalising: - **No `offline_access`, no refresh token.** The token response simply omits it, and the connection dies in an hour. - **Webhooks require `offline_access`** plus the scopes relevant to each event (`candidate_updated` needs `read` or `read_candidate`, and so on). A webhook you cannot subscribe to is usually a missing event scope. - Scopes never exceed what the authorizing JobAdder user may do. Which brings us to the one that surprises people: **only an admin user, or a user with "Grant API access to Integration partners", can authorize your app.** A standard consultant clicking your connect button will be refused, and that is a customer-side permission fix. - **Do not authorize with the developer-account login.** The user who authorizes must be a user of the *customer's* JobAdder account. This is a documented mix-up, and it produces a connection to the wrong data. ### As of 2026-09-20, Unified.to's JobAdder connector requests Across its ATS, CRM, HRIS, metadata and login surfaces, the connector's scope set is: `read`, `read_user`, `read_usergroup`, `read_candidate`, `write_candidate`, `read_jobapplication`, `write_jobapplication`, `read_job`, `write_job`, `read_jobad`, `read_company`, `write_company`, `read_contact`, `write_contact`, `read_events`, `write_events`, `read_interview`, `read_opportunity`, `write_opportunity`, `read_note`, `write_candidate_note`, `read_contact_note`, `write_contact_note`, and `offline_access` on every non-login operation. The login/identity path requests only `read` and `read_user`. Auth quirks of that connector, same date: it sends `prompt=select_account consent` on authorize (so a user with several JobAdder accounts is asked which one, every time); it posts the token and refresh requests as form-encoded with the client ID and secret in the body; **it reads the API base URL out of the `api` field of the token response and stores it per connection**, rather than using the production or sandbox host it lists as defaults; and it ships **no shared Unified.to JobAdder credentials** — every workspace must supply its own client ID and secret, which is the reason this skill exists. Custom-field/metadata reads go through the ordinary `read_*` object scopes, not `manage_*_custom`. Automatic new-connection testing is turned off for this connector. Note the surface split: **`jobadderassessment` is a separate connector** covering JobAdder's assessment/partner-action surface, and it requests a different, partner-gated scope set built around `partner_ui_action`. If the user's request is about assessments or partner action buttons, they may need those scopes approved at registration (§2) — but that connector is out of scope here. Confirm the current scope set and auth behaviour with the connector's owner before submitting anything; this is a dated snapshot, not a contract. ## 6. Capture the credentials From the developer portal: sign in → your application → the client ID and secret are shown on the application. They remain readable from the developer account afterwards, so this is not a shown-once secret — but treat it as one anyway and do not screenshot it into a ticket. Capture: - **Client ID** and **client secret** - Authorize URL `https://id.jobadder.com/connect/authorize` and token URL `https://id.jobadder.com/connect/token` (the same pair for every account and every region) - Which application this is — test or live - The exact authorised redirect URI list you registered Rotating the secret invalidates the old one. Existing access tokens live out their hour, but every token exchange and refresh fails until the new secret is deployed — and since refresh tokens rotate (§7), a failed refresh window can cost you the refresh token as well. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 7. Token lifetimes and the per-account API base URL The token response is where JobAdder differs most from other providers: ```json { "access_token": "...", "expires_in": 3600, "token_type": "Bearer", "refresh_token": "...", "api": "https://.jobadder.com/v2", "instance": "...", "account": 12345 } ``` | Thing | Behaviour | | --- | --- | | **Authorization code** | Valid **5 minutes**, single use. Exchange it immediately on the callback | | **Access token** | **60 minutes** (`expires_in: 3600`) | | **Refresh token** | Only issued with `offline_access`. **Expires after 2 weeks unused**; each use resets the 2-week timer | | **Refresh rotation** | Every refresh returns a **new** refresh token. Store it. A client that keeps replaying the original fails on the second refresh | | **`api`** | **The API base URL for this account.** Store it per connection and use it for every subsequent call | | **`instance` / `account`** | Identify the JobAdder account behind the token — map them to your customer record so you can tell two connections apart | **The `api` rule is the classic failure.** JobAdder runs the API on several regional clusters (its release notes name AU, EU, US and Sandbox instances). `https://api.jobadder.com/v2` is a default, not a universal host. A client that hardcodes it authenticates successfully and then 401s or 404s on every data call for accounts on another cluster — a bug that looks like bad scopes and is not. The base URL is discovered from the token response; there is no separate lookup call and nothing to derive from the account name. Two more operational facts: - **Deleted users kill connections.** If the JobAdder user who authorized is deleted (they left the company), the refresh token starts returning `invalid_grant` / `invalid_request`. That is a re-authorize by another admin, not a retryable error — handle it as a connection-level failure, not a transient one. - **Throttling is per JobAdder account.** A 429 comes with a `Retry-After` header in seconds; stop calling that endpoint for that account until it elapses, and spread requests out rather than bursting. ## 8. Verify end-to-end Authorizing with your developer-portal login proves nothing. Test the path a customer takes: 1. Authorize from a **real JobAdder account**, signed in as an admin (or a user holding "Grant API access to Integration partners"), through your platform's actual connect flow. 2. Confirm the token response carries a **`refresh_token`** — if it does not, `offline_access` was missing. 3. Confirm your client stored **`api`**, `instance` and `account`, and that the first data call goes to the `api` host rather than a hardcoded one. 4. Force a **refresh**, then force a **second** refresh using the token the first one returned. That is what catches a client ignoring rotation. 5. If webhooks are in play, create one and confirm a delivery — and note that a webhook suspends itself after 10 failed deliveries in an hour, losing every event until it is re-enabled by an update call. | Symptom | Cause | | --- | --- | | Authorize page errors, or the app is rejected outright, on a brand-new client ID | The application (or the developer account) is not approved yet (§2, §3) | | Redirect comes back with `error=access_denied` | The user declined — or is not an admin / lacks "Grant API access to Integration partners" (§5) | | Token exchange fails although the redirect worked | `redirect_uri` at exchange differs from the one sent at authorize, or is not in the authorised list (§4) | | Token exchange fails on a code that looked fine | Code older than 5 minutes, or already used once (§7) | | No `refresh_token` in the response | `offline_access` not requested (§5) | | Second refresh fails | Client is not storing the rotated refresh token (§7) | | Connection dies after a quiet fortnight | Refresh token expired from 2 weeks of disuse (§7) | | `invalid_grant` / `invalid_request` from one customer only | The authorizing user was deleted — needs a fresh authorization (§7) | | Auth succeeds, every API call 401s or 404s | Client ignored `api` and called the wrong regional host (§7) | | A scope is refused no matter how it is spelled | Partner-gated scope, or one not granted from your registration description (§2, §5) | | Sudden 429s | Per-account throttling; honour `Retry-After` (§7) | | Webhook silently stops delivering | Suspended after 10 failures in an hour; re-enable via an update call (§8) | ## 9. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a redirect host, a scope, storing the per-account base URL), keep it secret-free and say what the human must set out of band. - Close with: the developer account owner and application name; approval status of **both** gates; the client ID; where the secret was delivered; the authorize and token URLs; the exact scope strings requested and why; the registered redirect URIs; confirmation that the client stores `api`, `instance`, `account` and rotated refresh tokens; and anything still waiting on JobAdder. ## Stop and ask Hand back to a human rather than guessing when: the developer account or application is still unapproved and someone wants a workaround; JobAdder asks for commercial, legal, volume or compliance detail on the partner application; the integration needs `partner_jobboard` or `partner_ui_action` and those were not declared at registration; the client does not store rotated refresh tokens or the per-account `api` base URL (report it — do not register and hope); a customer cannot authorize because no admin will, or because their user was deleted; someone proposes using the undocumented `client_credentials` or `password` grants; a marketplace listing or published partner service is wanted (JobAdder requires written approval first); or the portal does not match the **Platform state** section above. ## References Every URL below returned 200 on 2026-09-20. - JobAdder Developers portal — https://developers.jobadder.com/ - Register as a partner (developer account application) — https://developers.jobadder.com/register - Developer portal sign-in — https://developers.jobadder.com/signin - API Reference (interactive) — https://api.jobadder.com/v2/docs - **OpenAPI document** — https://api.jobadder.com/v2/openapi.json — the authoritative source for the OAuth flow, the full scope list, the webhook event/scope matrix and the release notes - OpenID Connect discovery (endpoints, scopes, PKCE and grant support) — https://id.jobadder.com/.well-known/openid-configuration - JobAdder API Terms — https://jobadder.com/api-terms - Partnership request (commercial partner track, **not** credentials) — https://jobadder.com/partnership-request/ JobAdder's help centre (`jobadderapi.zendesk.com`) serves its `/hc/` article pages behind a bot check and returns 403 to automated fetches; they open normally in a browser. The identical article content is served — and verified 200 — at these JSON endpoints: - OAuth2 Authentication — https://jobadderapi.zendesk.com/api/v2/help_center/en-us/articles/360022196774.json - Developer Account Applications — https://jobadderapi.zendesk.com/api/v2/help_center/en-us/articles/360023091673.json - Redirect URIs — https://jobadderapi.zendesk.com/api/v2/help_center/en-us/articles/360022835513.json - Partner Tech Integration — https://jobadderapi.zendesk.com/api/v2/help_center/en-us/articles/7040444063503.json - Webhooks — https://jobadderapi.zendesk.com/api/v2/help_center/en-us/articles/360022511513.json - API Throttling — https://jobadderapi.zendesk.com/api/v2/help_center/en-us/articles/4410850130713.json - Authorization Denied — https://jobadderapi.zendesk.com/api/v2/help_center/en-us/articles/360022474453.json ## lever-oauth-app URL: https://docs.unified.to/skills/lever-oauth-app/SKILL.md # Lever API Credentials **Read this first: there is no Lever developer portal where you register an app and walk away with a client ID and secret.** Lever's own OAuth page says it plainly — "OAuth is part of our partner integration program, and apps must be registered" — and registration means a form you submit to Lever, after which, in Lever's words, "someone on the Lever team will create your OAuth app and send over your ClientID and Secret." Humans issue the credentials, by hand, and only to approved partners. Worse for anyone hoping to shortcut it: Lever states that it does **not** hand out API keys for partner development either ("OAuth is a requirement of the integrations program, and for that reason we do not provide API Keys for development"). If the user's goal is "get Lever OAuth credentials this afternoon," the honest answer is that it is not available at any price without the partner program, and the run should stop at §3. What *is* self-serve is different, and it is what most people actually have: **a Lever customer can create their own Data API key** inside their own account, in Settings, in about a minute. That key is HTTP Basic (key as username, empty password), scoped to endpoints the Super Admin ticks at creation time, and valid only for that one Lever account and that one environment. A platform can run on this — you ask each customer to generate a key and paste it in — and it is the only path open to a non-partner. Decide which of the two models you are building against before doing anything else (§1). Three more things cost real time here: the **`audience` parameter** that is not optional and whose absence produces a consent screen listing nothing but `offline_access` (§5), the **sandbox and production hosts that share no credentials and not even the same auth server** (§4), and the **Super Admin rule** — only a Super Admin can authorize your app or mint a key at all (§6). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Which credential model** | Partner OAuth, or per-customer Data API keys? (§1) | | **Partner status** | Has the partnership interest form been submitted / approved? Is there a sandbox account? (§3) | | **Integration name** | Company or product name customers will recognize; goes on the consent screen (§4) | | **Description**, 20–140 characters | Lever enforces both bounds; shown in the Lever ecosystem (§4) | | **Square logo URL** | A direct image link, ~150×150, **not** a Google Drive or download link (§4) | | **Callback URI(s)** | Every one, up front; separate apps per environment (§4, §5) | | **Scope set** | Exact `::admin` strings, plus `offline_access` (§6) | | **Environment** | Sandbox and production are two apps with two credential pairs (§4) | | **Data centre** | US or EU customers — the API host differs (§5) | ## Quick Start 1. Establish which **credential model** this needs — partner OAuth or per-customer API keys (§1). Most wrong turns start here. 2. Confirm whether credentials already exist worth reusing — re-issuing orphans every existing authorization (§2). 3. If partner OAuth: submit the partnership interest form, get approved, get a sandbox account (§3). 4. Submit the OAuth registration form with name, description, callbacks, logo and scopes; Lever emails back the pair (§4). 5. Build against **sandbox** hosts, including the sandbox auth server — it is a different domain entirely (§5). 6. Pin down scopes *and* that the authorizing user is a **Super Admin** — neither substitutes for the other (§6). 7. Check rate limits, confidential-data access and plan-gated endpoints before promising a launch date (§7). 8. Verify with a real authorize → callback → refresh → refresh-again round trip, against a second account (§8). 9. Hand the secret to a human, never to source control (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **OAuth credentials are issued by Lever staff, not by a portal.** Both the OAuth page and the Partner page say so. There is no console where a developer creates an app. - **The "register" button on Lever's own OAuth and Partner pages is misdirected.** As of this verification it links to `https://hire.lever.co/settings/integrations` — a *customer's* own Lever settings page, which just bounces an anonymous visitor to a login screen. It is not a registration form. Do not send a user to click it and expect a form; ask the Lever integrations team (`integration-support@lever.co`) for the current registration form link, or start from the partnership interest form (§3). Re-check whether this has been fixed before writing it off. - **Since 2020, all Lever *integrations* are OAuth.** API keys remain, explicitly, for customers building their own internal workflows. - **Sandbox runs on a different identity provider.** Production authorizes at `https://auth.lever.co`; sandbox authorizes at `https://sandbox-lever.auth0.com`. Both are Auth0 underneath (a bare production authorize request returns an Auth0-styled error page), which explains the `audience` parameter and the scope grammar. - **The EU data centre is documented inconsistently.** The US OAuth page says EU is handled for you — "Any integration authorization or action will be re-directed automatically to the appropriate data center." But Lever's EU copy of the API docs (`https://hire.eu.lever.co/developer/documentation`) documents EU-specific hosts: `https://auth.eu.lever.co/authorize`, `https://auth.eu.lever.co/oauth/token` and audience `https://api.eu.lever.co/v1/`. On 2026-09-20, `https://api.eu.lever.co` answered (401, as an authenticated host should) but every path probed on `auth.eu.lever.co` returned 404. **Do not pick one and build on it.** Ask Lever which auth host EU customers must use, and test it before you promise EU support. - **Lever is an Employ Inc. product** (alongside JazzHR and Jobvite). Partner and marketing pages sit on `lever.co`; the partner ecosystem now lives at `marketplace.lever.co` (`leverpartner.com` redirects there). - **The partner help centre is behind a login.** `partnerexperience.lever.co` returned 401 unauthenticated, so any step documented only there cannot be read from outside — a reason to ask the partner contact rather than guess. - **The API is still v1 and actively maintained** — the changelog shows new endpoints in April 2026 (deleted applications, deleted postings, opportunity file actions, application HTTP metadata fields). If the developer pages do not look like this, stop and report what you actually see. ## 1. Which credential, and who can get it | Surface | Credential | Hosts | Who gets it | | --- | --- | --- | --- | | **Data API** (core ATS read/write) via **OAuth 2.0** | Client ID + secret, authorization code grant | authorize/token on `auth.lever.co`; API `api.lever.co/v1/` | **Approved partners only**, issued by Lever staff (§3, §4) | | **Data API** via **API key** | Single key, HTTP Basic — key as username, **empty password** | `api.lever.co/v1/` | Any customer Super Admin, self-serve, for their own account (§4 Path B) | | **Postings API** | **None — public and unauthenticated** | `api.lever.co/v0/postings/` | Anyone. A live probe on 2026-09-20 returned HTTP 200 with no credentials at all | Consequences worth stating out loud before anyone starts work: - **The Postings API is not a cheaper Data API.** It returns only *published* job postings for a company's job site — Lever's own FAQ says internal, closed and draft postings are visible solely through the authenticated Data API. It is the right tool for a careers page or a job-board feed (`?mode=xml`), and useless for anything candidate-shaped. - **An API key is per-account and per-environment.** A key minted in a sandbox tells you nothing about production, and a key from customer A cannot read customer B. There is no multi-tenant API key. - **The two models differ in who your customers can be.** Lever's partner FAQ states that integrations using OAuth and programmatic webhooks are available to **all** customers, whereas older API-key integrations and manually configured webhooks require the customer to have the **Data API** and **Webhook** features in their plan. Choosing the API-key path therefore narrows your addressable customers, quietly, by plan tier. > **Product fact — dated.** As of **2026-09-20**, Unified.to's Lever connector supports **both** models: OAuth 2.0 and > an API-key mode that uses the documented Basic scheme with the key as username and an empty password. It records > **two** environments and no others — **Production** (API `https://api.lever.co/v1/`, authorize > `https://auth.lever.co/authorize`, token `https://auth.lever.co/oauth/token`, audience `https://api.lever.co/v1/`) > and **Sandbox** (API `https://api.sandbox.lever.co/v1/`, authorize and token on `https://sandbox-lever.auth0.com`, > audience `https://api.sandbox.lever.co/v1/`) — so there is **no EU-specific entry**, which matters given the EU host > discrepancy above. It sends `audience` as an authorize-time parameter, and sends client ID and secret in the token > request **body** rather than as HTTP Basic. It ships **no shared Unified.to OAuth credentials for Lever**: every > workspace must supply its own client ID and secret, which in practice means its own Lever partnership. Its scope > sets are the granular `::admin` strings chosen per unified object — `offline_access` on every set, > plus `opportunities`, `contact`, `resumes`, `postings`, `users`, `interviews`, `stages`, `files`, `notes` and > `webhooks` at `:read:admin` or `:write:admin` — with a login/authorize set of `offline_access` plus opportunities > read and write. It collapses a read scope into the matching write scope when both would be requested, which mirrors > Lever's documented de-duplication behaviour (§6). It does **not** request `confidential:access:admin`, requisition, > offer, EEO or form scopes, and interview *write* is not currently enabled. Its sandbox is recorded as not > self-serve, gated on partner approval, with a named Lever partner contact. **Confirm all of this with the > connector's owner before acting on it** — connector configuration changes independently of this skill. ## 2. Reuse the existing credentials, or request new ones New partner credentials mean a **new client ID, and every existing customer authorization is bound to the old one** — every customer re-authorizes. Reuse what exists for: adding a callback URL, adding a scope, rotating a compromised secret, or diagnosing a failure. Lever handles all four on the existing app, by request (§4). Request a **new** app only when the user explicitly wants one: a separate product, a replacement for a compromised client, or the deliberate sandbox → production promotion, which *is* a second app by design (§4). Say which path you are taking before you start. For **per-customer API keys** the calculus is gentler: each key is independent, so rotating one affects exactly one tenant — but note that a regenerated key is a hard cutover, with no grace window documented. ## 3. Accounts: the partner program, and a sandbox to build in **The partner program is the gate.** Start at the partnership interest form (`https://www.lever.co/partnershipinterest`) — Lever's stated sequence is: submit the form → internal review by the partnerships team → sandbox access → go live in the marketplace. What the run needs to know: - **Approval comes before anything technical.** Lever provides the sandbox account only once you are approved; the sandbox is not available to someone evaluating whether to build. - **Completing and releasing an integration is a requirement of partnership**, per Lever's partner FAQ. Partnership is not a credential you collect and sit on. - **Lever will not give you a customer list.** Their FAQ says so explicitly, so do not plan an outreach step around it. - **Production credentials are the *last* step, not the first.** Lever's four-step process is: register for sandbox OAuth → build → submit to move to production → finalize. Moving to production requires a help-centre article you write, **two sandbox logins created for `sadmin@levertest.com` and `integration-support@lever.co`**, ecosystem listing copy, technical support contact details, and a live **QA run-through call** where you screen-share the whole customer journey. Only after that does Lever issue production OAuth credentials. Plan in weeks, not days. - The agreement, co-marketing and listing terms are **business decisions**. Do not fill in revenue, security or compliance claims on the user's behalf — collect the questions and hand them back. **The sandbox has one behaviour that wastes an afternoon if you do not know it: email is turned off.** Invited users receive no invitation email and must sign in through the login link directly. A warning about this appears in the sandbox UI and is expected. Anything requiring a human — submitting the interest form, email verification, scheduling the QA call, accepting terms — hand back rather than looping. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the callback URLs from §5 and the scope strings from §6 — or a ready-to-send message to `integration-support@lever.co`, then continue once they report back with the client ID. ## 4. Obtaining the credentials ### Path A — Partner OAuth, for a multi-tenant connector Registration is a **form you submit and a human fulfils**. Lever's OAuth page lists exactly five things it asks for: 1. **Integration name** — your company name, or the product being connected. 2. **Description** — **between 20 and 140 characters**, enforced. It is displayed in the Lever integration ecosystem. 3. **Callback URI(s)** — see §5. On sandbox these may be `http`; in production they may not. 4. **Square logo URI** — a real image link. Lever explicitly rejects a Google Drive link or a link to a download. Square, recommended 150×150, rendered on a white background in both the product and the authorization module. 5. **Required scopes** — the exact strings (§6). Lever says most use cases need **5 to 8**, with an **absolute maximum of 20**. Lever's guidance worth heeding: **save a copy of your submission.** You will want it when debugging, because nothing in the flow shows you what was registered. You then get back a **Client ID** and **Client Secret** by email from the Lever team. There is no console to re-read them in — treat the secret as shown once and store it immediately. **The flow itself**, once you hold credentials (production hosts shown; swap in the sandbox hosts from §5 while building): - Authorize: `GET https://auth.lever.co/authorize` with `client_id`, `redirect_uri`, `response_type=code`, `state`, `scope` (space-separated) and **`audience`** — Lever documents all six, including `state` and `audience`, as **required**. PKCE is not part of the documented flow. - Exchange: `POST https://auth.lever.co/oauth/token`, `content-type: application/x-www-form-urlencoded`, body carrying `grant_type=authorization_code`, `client_id`, `client_secret`, `code` and `redirect_uri`. Client credentials go in the **body**, not an HTTP Basic header. - Response: `{ access_token, refresh_token, token_type: "Bearer", scope, expires_in }`. - Call the API with `Authorization: Bearer ` against `https://api.lever.co/v1/`. - Refresh: same token endpoint, `grant_type=refresh_token` plus `client_id`, `client_secret`, `refresh_token`. **Lifetimes, and the one that bites:** | Token | TTL | What breaks | | --- | --- | --- | | Access token | **1 hour** (`expires_in: 3600`) | Routine; refresh proactively | | Refresh token | **1 year, or 90 days of inactivity**, whichever comes first — and **a new refresh token is returned on every refresh** | A connection nobody touches for a quarter is dead and the customer must re-authorize. Refresh on a schedule, not lazily | **Store the new refresh token from every refresh response.** Lever's docs say the refresh returns a fresh `refresh_token`, and the example even shows a different token format (`v1.…`) coming back from refresh than from the initial exchange. A client that keeps replaying the original will work once and then stop. **Do not send `prompt=consent`.** Lever removed it from the documented flow in June 2024 because forcing consent on an already-authorized app **blocked non-admin Microsoft 365 SSO users from authorizing at all**. Consent is now requested only when the app is newly authorized, re-authorized after revocation, or asking for new scopes. ### Path B — Customer-created Data API key This is what a customer does in their own account, and the only path open without a partnership: 1. A **Super Admin** goes to **Settings → Integrations and API → API Credentials**. 2. Under **Lever API credentials**, click **Generate New Key**. 3. Tick the read and write **endpoints** the integration needs — the key's permissions are fixed by these choices. 4. If the integration must see confidential postings, opportunities or requisitions, enable **"Allow access to confidential"** — Lever documents this as grantable **only at key creation**. A key created without it can never be upgraded; you regenerate. 5. **Copy the key now.** Lever states this is the one and only time it is shown. 6. Use it as HTTP Basic with the key as the **username and an empty password** — so the header is Base64 of `KEY:`. The trailing colon is not optional, and omitting it is a classic silent 401. Multiple keys can be active at once, which is the sane way to give each integration its own revocable credential. ## 5. Callback URLs, environments and the `audience` parameter Register **every** callback host in your registration submission. Lever's FAQ says you may register multiple callbacks, that they are registered against the app rather than per customer, and that the `redirect_uri` you send must match a registered value. Lever also notes you may register with one callback and request additional ones through support — an email each time, so get the list right on day one. For Unified.to these are one per data centre; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` **Sandbox and production are two separate apps.** Lever's FAQ is explicit: callbacks can be unique to sandbox and production, because "you will have two separate apps, one for each environment, and the configuration can be different." During development on sandbox, callbacks may be plain `http`. Nothing about a working sandbox app implies anything about production — different client ID, different secret, different auth server, different API host: | | Authorize / token host | API base | `audience` | | --- | --- | --- | --- | | **Production** | `https://auth.lever.co` | `https://api.lever.co/v1/` | `https://api.lever.co/v1/` | | **Sandbox** | `https://sandbox-lever.auth0.com` | `https://api.sandbox.lever.co/v1/` | `https://api.sandbox.lever.co/v1/` | | **EU** | see Platform state — **verify with Lever** | `https://api.eu.lever.co/v1/` | `https://api.eu.lever.co/v1/` | **The `audience` parameter is the single most expensive thing to get wrong.** It is required, it must be the API base URL, and Lever stresses that it **must end in a trailing slash**. Omit it or malform it and the authorization does not fail loudly: the customer sees a consent screen granting **only `offline_access`**, authorizes happily, and every subsequent API call is forbidden. Lever's own debugging table lists exactly this symptom. Note also Lever's warning that their Postman template works only against sandbox, precisely because it does not send `audience`. ## 6. Scopes — and the permission rule that outranks them Lever scopes are space-separated in the authorize URL and shaped `::admin` — `opportunities:read:admin`, `postings:write:admin`, `interviews:read:admin`, `files:write:admin`, `webhooks:write:admin`, and so on across roughly fifty documented strings. Plus `offline_access`, which is not a resource at all: **include it or you get no refresh token.** Rules that change what you should request: - **A write scope contains its read scope.** Lever: "Write:admin scopes include all Read:admin endpoints." Requesting both `contact:read:admin` and `contact:write:admin` is redundant, and Lever's debugging guide notes that when both are registered **only the write scope is included** — which can make a `scope` response look like it lost a value. Request the write scope alone for a resource you write. - **Formatting is unforgiving.** A single space between scopes; no commas, no leading or trailing whitespace. - **Scopes must be registered on the auth server before you can request them.** Asking for a scope your app was not registered with fails at authorize time; adding one is a request to Lever, not a config change — and adding a scope later means existing customers must re-authorize to gain it. - **Budget 5–8, cap 20.** Lever states both numbers. A maximal scope list is not free: it is what the customer's Super Admin reads on the consent screen. - **Confidential data needs `confidential:access:admin`**, and it is a separate scope from the resource scopes. On the API-key path the equivalent is a toggle set at key-creation time only (§4 Path B). **The rule that outranks all of the above: a Lever Super Admin must be the one who authorizes.** Lever's OAuth page says Super Admins "will authenticate your app on behalf of their whole organization," and the same page is where they later see your app — rendered with the logo you submitted — and revoke it. A perfectly scoped app pointed at a non-admin user does not connect. Lever's documented meaning for **403** on the Data API is the same idea from the other side: "Your Lever account settings don't authorize you to perform the requested operation. Talk to a Super Admin on your Lever account to update your API settings." ## 7. Rate limits, plan gates, confidential data, listing - **Rate limits are a token bucket, per API key**, with **no per-endpoint limits**: a steady state of **10 requests/second**, bursting to **20 requests/second** when Lever can allow it. Lever says explicitly that these defaults are **not guaranteed**, vary with server load, and may change — so implement **exponential backoff** on `429` rather than pacing to a hardcoded number. - **Plan gates exist and are invisible from your side.** Requisitions endpoints require the customer to have the Lever TRM Enterprise package or the Advanced HR feature. Older API-key integrations and manual webhooks require the Data API and Webhook features respectively. OAuth integrations with programmatic webhooks are, per Lever, available to all customers. - **Confidential objects are invisible without explicit access.** Postings, opportunities and requisitions can be marked confidential; without confidential access, requests for them return an access error rather than an empty result you could mistake for "this customer has none." - **Pagination is cursor-based**: list responses carry `data`, `next` and `hasNext`; pass `next` back as `offset`. `limit` ranges 1–100, defaulting to 100. Only an offset Lever gave you is valid. - **Listing** happens in the Lever marketplace (`marketplace.lever.co`) after the QA review. Your integration is listed as *coming soon* once it works end to end against your help-centre article, and flipped to *available* only when you confirm production is live. ## 8. Verify end-to-end Do not stop at "the token came back." 1. Authorize through your platform's real connect flow against a **second** Lever account — a sandbox tenant, or a mutual customer's — not the one that built the integration. 2. **Read the consent screen.** If it lists only `offline_access`, your `audience` is missing or malformed (§5). This is the check that catches the most common Lever failure, and it takes five seconds. 3. Confirm the token response carries a **`refresh_token`**, and that `scope` lists what you expected — remembering that a write scope swallows its read twin (§6). 4. Make one real read against the right host for the environment. A sandbox client ID against the production API, or the reverse, produces signing-key errors rather than an honest 401 (§5). 5. **Refresh, then refresh again using the token returned by the first refresh.** Two minutes of work for a failure that otherwise appears a day later. 6. Repeat step 4 as a **non-Super-Admin** user if any customer might connect as one. Expect failure, and design for it. 7. Exercise pagination past the first page, with an offset Lever actually returned. | Symptom | Cause | | --- | --- | | Consent screen shows only `offline_access` | `audience` missing or not ending in a trailing slash (§5) | | Redirect window shows `undefined` | A parameter in the authorize URL does not match the registration — usually the callback URI (§5) | | `403 … Error: ClientID not found` | Wrong auth host for the environment (sandbox client against `auth.lever.co`, or the reverse) (§5) | | `403 … Unable to find a signing key that matches` | Calls going to the wrong API host for the environment (§5) | | `401 Unauthorized` right after registration | Client **secret** field populated with the client ID/key instead of the secret (§4) | | `403 … The key you provided does not have access to this endpoint` | Scope not requested, not registered, or shadowed by its write twin (§6) | | Scope list comes back shorter than requested | Read scope de-duplicated into the write scope — expected (§6) | | Authorize rejected for one customer only | The authorizing user is not a Super Admin (§6) | | 403 on the Data API for one customer | Their Lever account settings / API permissions — a Super Admin must change them (§6) | | Second refresh fails, first succeeded | Client is not storing the rotated refresh token (§4) | | Connection dies after a quiet quarter | 90-day inactivity expiry on the refresh token (§4) | | Sandbox works, production 401s | Separate apps, separate credentials, separate hosts (§5) | | Requisition endpoints 403 for some customers | Plan gate: TRM Enterprise or Advanced HR (§7) | | Confidential postings missing, no error on the list call | Confidential access not granted at credential creation (§4 Path B, §7) | | `429` under normal load | 10 req/s steady, 20 burst, per key; back off exponentially (§7) | | Non-admin Microsoft 365 SSO users cannot authorize | Client still sending `prompt=consent` (§4) | ## 9. Hand off — never commit the key - **Do not** write a client secret or API key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the human running this, for their secret store or console. The Postings API needs no credential at all and its site name may be recorded freely — keep the two apart in your head. - If a code change is needed (a callback host, a scope string, the `audience` value), keep it secret-free and say plainly what the human must set out of band. - Report a secret once so it can be pasted into the secret store, say clearly that it is now in the transcript and can be rotated, then move on. - Close with: which credential model you ended on; the client ID (or which customer holds which key); where the secret was delivered; the environment and its full host triplet — authorize, token, API base — and the exact `audience` string; the scope strings requested; the Super Admin requirement to pass to customers; the refresh cadence the 90-day inactivity window demands; and whatever is still waiting on Lever. ## Stop and ask Hand back to a human rather than guessing when: **there is no Lever partnership** and the ask is OAuth credentials (say so in the first reply — do not start a registration that cannot complete); the partner form, agreement or QA review needs business, security or compliance claims; someone proposes re-issuing partner credentials on a live integration (that re-authorizes every customer); a scope request comes back refused; the target is **EU customers** and the auth host question in Platform state is unresolved; a customer cannot find the API Credentials tab (they are not a Super Admin); confidential-data access is needed on a key that already exists (it cannot be added after creation); or the developer pages no longer match the Platform state section above. Note also that this skill was written without browser automation: every fact here came from fetching Lever's public documentation and probing its hosts directly. A run that also has no browser should hand click paths and form content to the user rather than simulating a portal session. ## References Verified to return HTTP 200 on 2026-09-20. - Lever API overview, authentication, scopes, rate limits, pagination — https://hire.lever.co/developer/documentation - OAuth for partner product integrations (registration inputs, FAQ, debugging table) — https://hire.lever.co/developer/oauth - Partner product integration process (the four steps) — https://hire.lever.co/developer/partner - Developer FAQ (Postings API vs Lever API, how to get an API key) — https://hire.lever.co/developer/support - API changelog / updates (incl. the `prompt=consent` removal) — https://hire.lever.co/developer/updates - Use cases — https://hire.lever.co/developer/usecases - Deprecated objects (candidates vs opportunities vs contacts) — https://hire.lever.co/developer/deprecated - Developer home — https://hire.lever.co/developer - Sandbox copy of the API docs (sandbox hosts and audience) — https://hire.sandbox.lever.co/developer/documentation - Sandbox copy of the OAuth page — https://hire.sandbox.lever.co/developer/oauth - Sandbox copy of the partner page — https://hire.sandbox.lever.co/developer/partner - EU copy of the API docs (EU hosts — see Platform state) — https://hire.eu.lever.co/developer/documentation - Generating and using API credentials (customer-side key creation) — https://help.lever.co/s/article/Generating-and-using-API-credentials - Using Lever's XML job posting feed (Postings API feed mode) — https://help.lever.co/s/article/Using-Lever-s-XML-job-posting-feed - Lever career site options — https://help.lever.co/s/article/Lever-Career-Site-Options - Lever support contact — https://help.lever.co/s/contactsupport - Become an integration partner (interest form) — https://www.lever.co/partnershipinterest - Lever partners overview — https://www.lever.co/partners - Partner marketplace / ecosystem — https://marketplace.lever.co/ - Postings API, live example (public, unauthenticated) — https://api.lever.co/v0/postings/lever Lever's OAuth and Partner pages also link an **Integrator Resources** repository on GitHub (example app and Postman collection) at `github.com/lever/integrator-resources`. It could not be reached from this session — GitHub access was blocked by session policy, not by Lever — so it is named here rather than listed as verified. ## linear-oauth-app URL: https://docs.unified.to/skills/linear-oauth-app/SKILL.md # Linear OAuth2 App Registration Get a working Linear OAuth2 client — a workspace to own it, an OAuth application, redirect URIs, a scope set, client ID and client secret — for a platform that connects many customers' Linear workspaces. Linear has no separate developer portal. An OAuth application is a row in an ordinary workspace's admin settings, which makes four things expensive to get wrong, and none of them is the form: 1. **Who owns it.** The app lives inside a Linear workspace and **every admin of that workspace can see and edit it**. Linear's own recommendation is to create a dedicated workspace for the app. Put it in the company workspace and every admin — now and in future — holds your client secret (§2). 2. **Distribution.** A new app defaults to **private**: usable only in the workspace that created it. A multi-tenant connector needs **public**. Nothing about the authorize URL tells you this is wrong; other workspaces simply cannot complete the flow (§3). 3. **Tokens now expire.** Linear migrated **all** OAuth apps to short-lived tokens on **1 April 2026**. Access tokens last ~24 hours and refresh tokens **rotate on every use**. Any client written against the old "Linear tokens don't expire" behaviour works perfectly for a day and then fails everywhere at once (§7). 4. **`actor`.** One query parameter decides whether the app acts as each authorizing user or as a single workspace-wide app identity — and the app-identity mode requires a workspace **admin** to install and **cannot request `admin` scope**. It is bound to the authorization, so it is not something you change later without re-authorizing (§6). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, icon, description, homepage URL | Name is 2–80 chars and **must not contain "Linear"** or `http://`/`https://` (§3) | | **Developer name** (person or company) | Required field, 2–80 chars (§3) | | **Which Linear workspace owns the app** | Every admin of it gets access — prefer a dedicated workspace (§2) | | **Distribution: private or public** | Public is required for other workspaces to install (§3) | | **Redirect URIs** | Every callback host your platform serves (§4) | | **Which operations the connector performs** | Decides the scope set; read / write / issues:create / comments:create (§5) | | **Actor mode: user or app** | Per-user attribution, or one workspace-wide app identity (§6) | | **Webhooks wanted?** | Enabled on the app itself; auto-creates a webhook per authorizing org (§8) | | **Client credentials tokens wanted?** | Separate toggle, server-to-server only, 30-day tokens (§7) | | **Integration Directory listing wanted?** | Optional, reviewed by humans, company-built apps only (§9) | ## Quick Start 1. Confirm a **new** app is needed — a new client ID orphans every existing customer connection (§1). 2. Sign in as a **workspace admin**, ideally of a workspace created just to own this app (§2). 3. **Settings → Administration → API → Applications → New application**, or go straight to `https://linear.app/settings/api/applications/new` (§3). 4. Set **distribution to public** if other workspaces must install it (§3). 5. Add **every** redirect URI, exactly (§4). 6. Decide the scope set; scopes are sent **comma-separated**, and `read` is always present (§5). 7. Decide `actor=user` vs `actor=app` before shipping the authorize URL (§6). 8. Capture client ID and client secret; plan for **24-hour tokens and rotating refresh tokens** (§7). 9. Enable app webhooks and pick resource types if you need push events (§8). 10. Verify from a **second, unrelated workspace**, as a **non-admin** if customers will be (§10). 11. Hand the credentials over — never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The docs moved.** `developers.linear.app` now 301-redirects to **`https://linear.app/developers`**. Old deep links such as `developers.linear.app/docs/oauth/authentication` land on the developers home page, not the OAuth page. - **All OAuth apps were migrated to the refresh-token system on 1 April 2026.** Newly created apps issued refresh tokens by default from **1 October 2025**; existing apps had until 1 April 2026. Access tokens now return `expires_in: 86399` (~24 hours) with a `refresh_token`. Refresh returns a **new access token and a new refresh token**. There is a **30-minute grace period** in which the original refresh request can be replayed to recover a rotated refresh token lost to a network error. - **The documented scope list is:** `read`, `write`, `issues:create`, `comments:create`, `timeSchedule:write`, `admin`. Agent/app scopes are documented separately: `app:assignable`, `app:mentionable`, `customer:read`, `customer:write`, `initiative:read`, `initiative:write`. Scopes are **comma-separated**, and `read` "will always be present". - **`actor=app` supersedes the deprecated `actor=application`.** Installing with `actor=app` is a **workspace-scoped** install and **requires admin permissions**. Apps using `actor=app` **cannot also request `admin` scope**. - **Agent APIs are an explicit Developer Preview** — Linear states functionality may change before general availability. Do not build a customer commitment on them without saying so. - **PKCE is supported** (`code_challenge` / `code_challenge_method`, `plain` or `S256`) and is optional; the plain confidential-client exchange with `client_secret` still works. - **`client_credentials` is supported** but must be toggled on per app. Those tokens are **app-actor tokens**, valid **30 days**, with **no refresh token**, capped at **1000 active tokens** per app, all of which must share the same scopes — requesting different scopes revokes the rest. - **Secrets can be rotated in place** (Settings → API → **Rotate secret**). Rotating the **client secret** immediately invalidates client-credentials tokens but **does not** revoke existing user access/refresh tokens. Rotating the **webhook signing secret** takes effect immediately. - **OAuth app manifests exist**: the creation page accepts pre-fill query parameters (`distribution`, `oauth.redirect_uris`, `webhook.resourceTypes`, …) or a whole JSON manifest, schema version `1.0.0`, published at `https://linear.app/.well-known/oauth-app-manifest.schema.json`. Redirect URIs are capped at **32**. - **Rate limits are per user (or app user) per hour**, not per app: OAuth apps get **5,000 requests/hour** and **2,000,000 complexity points/hour**; a single query may not exceed **10,000 complexity points**. Linear **dynamically increases** limits for workspace-level apps using actor authorization, based on the workspace's paid seat count. - **Third-party app approvals** are available on **any paid plan** and are off by default. **On Free plans every user is an Admin**, which is why a free test workspace cannot reproduce a customer's non-admin experience. - Unverified: Linear documents `distribution` as a creation-time field with default `private`, but does not state whether it can be flipped to `public` later. Check the app's settings page before assuming either way. If the settings page does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new application means a **new client ID, and every existing customer authorization is bound to the old one** — every customer re-authorizes. Reuse the existing app for: adding a redirect URI, changing the scope set, rotating a leaked secret, enabling webhooks or client credentials, or diagnosing an authorization failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, a deliberate environment split, or — the Linear-specific case — because the existing app sits in a workspace whose admin list can no longer be trusted with the secret, or because it was created private and cannot be made public. Note that scope changes are also customer-visible: a token carries only the scopes granted when it was issued, so broadening scopes re-authorizes everyone. Say which path you are taking before you touch the settings page. ## 2. Account and workspace — this is the decision people skip - Sign in at `https://linear.app/login`, or sign up at `https://linear.app/signup`. - **You must be a workspace admin.** Linear states plainly that admin permissions are necessary to even *view* Settings → Administration → API, which is where OAuth applications and webhooks live. - **Every admin of the owning workspace can access the application** — including its client secret. Linear's own recommendation in the Integration Directory guide is to build with OAuth and keep "a separate workspace for the application, which gives all admins access to the application (instead of only the application creator)." Read that the right way round: a dedicated workspace is how you get *deliberate* shared access instead of accidental exposure, and how you avoid the app dying with one employee's account. - **A free workspace is enough** to create and test an app, and agents installed in a workspace do not count as billable users. But on Free plans **all users are Admins**, so you cannot rehearse the customer path where a plain Member hits the app-approval wall (§9). For that you need a paid test workspace. - Record who owns the workspace, and put the owning workspace's name in the handoff summary (§11). Hand control back for anything only a human can do: email verification, 2FA, accepting terms, being made an admin, or submitting the directory form. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an ordered click path with the literal values to paste — the redirect URIs from §4 and the scope list from §5 — or, better, build a **pre-filled creation link** (§3) so there is nothing to mistype. Continue once they report back with the client ID. ## 3. Create the application **Settings → Administration → API → Applications → New application**, or go directly to `https://linear.app/settings/api/applications/new`. Fields that carry more weight than they look: | Field | Rule | | --- | --- | | **Application name** | 2–80 characters. **Must not contain "Linear"**, and must not contain `http://` or `https://`. In actor-app mode this is also how the app appears in mention and filter menus, so keep it short, recognisable and unique. | | **Developer name** | 2–80 characters. Required for a complete setup. | | **Icon URL** | Absolute HTTP(S) URL; at least 256×256px recommended. Shown during authorization. | | **Homepage / client URI** | Absolute HTTP(S) URL. Required for a complete setup. | | **Description** | Up to 1000 characters. Shown during authorization and in settings. | | **Distribution** | `private` (default) or `public`. **Private means only the owning workspace can install it.** | | **Redirect URIs** | See §4. Up to 32, unique, absolute HTTP(S). | | **Grant types** | `authorization_code` is always required; `client_credentials` is an extra toggle (§7). | | **Webhooks** | Enable, set the URL, and pick resource types (§8). | **Distribution is the multi-tenant trap.** The default is private. A private app authorizes fine inside its own workspace, so it looks healthy right up until a customer tries to connect and cannot. If other organizations must install this, set it to public. Linear does not document whether the value can be changed after creation — check the app's settings page rather than assuming, and if it cannot be changed, that is a new-app migration (§1), not a tweak. ### Pre-filling the form (worth using every time) The creation page accepts a manifest, either as dot-notation query parameters or as a single URL-encoded `manifest` JSON blob. This removes transcription errors from redirect URIs entirely — which matters, because a mistyped redirect URI fails at the *token exchange*, not at save time. Repeat a parameter to supply array values; unsupported parameters are ignored; if both a manifest and dot-notation parameters are present, the manifest wins. ``` https://linear.app/settings/api/applications/new ?distribution=public &developer.name= &oauth.client_name= &oauth.client_uri=https%3A%2F%2Fexample.com &oauth.redirect_uris=https%3A%2F%2Fapi.unified.to%2Foauth%2Fcode &oauth.redirect_uris=https%3A%2F%2Fapi-eu.unified.to%2Foauth%2Fcode &oauth.redirect_uris=https%3A%2F%2Fapi-au.unified.to%2Foauth%2Fcode &oauth.redirect_uris=https%3A%2F%2Fapi-dev.unified.to%2Foauth%2Fcode &oauth.grant_types=authorization_code ``` The JSON manifest form is the canonical version (`schemaVersion: "1.0.0"`), validates against Linear's published JSON Schema, and is the right thing to keep in a repo if this app is ever recreated. ## 4. Redirect URIs Register **every** callback host your platform serves. Linear allows up to **32**, they must be unique, and they must be absolute HTTP or HTTPS URLs. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` `redirect_uri` is **required on the authorize call and required again, identical, in the token exchange body**. Linear documents it as "Same redirect URI which you used in the previous step" on both the standard and PKCE exchanges. A mismatch surfaces as a failed exchange after the user has already consented — the worst place to find it. Treat matching as exact: register the literal strings, and do not rely on trailing-slash or subpath forgiveness. Linear's examples include a `http://localhost:3000/oauth/callback` redirect URI, so loopback HTTP is accepted for development. Do not leave a localhost URI on a production app. ## 5. Scopes Scopes go in the authorize URL's `scope` parameter as a **comma-separated** list — not space-separated, which is what most OAuth tooling emits by default. The full documented set: | Scope | What Linear says it grants | | --- | --- | | `read` | Read access for the user's account. **Always present**, whether or not you ask. | | `write` | Write access for the user's account. | | `issues:create` | Create new issues and their attachments. | | `comments:create` | Create new issue comments. | | `timeSchedule:write` | Create and modify time schedules. | | `admin` | Full access to admin-level endpoints. | | `app:assignable` | Let the app be assigned as a **delegate** on issues and made a project member (agents, §6). | | `app:mentionable` | Let the app be @mentioned in issues, documents and other editor surfaces (agents, §6). | | `customer:read` / `customer:write` | Read / read-and-write customer entities (agents, §6). | | `initiative:read` / `initiative:write` | Read / read-and-write initiative entities (agents, §6). | Three rules worth internalising: - **Prefer the narrow write scopes.** Linear's own guidance: "If your application only needs to create comments, use a more targeted scope." `issues:create` and `comments:create` exist precisely so you do not have to ask for `write`. - **`admin` is not a convenience scope.** Linear: "You should never ask for this permission unless it's absolutely needed." It is also what a workspace admin looks at hardest before approving the app (§9). The one thing it uniquely buys is the ability to create or read webhooks via the API — which you do not need if you use app-level webhooks (§8). And it is **incompatible with `actor=app`**. - **Broadening scopes re-authorizes everyone.** The token carries the scopes granted at issue time; adding a scope to the app registration does not upgrade tokens already out there. > **As of 2026-09-20, Unified.to's Linear connector** requests scopes per capability rather than one fixed set: `read` > alone for reading projects, issues, teams, people, comments and attachments and for the sign-in identity check; > `read,write` for writing projects, teams and comments; and `read,issues:create` for creating issues. It does **not** > request `admin`, `comments:create`, `timeSchedule:write`, or any of the agent/customer/initiative scopes. Register > at least the union your deployment will send, and confirm the exact current set with the connector's owner — this is > a snapshot, not a contract. ## 6. `actor=user` vs `actor=app` — pick before you ship the authorize URL The optional `actor` parameter on the authorize URL decides whose name is on everything the token creates. | | `actor=user` (default) | `actor=app` | | --- | --- | --- | | Attribution | Issues, comments and status changes are created **as the authorizing user** | Created **as the application itself** | | Install scope | Per user | **Workspace-scoped** | | Who can install | Any member who can authorize apps | **Admin permissions required** | | `admin` scope | Allowed | **Not allowed — the two are mutually exclusive** | | Typical use | Multi-tenant connectors where each person connects their own account | Agents and service accounts | | Identity | The human | An app user, with the app's name and icon, that does not count as a billable user | For a platform that connects many customers' workspaces **on behalf of individual people**, `actor=user` is the right default and is what you get by sending no `actor` at all. Choose `actor=app` only when the product is genuinely one workspace-wide identity — an agent that gets delegated issues, or a service account for automation. Note the knock-ons: you need an admin at every customer to install it, you can never add `admin` scope, and with `app:assignable`, **assigning an issue to the app sets it as the issue's *delegate*, not its assignee**, so humans keep ownership. `actor` is tied to the authorization and its access token, so switching modes is a re-authorization for every customer, not a configuration change. `actor=application` is the deprecated spelling; existing uses may keep working, but write `actor=app`. In app-actor mode, store the app's per-workspace user id (the `viewer.id` returned by the token) alongside the token — that is how you tell your own app's activity apart across workspaces. > **As of 2026-09-20, Unified.to's Linear connector** sends `client_id`, `redirect_uri`, `response_type`, `state` and > a comma-delimited `scope` on the authorize call, and **does not send `actor`** — so it runs in the default per-user > mode, and actions are attributed to the person who connected. It also **does not send `prompt=consent`** and **does > not use PKCE** (it is a confidential client with a secret). Confirm the current parameter set with the connector's > owner. ### `prompt=consent` and the multi-workspace problem `prompt=consent` forces the consent screen every time, "even if all scopes were previously granted". Linear calls out the reason it matters: it "can be useful if you want to give users the opportunity to connect **multiple workspaces**." A user who belongs to two Linear workspaces and has already authorized one will otherwise be sent straight back with a token for that same workspace, with no chance to pick the other. If your product lets one person connect more than one Linear workspace, you want this parameter, and it is a client-side change, not an app-registration one. ## 7. Capture the credentials, and plan for expiry From the application's settings page in Settings → Administration → API: - **Client ID** and **client secret** - The **webhook signing secret**, if webhooks are enabled (§8) - Endpoints, fixed and identical for everyone: - authorize — `https://linear.app/oauth/authorize` - token / refresh / client credentials — `https://api.linear.app/oauth/token` - revoke — `https://api.linear.app/oauth/revoke` - GraphQL API — `https://api.linear.app/graphql`; file storage — `https://uploads.linear.app` **The token exchange is form-encoded**: `POST https://api.linear.app/oauth/token` with `Content-Type: application/x-www-form-urlencoded` and `code`, `redirect_uri`, `client_id`, `client_secret`, `grant_type=authorization_code` in the body. PKCE swaps `client_secret` for `code_verifier` (the secret becomes optional). The response is JSON: `access_token`, `token_type: "Bearer"`, `expires_in: 86399`, `scope`, `refresh_token`. **Refresh** is the same endpoint and content type, with `refresh_token` and `grant_type=refresh_token`. Credentials go either in the body or as HTTP Basic (`Authorization: Basic base64(client_id:client_secret)`) — Linear accepts both, unlike vendors that insist on one. A PKCE-issued token can refresh with `client_id` alone. Three things that bite: 1. **Refresh tokens rotate.** Every refresh returns a *new* refresh token. Store the new pair from every response or the second refresh fails. The **30-minute grace period** exists exactly for the case where you lost the response to a network error: replay the original request within 30 minutes to get the new refresh token back. 2. **Tokens expire now, and did not used to.** Any code, doc or blog post predating **1 April 2026** that says Linear tokens are effectively permanent is stale. A connector that never refreshes will pass every test on day one and 401 everywhere on day two. 3. **`scope` shape varies by app age.** Apps created **before 1 December 2023** get `scope` back as an **array of strings**; newer apps get a space-joined string. If you are adopting an old app, check which you have. **Revocation**: `POST https://api.linear.app/oauth/revoke` with the credential in the `token` form field, optionally `token_type_hint` (`access_token` or `refresh_token`). 200 = revoked, 400 = could not revoke (e.g. already revoked), 401 = could not authenticate. The legacy `access_token`/`refresh_token` form fields still work for compatibility but must not be combined with `token`. Customers can also revoke from their side — an authorization can disappear with no warning beyond the next 401 (and, if you subscribe, an `OAuthApp` revoked webhook, §8). **Rotating the client secret** is available in place under Settings → API → **Rotate secret**, with a separate control for the webhook signing secret. Linear is specific about the blast radius: rotating the client secret **immediately invalidates client-credentials tokens** but **does not revoke access or refresh tokens already granted** — those keep working and pick up the new secret at their next refresh. Revoke those separately if that is what you meant. Never rotate without explicit go-ahead. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. > **As of 2026-09-20, Unified.to's Linear connector** exchanges the code with a form-encoded POST carrying > `client_id`, `client_secret`, `redirect_uri`, `code` and `grant_type` **in the body** (not HTTP Basic), refreshes > with `refresh_token`, `grant_type`, `client_id` and `client_secret` the same way, and sends the result as a > `Bearer` token. It also supports a **personal API key** as an alternative to OAuth for single-workspace customers — > note that a workspace admin can block Members from creating personal API keys, so that path is not always available. > It paginates with Linear's cursor and **caps page size at 50** deliberately, because larger pages blow the query > complexity budget. Confirm all of this with the connector's owner rather than trusting the snapshot. ### Client credentials tokens (only if a user flow is genuinely impossible) For CI and scheduled automation, Linear supports `grant_type=client_credentials` with a required comma-separated `scope`. You must **toggle client credentials on for the app** first; otherwise the response is `"Client does not support the client_credentials grant type"`. The token is an **app-actor token** with access to all public teams in the workspace, valid **30 days**, **with no refresh token** — fetch a new one when you get a 401, and Linear asks you to request one per run rather than treating it as a long-lived API key. Up to **1000** can be active in parallel **provided they all carry the same scopes**; requesting different scopes revokes every existing app-actor token. Mixing grant types breaks this: if you first mint an app-actor token some other way, you cannot then hold several in parallel. Team access for the app user is editable by workspace admins at any time. This is not a substitute for the user OAuth flow in a multi-tenant connector. ## 8. Webhooks — app-level, not API-level Linear has two different webhook mechanisms and confusing them costs a scope you did not need: - **App-level webhooks (what a multi-tenant connector wants).** Configure a webhook URL and resource types **on the OAuth application**. Then, "each time a new organization authorizes the given application, a webhook will be created for that organization that posts to the provided webhook URL." No per-customer setup, no `admin` scope. When the app is de-authorized from an organization, an **`OAuthApp` revoked** event is sent, carrying `oauthClientId` and `organizationId` — the cleanest signal that a customer disconnected. - **API-created webhooks.** The `webhookCreate` mutation. Linear: "Only workspace admins, or OAuth applications with the `admin` scope, can create or read webhooks." If you find yourself wanting `admin` for webhooks, you probably wanted app-level webhooks instead. Practicalities: - The endpoint must be a **publicly accessible HTTPS, non-localhost URL**, and must not be a loopback host, a private-network host, or on `linear.app`. - Respond **200 within 5 seconds**. Failures retry **3 times** with backoff at **1 minute, 1 hour, 6 hours**; a URL that stays unresponsive **may be disabled by Linear and must be re-enabled manually**. - Verify the **`Linear-Signature`** header: hex HMAC-SHA256 of the **raw** body with the webhook signing secret. Restringifying parsed JSON changes the bytes and breaks the signature. Also check `webhookTimestamp` is within a minute to block replays. Linear additionally publishes outbound IPs on its security page if you want an allowlist. - Resource types selectable on an app include `AgentSessionEvent`, `AppUserNotification`, `Attachment`, `Comment`, `Customer`, `CustomerNeed`, `Cycle`, `Document`, `Initiative`, `InitiativeUpdate`, `Issue`, `IssueLabel`, `IssueSLA`, `OAuthAuthorization`, `PermissionChange`, `Project`, `ProjectLabel`, `ProjectUpdate`, `Reaction`, `Release`, `ReleaseNote`, `User`. - **Linear explicitly discourages polling** the API for updates and points at webhooks instead — which matters, because polling burns the per-user request and complexity budgets in §10. For agents, enabling webhooks and selecting **Agent session events** is not optional — it is how the app learns it was mentioned or delegated an issue. Linear also suggests **Inbox notifications** and **Permission changes**; the latter fires a `PermissionChange` webhook when an admin changes or revokes the app's team access. > **As of 2026-09-20, Unified.to's Linear connector** detects changes by **polling** rather than by registering an > app-level webhook URL. If that is still true, nothing on the app registration needs a webhook URL — but it is worth > raising with the connector's owner, because app-level webhooks are the documented, rate-limit-friendly path and > would also deliver de-authorization notices. ## 9. Approvals, distribution and the Integration Directory Three separate gates, often confused: 1. **Distribution (yours).** Private vs public on the app itself (§3). Public is what makes the app installable by other workspaces at all. There is no Linear review to make an app public. 2. **Third-party app approvals (the customer's).** Available on **any paid plan** and off by default. A workspace owner (Enterprise) or admin (other paid plans) turns it on at **Settings → Administration → Security**. Once on, a Member who tries to install your app gets a "request approval" screen instead, with an optional reason; the admins get an email and an in-app notification; approved and denied apps are listed under **Applications**, where an admin can also **revoke authorization for an already-installed application** or flip a previous decision. If an app was denied before, the requester is told, along with the reason. This is entirely customer-side — you cannot register your way out of it, and it is the most common cause of "the connect button did nothing" from enterprise customers. 3. **Integration Directory listing (optional).** A public app works through its OAuth flow without ever being listed. If a listing is wanted: fill out Linear's submission form and send assets to `integrations@linear.app` — a colour icon and a monochrome white icon at 320×320, and one to three showcase images at 1600×1000. Linear says it accepts integrations it "think[s] are useful to the community and are built by formal companies" and "generally do[es] not accept scripts, low-effort/vibe coded or apps built by hobbyists." Listing changes and delisting go to the same address. Do not promise a customer a directory listing before it is accepted, and do not invent compliance, data retention or volume claims on the form — ask. ## 10. Verify end-to-end Testing in the workspace that owns the app proves very little — you are an admin there, and on a Free plan so is everyone else. Test the path a customer takes: 1. Authorize from a **second, unrelated workspace**, through your platform's real connect flow, as a **non-admin Member** if customers will be. On a paid test workspace, turn **third-party app approvals on** once and confirm your product handles the approval-pending state sanely. 2. Confirm the token response carries `expires_in` (~86399) and a `refresh_token`. 3. Force a **refresh**, then force a **second refresh using the token returned by the first**. That is the test that catches a client ignoring rotation — and the one that would have caught the 1 April 2026 migration. 4. Fast-forward past 24 hours (or expire the stored token deliberately) and confirm a real read still works. 5. Call `query { viewer { id name } }` on `https://api.linear.app/graphql` with `Authorization: Bearer `. In `actor=app` mode, **store `viewer.id` per workspace** — it is how you identify your own app later. 6. Exercise one write in each mode you support and check the attribution in the Linear UI matches what you promised (the user, or the app). 7. If webhooks are on, authorize from the second workspace and confirm a webhook was auto-created for that org and that your signature check passes against the **raw** body. 8. Deliberately **revoke** from the customer side (admin → Applications → revoke) and confirm your platform notices, via the `OAuthApp` revoked webhook or a clean 401 path. 9. Watch `X-RateLimit-*` and `X-Complexity` response headers on a realistic sync and confirm page sizes keep single queries well under **10,000** complexity points. | Symptom | Cause | | --- | --- | | Everything works for a day, then 401 everywhere | Access tokens expire in ~24h since the 1 April 2026 migration; the client is not refreshing (§7) | | First refresh succeeds, second fails | Rotated `refresh_token` not stored; replay the original request within the 30-minute grace window (§7) | | Token exchange fails right after consent | `redirect_uri` missing from the exchange body, or not byte-identical to the authorize value (§4) | | `invalid_client` / auth failure at exchange | Wrong client secret, or the secret was rotated and not redeployed (§7) | | Another workspace cannot install the app | Distribution is **private** (§3) | | Install fails only for non-admins | `actor=app` install is workspace-scoped and needs admin (§6) | | Authorize screen offers "request approval" instead of connecting | Customer has third-party app approvals on (§9) | | App cannot request `admin` scope | `actor=app` and `admin` are mutually exclusive (§6) | | `invalid_scope`-style rejection with a valid-looking scope list | Scopes were **space**-separated; Linear wants commas (§5) | | User can only ever connect one of their workspaces | `prompt=consent` not sent (§6) | | Cannot see Settings → Administration → API at all | Not a workspace admin (§2) | | `"Client does not support the client_credentials grant type"` | The client-credentials toggle is off on the app (§7) | | All app-actor tokens suddenly stop working | A client-credentials token was requested with different scopes, or the client secret was rotated (§7) | | HTTP **400** with `"code": "RATELIMITED"` in `errors` | Rate limit, not a malformed query — GraphQL returns 400 (§10) | | Query rejected outright regardless of retries | Single-query complexity over **10,000** points; reduce page size (§10) | | Webhook signature never matches | Signature computed over restringified JSON instead of the raw body (§8) | | Webhooks stopped arriving and never resumed | Repeated non-200/slow responses got the webhook disabled; re-enable manually (§8) | | `webhookCreate` returns a permission error | API-created webhooks need workspace admin or `admin` scope; use app-level webhooks (§8) | | `scope` comes back as an array, breaking the parser | App created before 1 December 2023 (§7) | | Files return 401 when downloaded | `uploads.linear.app` needs the same `Authorization: Bearer` header, or a signed URL via `public-file-urls-expire-in` (§8 refs) | ## 11. Hand off — never commit the secret - **Do not** write the client secret, the webhook signing secret, an access token, a refresh token or a personal API key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for their secret store or console. If one leaks, rotate it from the app's settings page and understand what that does and does not revoke (§7). - If a code change is needed (a redirect host, comma-delimited scopes, `prompt=consent`, refresh-token rotation), keep it secret-free and say what the human must set out of band. - Close with: application name, **the workspace that owns it and therefore which admins can read the secret**, distribution (private/public), the client ID, where the secret was delivered, the authorize/token/revoke endpoints, the exact scope string the client will send, the actor mode, whether client credentials and webhooks are enabled, the webhook resource types, whether a directory listing was submitted, and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the user wants a single-workspace setup and a **personal API key** is the right answer (that run produces no OAuth credentials at all); the owning workspace is the company workspace, because every admin gains the secret; distribution needs to change and the settings page does not offer it; `actor=app` is on the table, because it needs an admin at every customer, forecloses `admin` scope, and changes attribution for everyone; a scope needs adding, because that re-authorizes every existing customer; a customer's admin must approve or has denied the app (customer-side, not registerable); the Integration Directory form asks for compliance, legal, data-retention or volume claims; agent/preview APIs would become a customer commitment; or the settings page does not match the **Platform state** section above. ## References - Developer docs home — https://linear.app/developers - OAuth 2.0 authentication (flow, scopes, refresh, revoke, client credentials, secret rotation) — https://linear.app/developers/oauth-2-0-authentication - OAuth actor authorization — https://linear.app/developers/oauth-actor-authorization - OAuth application manifests (fields, limits, pre-fill URLs, JSON schema) — https://linear.app/developers/oauth-app-manifests - OAuth app manifest JSON Schema — https://linear.app/.well-known/oauth-app-manifest.schema.json - Agents: getting started (actor=app, app:assignable / app:mentionable, customer and initiative scopes) — https://linear.app/developers/agents - Developing the agent interaction — https://linear.app/developers/agent-interaction - File storage authentication — https://linear.app/developers/file-storage-authentication - Webhooks (app-level webhooks, signing, retries, resource types) — https://linear.app/developers/webhooks - Rate limiting and query complexity — https://linear.app/developers/rate-limiting - Deprecations policy — https://linear.app/developers/deprecations - GraphQL API getting started — https://linear.app/developers/graphql - Pagination — https://linear.app/developers/pagination - Integration Directory (submission, assets, acceptance bar) — https://linear.app/developers/integration-directory - Admin docs: API and webhooks (where OAuth apps live; API-key controls) — https://linear.app/docs/api-and-webhooks - Admin docs: third-party app approvals — https://linear.app/docs/third-party-application-approvals - Admin docs: members and roles — https://linear.app/docs/members-roles - Admin docs: security and access — https://linear.app/docs/security-and-access - Security page (incl. outbound IP addresses) — https://linear.app/security - Changelog — https://linear.app/changelog - Create an OAuth application — https://linear.app/settings/api/applications/new - Workspace API settings — https://linear.app/settings/api - Integration directory (customer-facing) — https://linear.app/integrations ## linkedin-oauth-app URL: https://docs.unified.to/skills/linkedin-oauth-app/SKILL.md # LinkedIn OAuth2 App Registration Get a working LinkedIn OAuth2 client — a developer app, a verified LinkedIn Page association, the right Products, redirect URLs, scopes, client ID and client secret — for a platform that connects many customers' LinkedIn accounts. Two things about LinkedIn are unlike every other portal in this repo, and both are gates rather than fields. **First: the app is worthless until a human who is not you approves it.** A LinkedIn app must be associated with a LinkedIn **Page**, and that Page's **super admin** must verify the app through a one-time URL you generate and send them. You cannot self-approve, you cannot skip it, and the link expires in 30 days. If nobody on your side is a super admin of a real company Page, the run stops here — and a fake Page is a documented rejection reason. **Second: scopes are not something you choose — they are granted by Product.** Creating the app gives you almost nothing. Each API family (Sign In with LinkedIn using OpenID Connect, Share on LinkedIn, Advertising API, Community Management API, Lead Sync, Conversions, Events Management, Matched Audiences, Talent, Sales Navigator, Messages) is a **Product** you add on the app's Products tab. Three are self-serve; the ones a data connector actually needs require an **application and review** by LinkedIn, and the scope strings only appear on your Auth tab *after* approval. A scope you have not been granted fails at the authorize step with `Invalid scope`, not at save time. §4 is the section that decides whether this project ships this month or next quarter. Plan for the review calendar, not the form. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, description, app logo | Shown on the member consent screen | | **LinkedIn Page** to associate | Must be a real company Page you control (§2) | | **Who is the Page super admin** | They must click your verification link (§3) | | **Privacy policy URL**, terms URL | Required on the app form | | **Redirect URLs** | Every callback host your platform serves (§5) | | **Which Products / scope set** | What the connector actually calls (§4, §6) | | **New app or edit to an existing one** | New credentials orphan existing connections (§1) | | **Is server-side token refresh required?** | Blocking — needs MDP approval (§8) | | **Business/legal details** | Legal name, registered address, website, business email — the access forms ask | ## Quick Start 1. Confirm a **new app** is needed rather than an edit — existing connections are bound to the current client ID (§1). 2. Sign in to the Developer Portal with a LinkedIn member account; make sure a LinkedIn Page exists (§2). 3. Create the app, associate the Page, then **get the Page super admin to verify it** (§3). 4. Add the **Products** you need and file the access requests — the long pole (§4). 5. Add **every** redirect URL, exactly (§5). 6. Confirm the granted scopes on the Auth tab and send exactly those in `scope` (§6). 7. Capture client ID and client secret from the Auth tab (§7). 8. Settle the token story: 60-day access tokens, programmatic refresh tokens only for approved partners (§8). 9. Verify end-to-end with a real member outside your own org (§9). 10. Hand the credentials over — never commit them (§10). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Developer portal**: `https://www.linkedin.com/developers/apps`. An app page has **Settings**, **Auth**, **Products** and **Analytics** tabs. Settings holds the Page association and the **Verify** button; Auth holds client ID, client secret, redirect URLs and the list of **currently granted** scopes; Products is where you add and apply for API access; Analytics shows per-endpoint rate limits and the refresh-token quota table. - **Page association and super-admin verification are required**, and LinkedIn documents company Page verification as a prerequisite for applying to Products. "Do not use a fake company page." The verification URL is generated under Settings → Verify → **Generate URL**, sent to the super admin out of band; they have **30 days**. A denial **invalidates every link generated by any developer on that app**, so coordinate before sending. - **Self-serve Products (Open Permissions)** — no review: *Sign In with LinkedIn using OpenID Connect* (`profile`, `email`, plus `openid`) and *Share on LinkedIn* (`w_member_social`). - **Review-gated Products**: Advertising API, Community Management API, Lead Sync API, Conversions API, Events Management API, Matched Audiences API (all "open to all approved developers", i.e. an application). **Audience Insights** and **Media Planning** are restricted to developers LinkedIn qualifies case by case. **Company Intelligence API** is not accepting new applications. **Compliance** (`r_compliance`, `w_compliance`) is closed and cannot be requested. **Messages API** usage is "restricted to approved partners, subject to limitations via API agreement". Talent (RSC, Apply Connect, Apply with LinkedIn, Premium Job Posting) and Sales Navigator (SNAP) are separate partner applications made off-portal. - **Access tiers**: Advertising API and Community Management API each start at **Development** tier and require a separate upgrade request for **Standard**. Development tier is genuinely limiting — see §4. - **Tokens**: access tokens are issued with a **60-day** lifespan. **Programmatic refresh tokens are available to approved Marketing Developer Platform (MDP) partners only**; by default they are valid **365 days** and that TTL is *not* extended by use. Without them, "refreshing" means sending the member back through the authorize URL, which is only silent while they are still logged in to linkedin.com and their current token has not expired. - **Legacy Sign In with LinkedIn was deprecated on 1 August 2023.** New work uses `openid profile email` and the userinfo endpoint, not `r_liteprofile` / `r_emailaddress` and `/v2/me`. - **Marketing versioned APIs sunset on a rolling schedule** — version `202510` sunsets 15 October 2026. The API version is a request header, not part of registration, but a connector pinned to an old version breaks on that date independently of anything in this skill. If the portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing member authorization is bound to the old one** — every customer re-authorizes. It also means starting the Page verification and every Product review again from zero, which on LinkedIn is weeks, not minutes. Reuse the existing app for: adding a redirect URL, adding a Product or scope, rotating a compromised secret, or diagnosing an authorization failure. Register a **new** app only when the user explicitly wants one — a replacement for a compromised app, a separate app for a separate product line, or a deliberate split of Development-tier testing from production. Say which path you are taking before you touch anything. One LinkedIn-specific wrinkle: **changing the scope set forces every member to re-consent**, and requesting a different scope than previously granted **invalidates the member's existing access tokens**. Adding a Product is therefore a customer-visible event too, just a cheaper one than a new client ID. ## 2. Account and Page: what must exist first - **A LinkedIn member account.** Apps are owned by a member, not by an organization, and team members are added to the app afterwards. Use a durable account, not a personal one belonging to someone who may leave. - **A LinkedIn Page** for your company. Create one first if it does not exist; the app form asks for it by name and API Products available to individual developers must have a default Page associated. - **A Page super admin who will cooperate.** This is the one person who can unblock §3. Identify them by name before you create anything. - **Business details** — legal name, registered address, website, business email. The Product access forms ask. Hand control back to the user for anything only they can do: LinkedIn login and 2FA, creating or claiming the Page, accepting the API Terms of Use, and the super-admin click in §3. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste — the redirect URLs from §5 and the scope list from §6 — then continue once they report back with the client ID and the granted-scope list from the Auth tab. ## 3. Create the app and get it verified **Create**: `https://www.linkedin.com/developers/apps/new`. Fill in app name, the **LinkedIn Page**, privacy policy URL, and app logo. Accept the API Terms of Use. The app exists immediately — and does nothing yet. **Verify** (the human gate): 1. Open the app → **Settings** tab → **Verify**. 2. In the *Verify company* dialog, click **Generate URL**, then **Copy URL**. 3. Send that URL to the Page **super admin** (email or LinkedIn message). Tell them what they are approving; the approval screen shows them your name, photo, title, company and the app details. 4. They have **30 days**. On approval the app shows as verified in Settings. Three things worth knowing before you send it: - A **denial invalidates all links generated by any developer on that app**, not just yours. Agree with the super admin first; one reflexive "deny" costs the whole team a regeneration cycle. - Verification is a prerequisite for the Product access forms in §4, so a delay here delays everything downstream. - Do not associate a Page you do not control to get unblocked. Fake Pages are a stated rejection reason and put the app itself at risk. Also on Settings: add the teammates who should administer the app, so the run does not depend on one person's login. ## 4. Products and access requests — this is the gate Scopes come from Products. On the app's **Products** tab you add a Product; self-serve ones enable immediately, review-gated ones open an access form and then wait. **The scope strings appear on your Auth tab only after the Product is granted.** Until then, sending that scope in the authorize URL returns `401 Invalid scope`. | Product | How you get it | Scopes it grants (as LinkedIn spells them) | | --- | --- | --- | | **Sign In with LinkedIn using OpenID Connect** | Self-serve | `openid`, `profile`, `email` | | **Share on LinkedIn** | Self-serve | `w_member_social` | | **Advertising API** | Apply on the Products tab; review | `r_ads`, `rw_ads`, `r_ads_reporting`, `r_basicprofile`, `r_organization_admin`, `r_organization_social`, `rw_organization_admin`, `w_organization_social`, `w_member_social`, `r_1st_connections_size` | | **Community Management API** | Apply; review + app screen recording + test credentials | `r_organization_social`, `r_organization_social_feed`, `w_organization_social`, `w_organization_social_feed`, `rw_organization_admin`, `r_organization_followers`, `r_basicprofile`, `w_member_social`, `w_member_social_feed`, `r_member_profileAnalytics`, `r_member_postAnalytics`, `r_1st_connections_size` | | **Lead Sync API** | Apply; review | `r_marketing_leadgen_automation`, `r_ads`, `r_events`, `r_liteprofile`, `r_organization_admin` | | **Conversions API** | Apply; review | `rw_conversions`, `r_ads`, `r_liteprofile` | | **Events Management API** | Apply; self-serve on request | `r_events`, `rw_events` | | **Matched Audiences API** | Apply; requires Advertising API first | `rw_dmp_segments` | | **Audience Insights / Media Planning** | Restricted; qualification survey, case by case | app-level permission; `rw_media_plans` for saved media plans | | **Messages API** | Approved partners only, under an API agreement | `r_messages`, `w_messages` — **not obtainable self-serve** | | **Talent (RSC, Apply Connect, Apply with LinkedIn, Premium Job Posting)** | Off-portal partner application | Per program | | **Sales Navigator (SNAP)** | Off-portal partner application | `r_sales_nav_analytics`, `r_sales_nav_display`, `r_sales_nav_validation`, `r_sales_nav_profiles` | | **Compliance** | **Closed** — cannot be requested | `r_compliance`, `w_compliance` | Before you file anything, read LinkedIn's **API and data restrictions** page (linked in §References). Applications are rejected for restricted use cases, and that rejection costs a full cycle. **Tiers are a second gate that people forget.** Both big Products start at Development tier: - **Advertising API — Development**: unlimited *read* on ad accounts you administer; *edit* on at most **five** ad accounts; creation of exactly **one** test ad account through the API. Standard tier (unlimited edit, unlimited ad account creation) is a separate request through the Developer Support Portal, **with a video demonstrating your platform creating, editing or optimizing LinkedIn campaigns**. A multi-tenant connector cannot run on Development tier past its sixth customer. - **Community Management API — Development**: **500 API calls per app per 24h**, **100 API calls per member per 24h**, **BATCH_GET is not allowed at all**, and social-action webhook push notifications are **disabled**. You are expected to finish the integration within **twelve months**. Standard tier removes the restrictions and needs a form, a screen recording and test credentials. Two things to stop and ask about rather than guess: the business/legal details on the access forms, and the demo video and test credentials. Those are the user's to provide. ### Product fact — what this platform's connector needs As of 2026-09-20, **Unified.to's LinkedIn connector requests**, depending on which objects a customer enables: - Login / identity: `openid`, `profile`, `email` - Advertising objects (ad accounts, campaigns, campaign groups, ads, creatives, targeting): `r_ads`, `rw_ads` - Ad reporting: `r_ads_reporting` - Organization pages, posts and social engagement: `r_organization_social`, `w_organization_social`, `rw_organization_admin` - Member profile: `r_basicprofile` - Messaging: `r_messages`, `w_messages` Which implies, at minimum: **Sign In with LinkedIn using OpenID Connect** (self-serve) for the identity scopes; **Advertising API** for `r_ads` / `rw_ads` / `r_ads_reporting` / `r_basicprofile`; **Advertising API and/or Community Management API** for the `*_organization_*` scopes; and, for messaging, an **approved-partner agreement covering the Messages API** — `r_messages` and `w_messages` appear in no self-serve or marketing Product, so the messaging objects simply will not authorize without that partnership. The connector also exchanges and refreshes tokens server-side with `client_id` + `client_secret` (form-encoded POST), which means it depends on **programmatic refresh tokens** and therefore on MDP approval (§8); it pins a dated `LinkedIn-Version` request header and `X-RestLi-Protocol-Version: 2.0.0`, and pages with `start`/`count` over `elements` at up to 100 per page. Treat that list as a starting point, not gospel: **confirm the current scope set and the messaging/MDP status with the connector's owner** before filing access requests, and request only the scopes the customer's enabled objects need. LinkedIn asks for the least number of scope permissions, and a broad ask is a slower review. ## 5. Redirect URLs Add every callback host your platform serves, on the app's **Auth** tab under *Redirect URLs*. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` LinkedIn's rules, which are stricter than they look: - **HTTPS and absolute.** `https://dev.example.com/auth/linkedin/callback`, never a bare path. - **Query parameters are ignored** — a registered `…/callback?id=1` is stored as `…/callback`. Do not try to encode tenant or state in the registered URL; that is what `state` is for. - **No `#` anywhere.** A URL containing a fragment is invalid. - **Exact match at authorize time.** The `redirect_uri` you send must match a registered value exactly; a mismatch is `401 Redirect_uri doesn't match` when a customer clicks connect, not when you save the app. - The same `redirect_uri` must be sent **again** on the token exchange, and it is a required parameter there. Omitting it returns `400 invalid_request "A required parameter redirect_uri is missing"`. ## 6. Scopes `scope` is URL-encoded and space-delimited on the authorize URL. Three rules do most of the damage: 1. **Only request scopes your Auth tab shows as granted.** Anything else is `401 Invalid scope`. The Auth tab is the source of truth, not this document and not the Products table. 2. **Consent is all-or-nothing.** If you request several scopes the member must accept all of them; they cannot pick. Request the minimum set for what the customer actually enabled. 3. **Changing the set re-consents everyone.** If your app's scopes change, members must re-authenticate, and requesting a different scope than previously granted invalidates the previously issued access tokens. For identity, use the OpenID Connect trio — `openid` (returns the ID token), `profile` (id, name, picture), `email` (primary address) — and read the member from `GET https://api.linkedin.com/v2/userinfo` or the ID token, validating against LinkedIn's discovery document and JWKS. Do **not** build new work on `r_liteprofile` / `r_emailaddress` and `/v2/me`: that product was deprecated on 1 August 2023. Note that `email` and `email_verified` are documented as **optional** in the userinfo response — handle their absence. Watch for the two legacy scopes that are still live inside newer Products: Lead Sync and Conversions grant `r_liteprofile`, which is not the same thing as the OIDC `profile`. If you use those Products, you will hold both. ## 7. Capture the credentials App → **Auth** tab → *Application credentials*: **Client ID** (also called the API key or consumer key) and **Client Secret** ("Primary Client Secret"). The same tab lists the OAuth 2.0 scopes currently granted — capture that list verbatim, because it is what §6 must match. Endpoints (the same for every app; there is no per-tenant host): - Authorize: `GET https://www.linkedin.com/oauth/v2/authorization` - Token and refresh: `POST https://www.linkedin.com/oauth/v2/accessToken`, `Content-Type: application/x-www-form-urlencoded` - API base: `https://api.linkedin.com` - OIDC discovery: `https://www.linkedin.com/oauth/.well-known/openid-configuration`; JWKS: `https://www.linkedin.com/oauth/openid/jwks` Also capture: the app's LinkedIn Page, the verification status, the Products granted and their **tier**, and the authorization **code** lifetime (30 minutes, single use) if you are debugging an exchange. LinkedIn warns explicitly: never pass the client secret in a URL or query string, and never post it in a support forum or chat. Rotating the secret breaks every token exchange and refresh until the new value is deployed — existing access tokens keep working until they expire, which makes the breakage look intermittent. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 8. Token lifetimes and refresh This is where LinkedIn connectors die quietly, sixty days after a successful launch. | Fact | Consequence | | --- | --- | | Access tokens are issued with a **60-day** lifespan (`expires_in`, e.g. `5184000`) | A connector that never refreshes loses every connection two months after onboarding | | **Programmatic refresh tokens are for approved MDP partners only** | Without MDP, there is no server-side refresh at all | | Default programmatic refresh token TTL is **365 days**, and using it does **not** extend that TTL | Even a perfect refresh loop forces re-authorization once a year | | Refreshing returns a new access token with a fresh 60-day TTL and the **remaining** refresh TTL | Track `refresh_token_expires_in` and warn customers before it hits zero | | Without programmatic refresh, "refresh" means re-running the authorize URL | Silent **only** while the member is still logged in to linkedin.com *and* the current token has not expired — otherwise they see the consent screen again | | LinkedIn "reserves the right to revoke Refresh Tokens or Access Tokens at any time" | The documented expectation is to fall back to the full OAuth flow and show the login screen. Build that path | | Refresh-token minting has a **daily per-app quota**, resetting 00:00 UTC | The Analytics tab shows percent of quota used and a Throttled / Not Throttled flag. A mass re-refresh can throttle the whole app | Refresh request: `grant_type=refresh_token`, `refresh_token`, `client_id`, `client_secret`, form-encoded, to the same `/oauth/v2/accessToken` endpoint. An expired or revoked refresh token returns `400 invalid_request "The provided authorization grant or refresh token is invalid, expired or revoked"` — the only fix is re-authorizing the member. **Ask early whether MDP approval exists or is in flight.** If it does not, say so plainly: the app will register fine, every connection will work for 60 days, and then all of them will fail at once. That is a finding to report, not a step to work around. Also plan for rate limits, since they shape the same loop: limits apply **per application** and **per member per application**, reset at midnight UTC, and return **429**. Standard limits are not published — look them up per endpoint on the app's **Analytics** tab, which only lists endpoints you have called at least once today. Developer admins get an email alert at **75%** of an application-level quota, delayed 1–2 hours; member-level breaches do not alert at all. ## 9. Verify end-to-end Testing with your own account inside your own Page proves less than it looks — you are an admin of everything. 1. Use the portal **Token Generator** (`/developers/tools/oauth/token-generator`) first to confirm the app and its granted scopes work at all, before blaming your own code. 2. Then run the real flow through your platform's connect URL with a **different member**, one whose LinkedIn roles match a customer's (a Page `ADMINISTRATOR`, or an ad account `ACCOUNT_MANAGER` / `CAMPAIGN_MANAGER`). 3. Confirm the token response carries `refresh_token` and `refresh_token_expires_in`. **If those fields are absent, the app does not have programmatic refresh tokens** — stop and go back to §8. 4. Force a refresh, and check that the new response's `refresh_token_expires_in` has *decreased* rather than reset. 5. Make one real read call against `https://api.linkedin.com` with the version and protocol headers the connector sends, not a bare `/v2/me`. 6. Paste the token into the **Token Inspector** to confirm the scopes and TTL are what you expect. | Symptom | Cause | | --- | --- | | `401 Invalid scope` at authorize | Scope not granted to the app — the Product is missing or still under review (§4) | | `401 Redirect_uri doesn't match` | Redirect URL not registered exactly; or a `#`, or you relied on a query string LinkedIn stripped (§5) | | `401 Client_id doesn't match` | Wrong client ID for this environment | | `400 invalid_request "…redirect_uri is missing"` | Redirect URI omitted on the **token exchange**, where it is also required (§5) | | `400 invalid_redirect_uri "…does not match authorization code. Or authorization code expired…"` | Different `redirect_uri` on exchange than on authorize, or the code is past its 30-minute single-use life | | Token response has no `refresh_token` | App is not approved for programmatic refresh tokens (§8) | | All connections fail about 60 days after launch | No refresh path — access tokens expired (§8) | | All connections fail about a year in | Refresh token hit its 365-day fixed TTL; members must re-authorize (§8) | | Members suddenly re-prompted for consent | The app's scope set changed (§6) | | Previously working tokens stop working after a scope change | Requesting a different scope than granted invalidates earlier tokens (§6) | | `429` on a working integration | Per-app or per-member daily limit; check the Analytics tab (§8) | | Refresh calls throttled but API calls fine | Daily refresh-token minting quota exhausted; resets 00:00 UTC (§8) | | Edits work for a few customers and fail for the rest | Advertising API **Development** tier — 5 ad accounts (§4) | | `BATCH_GET` requests rejected outright | Community Management **Development** tier (§4) | | Auth works but the customer sees no data | Member lacks the Page or ad account **role**; scopes never exceed what the member may do | | App verification never completes | Super admin has not clicked, or denied — which invalidates all generated links (§3) | ## 10. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a redirect host, a scope list, a refresh path, a version header), keep it secret-free and say what the human must set out of band. - Close with: app name and client ID; the associated LinkedIn Page and its verification status; where the secret was delivered; the Products granted, still pending, and their **tier**; the exact scope strings on the Auth tab; the registered redirect URLs; the refresh-token position (MDP approved, applied for, or not available) and what that means for connection lifetime; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: no LinkedIn Page exists, or no super admin will verify the app (§3) — this cannot be worked around, and a fake Page is a rejection reason; a Product access form asks for business, legal, compliance or volume claims, a demo video, or test credentials (§4); the connector needs `r_messages` / `w_messages` or any Talent or Sales Navigator scope, which require a partner agreement negotiated off-portal; the platform has no programmatic refresh tokens and the user has not accepted 60-day connection lifetimes (§8); someone proposes registering a new app for a live integration, which re-authorizes every customer (§1); a Development-tier limit means the current plan cannot serve the intended customer count (§4); or the portal does not match the **Platform state** section above. ## References Official LinkedIn / Microsoft Learn docs only — every URL below verified to resolve on 2026-09-20. - Authenticating with OAuth 2.0 overview — https://learn.microsoft.com/en-us/linkedin/shared/authentication/authentication - Authorization Code Flow (3-legged OAuth) — https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow - Getting access to LinkedIn APIs (Products and partner programs) — https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access - Programmatic refresh tokens — https://learn.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens - Token introspection — https://learn.microsoft.com/en-us/linkedin/shared/authentication/token-introspection - Developer portal tools (token generator, inspector, refresh-token quota) — https://learn.microsoft.com/en-us/linkedin/shared/authentication/developer-portal-tools - Rate limiting — https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/rate-limits - Error handling — https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/error-handling - Securing applications (client secret handling) — https://learn.microsoft.com/en-us/linkedin/shared/api-guide/best-practices/secure-applications - Marketing quick start (apply for API access) — https://learn.microsoft.com/en-us/linkedin/marketing/quick-start - Increasing access — permissions table and access tiers — https://learn.microsoft.com/en-us/linkedin/marketing/increasing-access - API and data restrictions (read before applying) — https://learn.microsoft.com/en-us/linkedin/marketing/restricted-use-cases - Community Management app review — https://learn.microsoft.com/en-us/linkedin/marketing/community-management-app-review - Page and ad account roles — https://learn.microsoft.com/en-us/linkedin/marketing/getting-started - Marketing API versioning and sunset schedule — https://learn.microsoft.com/en-us/linkedin/marketing/versioning - Sign In with LinkedIn using OpenID Connect — https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2 - Legacy Sign In with LinkedIn (deprecated 2023-08-01) — https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin - Messages API (approved partners only) — https://learn.microsoft.com/en-us/linkedin/shared/integrations/communications/messages - Recruiter System Connect (Talent) — https://learn.microsoft.com/en-us/linkedin/talent/recruiter-system-connect - Send an app verification request for a LinkedIn Page — https://www.linkedin.com/help/linkedin/answer/a1665329 - Approve or deny an app verification request — https://www.linkedin.com/help/linkedin/answer/a1669245 - Associate an app with a LinkedIn Page — https://www.linkedin.com/help/linkedin/answer/a548360 - Create a LinkedIn Page — https://www.linkedin.com/help/linkedin/answer/a543852 - Developer Portal — My apps — https://www.linkedin.com/developers/apps - Create a new app — https://www.linkedin.com/developers/apps/new - OAuth token generator — https://www.linkedin.com/developers/tools/oauth/token-generator - OAuth token inspector — https://www.linkedin.com/developers/tools/oauth/token-inspector - OIDC discovery document — https://www.linkedin.com/oauth/.well-known/openid-configuration - OIDC JWKS — https://www.linkedin.com/oauth/openid/jwks - LinkedIn Developer Support Portal — https://www.linkedin.com/help/linkedin/ask/dsapi - API Terms of Use — https://www.linkedin.com/legal/l/api-terms-of-use - Talent Solutions partner application — https://business.linkedin.com/talent-solutions/ats-partners/partner-application - Sales Navigator (SNAP) partner application — https://business.linkedin.com/sales-solutions/partners/become-a-partner - LinkedIn Marketing Partners — https://business.linkedin.com/marketing-solutions/marketing-partners - LinkedIn Developer Solutions — https://developer.linkedin.com/ ## mailchimp-oauth-app URL: https://docs.unified.to/skills/mailchimp-oauth-app/SKILL.md # Mailchimp OAuth2 App Registration Get a working Mailchimp OAuth2 client — an account, a registered app, a redirect URI, client ID and client secret — for a platform that connects *other organizations'* Mailchimp accounts on behalf of many customers. The form itself takes two minutes. Four things around it are expensive to get wrong, and none of them is the form: 1. **Mailchimp has no OAuth scopes.** There is no `scope` parameter, no scope list, nothing to declare on the app and nothing to line up with the authorize URL. A token is all-or-nothing across everything the authorizing user can reach. Least privilege is not available at the OAuth layer — it lives entirely in *which user* authorizes (§5). 2. **There is no fixed API host.** Each account lives in a data centre (`us6`, `us19`, `us21`, …) and the API root is `https://.api.mailchimp.com/3.0/`. You discover `` by calling the metadata endpoint *after* the token exchange and storing it next to the token. Hardcoding any one host breaks for most customers (§6). 3. **The client secret is shown exactly once**, at the bottom of the page right after you click Create. Miss it and the only way back is rotation, which breaks every deployed copy of the old secret (§7). 4. **Redirect URI matching is exact**, and Mailchimp's docs and sample code describe *the* Redirect URI, singular. If your platform serves four regional callback hosts, settle §4 before you register anything — it may decide how many apps you end up with. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, description, company/website | Shown to the user on the Mailchimp consent screen | | **Mailchimp account** that will own the app, and its login email | Must be able to reach account settings — Owner or Admin (§2) | | **Redirect URI(s)** | Every callback host your platform serves; read §4 before promising all of them on one app | | **New app, or edit/rotate an existing one?** | Default answer is "existing" (§1) | | **Marketplace listing wanted?** | Separate program with a 25-active-user bar and a review (§9) | | **Test Mailchimp account** for verification | A free account is enough to prove the flow; see §10 | | **Who holds the secret** | Secret store / console — decided before it is generated (§7, §11) | ## Quick Start 1. Confirm a **new** app is actually needed — a new client ID orphans every existing customer connection (§1). 2. Sign in to the Mailchimp account that will own the app, as Owner or Admin (§2). 3. **Registered Apps → Register An App → fill the form → Create** (§3). 4. Set the redirect URI exactly, character for character (§4). 5. Skip the scope step — there isn't one. Read §5 so you can explain what the token *does* reach. 6. **Copy the client secret immediately.** It is shown once (§7). 7. Make sure the client discovers and stores the per-account data-centre prefix after the exchange (§6). 8. Confirm the token model: no expiry, no refresh token, revoked by the user or by removing the authorizing user (§8). 9. Authorize from a **second, unrelated** Mailchimp account, then run one read call (§10) and hand off (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Endpoints are fixed and shared by everyone**: authorize `https://login.mailchimp.com/oauth2/authorize`, token `https://login.mailchimp.com/oauth2/token`, metadata `https://login.mailchimp.com/oauth2/metadata`. The Marketing API itself is **per-account**: `https://.api.mailchimp.com/3.0/` (§6). - **The Marketing API is on version 3.0.** The reference page carried `3.0.91` on 2026-09-20. Versions 1.1–1.3 are gone and 2.0 is deprecated and unmaintained. The Partner Program requires the *current* version. - **No scopes are documented anywhere** in Mailchimp's OAuth 2 guide or Marketing API fundamentals — no `scope` authorize parameter, no scope catalogue, no per-app permission checkboxes (§5). - **Access tokens do not expire and there is no refresh token.** Mailchimp states this outright: a token stays valid until the user revokes the app's permission (§8). - **Redirect URL matching has been exact since 2020-08-11** ("Stricter rules for URL matching in OAuth 2"). No prefix or trailing-slash forgiveness (§4). - **Rate limiting is concurrency-based, not a request quota**: 10 simultaneously processing requests **per user** — "per user, and not per API key or per client" — with a 120-second per-call timeout, and a separate cap of 500 pending batch webhook events per user. Both surface as 429 (§9). - **The developer release-notes feed's newest entry on 2026-09-20 was dated 2025-08-19** (Transactional status page). Nothing about OAuth, scopes or token lifetime has been announced since. Check it again anyway. - **Mailchimp is an Intuit product** and the OAuth hosts answer behind Intuit infrastructure; error bodies and headers carry Intuit trace ids. That is normal, not a sign you hit the wrong host. - **One live quirk to know before you debug anything**: the metadata endpoint answers **HTTP 200 even when auth fails**. Verified 2026-09-20 with no token and with a junk token — both returned `200` with body `{"error":"invalid_token","error_description":"Unable to load login and user"}` and a `WWW-Authenticate: OAuth realm='Service', …` header. A client that only checks the status code will treat a failed discovery call as a success (§6, §10). If the portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one **A new app means a new client ID, and every existing customer authorization is bound to the old one.** Because Mailchimp tokens never expire, a live Mailchimp connector can be sitting on years-old tokens that no one has had to touch — and they all die with the old client ID. Re-registering "to get a clean form" is a customer-wide migration. Reuse the existing app for: changing the redirect URI, rotating a compromised secret (§7), or diagnosing an install failure. Its client ID lives in your platform's secret config or console — look it up there, not by guessing from the portal list. Register a **new** app only when the user explicitly asks: a replacement for a compromised app, a separate app for a different product or brand, or the region case in §4 if that is the route taken. Say which path you are on before you touch the portal. ## 2. Account: sign in or sign up - **Sign in** at `https://login.mailchimp.com/`, or sign up at `https://login.mailchimp.com/signup/`. - Every admin URL below is **data-centre prefixed** (`https://us19.admin.mailchimp.com/…`) and Mailchimp's own docs link the same page with three different prefixes (`us1`, `us19`, `us21`). It does not matter: all of them bounce through `login.mailchimp.com` and land you in *your* account's data centre. Use the prefix-less `https://admin.mailchimp.com/account/oauth2/` and let Mailchimp route you. - **Owner or Admin.** Mailchimp's user-levels help page places API keys and integrations under the Owner and Admin levels; Manager, Author and Viewer are progressively more limited. A lower-level user may not be able to reach the Registered Apps page at all — and, separately, should not be the one authorizing production connections (§5). - There is **no separate developer account, no developer portal and no sandbox tier**. App registration is a page inside an ordinary Mailchimp account, and a free account can register one. What the *API* can do varies by the account's plan ("What you can do with the API depends on what level of Mailchimp plan you have"), so a free account cannot fully rehearse a paid customer's experience. Hand control back for anything only a human can do: CAPTCHA, email verification, 2FA, accepting terms. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the redirect URI from §4 — and continue once they report back with the client ID and confirm the secret is in the secret store. ## 3. Register the app **Registered Apps page → Register An App → fill out the form → Create.** - Registered Apps: `https://admin.mailchimp.com/account/oauth2/` - The form is short — app name, a description of what it does, your company/website, and the redirect URI. Treat the name and description as customer-facing copy: they are what a customer reads on the consent screen when deciding whether to hand over their whole Mailchimp account. - There is no app type, no confidential/public toggle, no environment selector and no scope picker. Do not go looking for them. **After you click Create, `client_id` and `client_secret` appear at the bottom of the page. This is the only time the secret is visible.** Copy it straight into the secret store before navigating away (§7). ## 4. Redirect URI Mailchimp's guide asks for "**The** Redirect URI for your application", singular, and its own sample apps instruct you to "Set the Redirect URI" to one value. The form is behind a login, so **check what the field actually accepts before promising a customer anything**: if it takes one URI only, a multi-region platform needs one registered app per callback host, each with its own client ID and secret — a real architectural consequence, not a detail. The callbacks this platform serves, one per data centre (confirm the current list with the platform owner rather than assuming): ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Rules that bite: - **Matching is exact**, and has been since 2020-08-11. Mailchimp's release note: "We now only support exact matching on the redirect URI to adhere to the OAuth 2 spec." No trailing-slash tolerance, no subpath, no scheme fudging. - **The same value must be sent on both legs.** `redirect_uri` goes on the authorize URL *and* in the token-exchange body; Mailchimp's own sample sends one constant to both. A mismatch fails the exchange, not the save. - **HTTPS is recommended but not enforced** — "For security, we strongly recommend—but do not enforce—using HTTPS for your `redirect_uri`." Never leave a non-HTTPS URI on a production app. - **For local testing use `127.0.0.1`, not `localhost`.** Mailchimp's own sample app is explicit: "You HAVE to use 127.0.0.1 if you're running it locally, localhost won't work!" Remove it from a production app afterwards. - If the user asks to add a region later, that is an edit to the existing app (§1) — or a new app, if the field holds only one value. ## 5. Scopes — there are none, and that is the whole problem **Mailchimp's Marketing API OAuth 2 has no scopes.** The authorize URL Mailchimp documents is: ``` https://login.mailchimp.com/oauth2/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI ``` That is the complete documented parameter list. There is no `scope`, no `optional_scope`, no consent-screen checkbox set, and nothing to declare on the app. Mailchimp also documents no `state` parameter — standard practice is to send one anyway for CSRF protection, so **verify with your own client that `state` round-trips** rather than assuming. Say the consequence out loud to whoever is approving this, because it is the least-privilege story: > **A Mailchimp OAuth token is all-or-nothing.** It can read and write audiences, contacts, campaigns, reports, files, > e-commerce data, automations and account settings — everything the API exposes — for the entire Mailchimp account. > You cannot ask for read-only. You cannot ask for one audience. A customer who wants to grant less has no lever at > the OAuth layer, and neither do you. ### The one lever that does exist: the authorizing user's role What a token can do is bounded by **the Mailchimp user who authorized it**, and that binding is live, not frozen: - "API authentication is tied to the user that created the API key or authorized the OAuth2 app. **If the user is removed from the account, that authentication token will be revoked.**" - "API access is also limited by the user level (role) of the authorizing user. **The role is not fixed, and can change over time.**" Actions the role does not permit return **403**. - The role on a token can be read back from the **API Root** endpoint (`GET /3.0/`). - Mailchimp's own recommendation: "When creating an API key or authorizing an app, we recommend using an admin user when possible. **OAuth integrators may want to inform the user if the application won't work at their user level.**" Practical guidance to give customers: authorize as an **Owner or Admin** — an account where the connector needs write access will 403 its way through a Manager's token — and understand that a connection dies the day that person is removed from the account. Where a customer wants least privilege, the honest answer is "authorize with a dedicated Mailchimp user at the lowest level that still works, and expect 403s if it is too low," not "we'll request fewer scopes." > **As of 2026-09-20, this platform's Mailchimp connector** sends exactly `response_type`, `client_id`, `redirect_uri` > and `state` on the authorize call — no `scope` (correct; there is none) and **no PKCE** (Mailchimp documents none). > Its object coverage spans audiences/lists, contacts, campaigns and campaign reports, with **create and update on > audiences, contacts and campaigns** and native audience webhooks — so customers must authorize with a user whose > level permits writes. Confirm the current coverage with the connector's owner before telling a customer what the > connection will and won't touch. ## 6. The data-centre prefix — the step people skip There is no single API host. After the token exchange you must call the metadata endpoint, read the account's server prefix, and **store it alongside the token**: ``` GET https://login.mailchimp.com/oauth2/metadata Authorization: OAuth ``` Mailchimp's guide documents `dc` (the server prefix, e.g. `us19`) and instructs you to "persist both the access token and the server prefix". The root then becomes `https://.api.mailchimp.com/3.0/`. Fundamentals gives three ways to find a data centre, and for OAuth only one of them applies — this endpoint. The other two (the URL of the API-keys page; the `-us6` suffix on an API key) are for key-based auth. Things that make this step go wrong: - **It returns 200 on failure.** Verified live 2026-09-20: with no token, and with an invalid token, the endpoint returns **HTTP 200** and a JSON body `{"error":"invalid_token","error_description":"Unable to load login and user"}`, with the real signal in a `WWW-Authenticate: OAuth realm='Service', error='invalid_token', …` header. **Gate on the presence of the prefix in the body, not on the status code.** A client that trusts 200 will store an empty host and fail every subsequent call for reasons that look nothing like an auth problem. - **`OAuth` vs `Bearer`.** Mailchimp's guide uses `Authorization: OAuth ` for this endpoint. Probing it on 2026-09-20, a `Bearer` header was parsed as a valid scheme too (it produced `invalid_token`, not a malformed-request error), and Marketing API fundamentals documents `Bearer` for API calls. Prefer the documented `OAuth` prefix here and `Bearer` (or HTTP Basic with username `anystring`) on the API itself. - **Never hardcode a host.** `us1`, `us19` and `us21` all appear in Mailchimp's own documentation links; none of them is "the" data centre. A bare `api.mailchimp.com` with no prefix is not a working Marketing API host for any account. - **Re-check on reconnect.** Store the prefix per connection, refresh it whenever a customer re-authorizes, and treat a missing prefix as a hard failure of the connect flow rather than something to paper over with a default. > **As of 2026-09-20, this platform's Mailchimp connector** performs exactly this discovery step immediately after the > exchange, stores the per-account API host returned by the metadata call, and additionally reads the user's name and > email out of the metadata response's login block to label the connection. For key-based connections it instead > derives the host from the text after the last `-` in the key. Two things to confirm with the connector's owner: that > the discovery call's **200-on-error** case is treated as a failure (see above), and that a connection whose host was > never discovered is surfaced as broken rather than left pointing at the prefix-less default. ## 7. Capture the credentials, and rotation From the app page immediately after **Create**: - **`client_id`** and **`client_secret`** — "This is the only time you'll be able to see `client_secret`, so you'll need to copy it and store it securely." - The endpoints, which are the same for every app: authorize `https://login.mailchimp.com/oauth2/authorize`, token `https://login.mailchimp.com/oauth2/token`, metadata `https://login.mailchimp.com/oauth2/metadata`. - Note that there is no sandbox/production split — the credentials you just made are production credentials. The exchange, as Mailchimp documents it, is a **form-encoded POST with the credentials in the body** — not HTTP Basic: ``` POST https://login.mailchimp.com/oauth2/token grant_type=authorization_code&client_id=…&client_secret=…&redirect_uri=…&code=… ``` **Rotation** (Registered Apps → **Edit** on the app → Client secret → **Rotate** → type `ROTATE` → Rotate Client Secret) shows `client_id` and the new `client_secret` once, in a modal. Rotation breaks every deployment still holding the old secret, so never rotate without explicit go-ahead and a cutover plan. Note the asymmetry that makes rotation survivable here: because Mailchimp tokens never expire and are never refreshed (§8), already-issued customer tokens keep working — it is *new authorizations* that break until the new secret is deployed. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. > **As of 2026-09-20, this platform's Mailchimp connector** exchanges the code exactly as documented — form-encoded > POST carrying `client_id`, `client_secret`, `code`, `redirect_uri` and `grant_type` in the body — and then sends the > resulting token as a bearer token on Marketing API calls. It also supports key-based connections, authenticating > those with HTTP Basic using the literal username `anystring`, which is what Mailchimp's docs specify. ## 8. Token behaviour — short section, load-bearing - **Access tokens do not expire.** Mailchimp: "Mailchimp Marketing access tokens do not expire, so you don't need to use a `refresh_token`. The access token will remain valid unless the user revokes your application's permission to access their account." - **There is no refresh token and no refresh flow.** Do not build one, and do not treat a 401 as "refresh and retry" — there is nothing to refresh. A 401 means the token is dead and the customer must re-authorize. - **Revocation happens three ways, all silent from your side**: the customer disconnects the integration in their Mailchimp account (**Integrations → Manage**); the authorizing user is removed from the account (fundamentals: that token "will be revoked"); or the account is deactivated (403 *User Disabled*). - **Non-expiring tokens are a storage problem, not a convenience.** They never rotate, so a leaked token stays valuable indefinitely. Mailchimp: "You are responsible for the security of API tokens… Because of the potential security risks associated with exposing an API token, Mailchimp does not support client-side implementation of our API using CORS requests." Encrypt at rest, and delete tokens on uninstall — Mailchimp's integration guidance requires it: "If a user deletes or uninstalls an integration, it's important that the integration also deletes any relevant data like OAuth tokens and other syncing configurations." - Mailchimp exposes **Authorized Apps** endpoints (`GET /3.0/authorized-apps`, `GET /3.0/authorized-apps/{app_id}`) to list an account's registered, connected applications — useful when a customer swears they connected and you see nothing. ## 9. Limits, review, and listing **Rate limits are concurrency-based.** The Marketing API allows **10 simultaneously processing requests**, and the limit is **per user, not per API key or per client** — so your connector shares that budget with everything else the customer has connected under that user, including their own scripts. Exceeding it returns 429 (`TooManyRequests: You have exceeded the limit of 10 simultaneous connections`). Also: - Slow calls hold a slot: "The length of time it takes a request to run will affect how many requests you can make." Requests that time out for you may still be running server-side, still holding a slot. - **120-second stream timeout** per call; a 502 with an HTML body usually means the CDN closed a timed-out request. - At very high volume you may get a **429 or 403 with no JSON body** — handle the bodiless case. - Separate limit: **500 pending batch webhook events per user**, returned as a 429 when starting a new batch operation. - "Currently there are no options to raise the limit on a per-customer basis." Cache, paginate, use partial responses, and push long-running work to the Batch endpoint. **Listing is optional.** A registered app works through its OAuth flow without any review, listing or approval. What review buys is placement in the **Mailchimp Marketplace**, via the **Integration Partner Program** — and the bar is usage, not paperwork: | Requirement | Detail | | --- | --- | | Current API version | Built on the current version of the Marketing API (3.0) | | OAuth 2 | Required — "it's a requirement to join the Integration Partner Program and have your integration listed" | | **25+ active users in the last 90 days** | "An active user is a unique OAuth sending API calls" — a chicken-and-egg bar you must clear *before* listing | | **Three+ core features** | From Mailchimp's list: tags / custom events / merge fields, audience segments, automations, connected sites, email templates, landing pages, signup forms, Transactional sending, full e-commerce sync, contacts with correct subscription statuses respecting GDPR fields, conversations, Content Studio images, reports | | Test account | "Provide a test account with full access and password reset ability using the email address `integrationtest@mailchimp.com`" | | End-user support | Mailchimp routes user inquiries about your integration to your support channel | | Terms | Agree to partnership terms and conditions | Mailchimp "will review and test your integration to verify it meets the requirements"; the program page does not publish a turnaround, and applications go through an external application portal. **Stop and ask** before answering anything you would have to invent — user counts, support commitments, compliance or legal attestations, or which feature list to claim. A plausible guess is worse than a blank, and expensive to walk back after submission. **Transactional is a different product.** Mailchimp Transactional (formerly Mandrill) lives at `https://mandrillapp.com/api/1.0/`, authenticates with its **own API key passed in the JSON body of every POST**, and Mailchimp documents no OAuth flow for it. A Marketing API OAuth token is not a Transactional credential. It is a paid add-on requiring a Standard or Premium Mailchimp account. If the request turns out to be about sending transactional email, this run does not produce the right credentials — say so and stop (see **Stop and ask**). ## 10. Verify end-to-end Testing inside the account that owns the app proves almost nothing — that user is the Owner. Test the customer's path: 1. Authorize from a **second, unrelated Mailchimp account** through your platform's real connect flow. A free account works for the flow itself. 2. Confirm the callback landed on the registered redirect URI **character for character**, and that `state` came back. 3. Confirm the exchange returned an access token, and that **no** refresh token was expected or required (§8). 4. Confirm the metadata call returned a **server prefix** and that your client stored it — and prove the failure path too, by pointing the same call at a junk token and checking your client treats that 200 as an error (§6). 5. Call `GET https://.api.mailchimp.com/3.0/ping` — the cheapest proof the token and host agree — then `GET /3.0/` (API Root) to read back the **account name and the authorizing user's role**, and record the role. 6. If the connector writes, exercise one write with that role. A read-only role fails at write time, not at connect time, which is the worst possible moment to find out. 7. Disconnect from the customer side (Mailchimp account → **Integrations → Manage**) and confirm your platform notices and stops. | Symptom | Cause | | --- | --- | | Authorize URL shows a Mailchimp **404 "Page Not Found"** page | Unknown or wrong `client_id`, or the app lives in a different account (verified live 2026-09-20) | | Token exchange returns **400 `{"error":"invalid_client"}`** | Wrong `client_id`/`client_secret` pair, or credentials missing from the form body (verified live 2026-09-20) | | Token exchange fails after a working authorize | `redirect_uri` on the exchange not byte-identical to the one on the authorize call (§4) | | Redirect "worked yesterday", fails now | Exact matching since 2020-08-11 — a trailing slash or added path breaks it (§4) | | Local callback never fires | `localhost` instead of `127.0.0.1` (§4) | | Metadata call "succeeds" but the host is empty | 200-on-error: body was `{"error":"invalid_token",…}` (§6) | | Every API call 404s, or hits a host that resolves to nothing | Data-centre prefix missing or hardcoded — no account lives at a bare `api.mailchimp.com` (§6) | | 401 *API Key Invalid* | Token revoked, or the customer disconnected the integration. No refresh exists — re-authorize (§8) | | 403 *Forbidden* on some endpoints only | Authorizing user's level does not permit that action, or their level changed since authorizing (§5) | | 403 *User Disabled* | The Mailchimp account is deactivated (§8) | | 403 *Authorization Failure: User does not have access to the requested operation* | The customer's **pricing plan** does not include that feature — not a permissions bug you can fix | | Sudden total failure for one customer | The authorizing user was removed from their Mailchimp account; that token is revoked (§5) | | 429 despite modest traffic | 10 simultaneous connections **per user**, shared with everything else that user connected (§9) | | 429 or 403 with no JSON body | Very high volume — handle the bodiless case (§9) | | 502 with an HTML body | Request timed out and was closed by the CDN; use pagination, partial responses, or Batch (§9) | | 426 | Request made over HTTP, or below TLS 1.2 | | Customer expects read-only access | Not possible — Mailchimp has no scopes; the only lever is the authorizing user's role (§5) | To rehearse error handling without breaking anything, Mailchimp supports an **`X-Trigger-Error` header** on Marketing API requests: sending `APIKeyMissing`, for example, triggers a 401. ## 11. Hand off — never commit the secret - **Do not** write the client secret, an access token or an API key into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. Mailchimp's own sample code says the same thing next to its placeholder constants. - Remember that Mailchimp tokens **never expire** — a leaked one is valuable forever and there is no rotation to save you. If a token is exposed, the customer must disconnect the integration to kill it. - If a code change is needed (a callback host, the metadata-discovery guard, the per-account host), keep it secret-free and say plainly what the human must set out of band. - Close with: the app name and the Mailchimp account that owns it; the client ID; where the secret was delivered and that it cannot be viewed again; the exact redirect URI(s) registered and whether one app covers every region; the authorize / token / metadata endpoints; **that Mailchimp has no scopes and the token is account-wide**; the fact that tokens do not expire and there is no refresh; the role the test account authorized with; Marketplace/Partner Program status if relevant; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the request is really about **Transactional / Mandrill** (different product, different auth, no OAuth — §9); the account is gated behind verification you cannot complete (§2); the Registered Apps form turns out to accept **only one redirect URI** and the platform needs four, because that decides how many apps and client IDs exist (§4); someone asks for **read-only or per-audience access**, which Mailchimp cannot grant (§5); rotating a live app's secret is on the table (§7); a customer's connection depends on a specific Mailchimp user staying in their account (§5); the Partner Program application asks for user counts, support commitments, compliance or legal claims (§9); or the portal does not match the **Platform state** section above. When the portal behaves in a way the docs did not describe, say what you found rather than picking the option that lets you keep going. ## References - Access Data on Behalf of Other Users with OAuth 2 (registration, full flow, secret rotation) — https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ - Marketing API Fundamentals (data centres, auth, user roles, API limits) — https://mailchimp.com/developer/marketing/docs/fundamentals/ - Errors and error glossary (401 / 403 / 429 / 426, `X-Trigger-Error`) — https://mailchimp.com/developer/marketing/docs/errors/ - Integrations documentation (building, requirements, partner program) — https://mailchimp.com/developer/marketing/docs/integrations/ - Integration Partner Program — https://mailchimp.com/developer/integration-partner-program/ - Release notes — https://mailchimp.com/developer/release-notes/ - Stricter rules for URL matching in OAuth 2 (2020-08-11) — https://mailchimp.com/developer/release-notes/stricter-rules-for-url-matching-oauth-2/ - API Root endpoint (read back account name and role) — https://mailchimp.com/developer/marketing/api/root/ - Ping endpoint — https://mailchimp.com/developer/marketing/api/ping/ - Authorized Apps endpoints — https://mailchimp.com/developer/marketing/api/authorized-apps/ - Methods and parameters (pagination, partial responses) — https://mailchimp.com/developer/marketing/docs/methods-parameters/ - Marketing API Quick Start — https://mailchimp.com/developer/marketing/guides/quick-start/ - Synchronize Audience Data with Webhooks — https://mailchimp.com/developer/marketing/guides/sync-audience-data-webhooks/ - Mailchimp Transactional fundamentals (separate product and auth) — https://mailchimp.com/developer/transactional/docs/fundamentals/ - Help: manage user levels in your account — https://mailchimp.com/help/manage-user-levels-in-your-account/ - Help: about API keys — https://mailchimp.com/help/about-api-keys/ - Help: about integrations (how customers connect and disconnect) — https://mailchimp.com/help/about-integrations/ - Registered Apps page — https://admin.mailchimp.com/account/oauth2/ - Mailchimp Marketplace — https://mailchimp.com/marketplace/ - Integrations directory — https://mailchimp.com/integrations/ - API status — https://status.mailchimp.com/ ## meta-ads-oauth-app URL: https://docs.unified.to/skills/meta-ads-oauth-app/SKILL.md # Meta Ads (Marketing API) OAuth2 App Registration Get a Meta app that can actually read a customer's live ad accounts: a Business-type app carrying the **Marketing API** product and a **Facebook Login for Business** configuration, an App ID and App Secret, and — the part that takes weeks — the approvals and the rate-limit tier that stand between a valid credential and a working one. The registration mechanics are not here; they are identical for every Meta product and live in the base skill. Three things are specific to Meta Ads, and all three are decided before you write a line of code. **First, Standard Access arrives free with the product, and that is the trap.** Meta grants the Marketing API permissions automatically "when you add the Marketing API product to your app" — at Standard level, which reaches only people who hold a role on your app. Everything looks configured and nothing works for a customer. All three permissions this connector needs are also on the Access Verification list, so a second, independent gate produces the same symptom with a different error. **Second, the token model decides your operational burden, and you pick it in the dashboard.** A Meta user access token is short-lived, exchanges once into a ~60-day token, and cannot be renewed — so re-authorization becomes a scheduled campaign. Choosing a **Business Integration System User access token** in your login configuration instead gives you tokens that default to never expire. That choice is made at registration, and changing it later re-authorizes every customer. **Third, the Marketing API version clock is 90 days, not two years.** The two-year rule is the Graph API's. The Marketing API ships every few months and retires versions in about a quarter. ## Built on: `meta-graph-app` **Read `meta-graph-app` first, in full, then come back here.** It owns: - **The developer account, the business portfolio that claims the app, and the app itself** — the creation wizard and its two irreversible choices (app type, and any use case you add), plus the Business-vs-Consumer difference that means a Business app has **no app modes** at all. - **Redirect URIs** — exact matching, HTTPS only, the dashboard's silently appended trailing slash, the requirement that authorize and token exchange send the same value, and this platform's per-data-center callback list. - **Access levels and the four review regimes** — Standard vs Advanced Access, what an App Review submission asks for, Business Verification, **Access Verification / Tech Provider** (including the `(#100)` error only your customers ever see), and the Ongoing Review and annual Data Use Checkup that can take it all away again. - **The App ID and App Secret**, `appsecret_proof` and the "Require App Secret" switch, and what secret rotation breaks; plus the rule that Meta issues no OAuth refresh tokens on any surface. - **Graph API versioning and the two-year rule**, the `X-App-Usage` and `X-Business-Use-Case-Usage` headers, and the generic `#1` / `#4` / `#10` / `#17` / `#100` / `#102` / `#190` / `#200` and 80000-series error codes. If you loaded only this file, you are missing all of the above, and nothing below substitutes for it — in particular, creating the app, registering redirect URIs, getting Advanced Access and capturing the secret are base-skill steps. Where this file and the base disagree, this file wins, because Business-type apps and the Marketing API deviate from the general rules in exactly the places called out below. ## Inputs specific to Meta Ads The base skill's input table applies in full. Collect these on top of it, in the same batch. | Input | Notes | | --- | --- | | **Token type**: user access token or Business Integration System User token | The single biggest operational decision here (§1, §5) | | **Read-only or read-write?** | `ads_read` alone vs `ads_read` + `ads_management` (§2) | | **Does the connector resolve owning businesses or list business-owned assets such as product catalogs?** | The only reason to request `business_management` (§2) | | **A test ad account** you control, plus one owned by someone with no role on the app | (§8) | | **Expected call volume, and when real customer traffic starts** | Decides how long you sit at the throttled tier (§4) | ## Quick Start 1. Read **Platform state** below — the tier names and the version clock both changed recently. 2. Create a **Business**-type app per the base skill, then add the **Marketing API** product and **Facebook Login for Business** (§1). 3. Create a login **configuration** and pick the token type there — this is the 60-day decision (§1, §5). 4. Register the redirect URIs in the Facebook Login settings panel (§1). 5. Request only the permissions the connector calls (§2). 6. Run the base skill's four review regimes; note that all three ads permissions need Access Verification too (§3). 7. Separately upgrade the **Marketing API access tier** from Limited to Full once you have 500 clean calls (§4). 8. Wire the short-lived → long-lived exchange, or the system-user path, and schedule re-authorization (§5). 9. Pin a Marketing API version and calendar its 90-day grace window (§6). 10. Verify with an ad account belonging to someone with **no role on the app** (§8), then hand off (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) Read this alongside the base skill's Platform state; these are the Marketing-API-specific facts. - **The Marketing API access tier was renamed.** Meta's authorization page: "Tier labels have been updated: 'Standard Access' is now **Limited Access**, and 'Advanced Access' is now **Full Access**." Limited is "Heavily rate-limited per ad account. For development only. Not for production apps running for live advertisers"; Full is "Lightly rate limited per ad account." - **The Full Access threshold was lowered.** "The revised qualification threshold for Full Access has been reduced from 1,500 to **500 Marketing API calls** in the past 15 days," alongside "an error rate of less than 15% in the last 500 calls." - **Meta's own docs have not finished the rename.** The Graph API rate-limiting page and the Ads Insights best-practices page still describe the "Ads Management Standard Access" *feature* and report `ads_api_access_tier` values of `development_access` and `standard_access`. Two Meta pages, two vocabularies, one mechanism. Read both. - **The Marketing API versions page says "The current version of the Marketing API is v25.0" while the changelog lists v26.0 (released 29 July 2026) as the latest.** Trust the changelog table. - **Marketing API versions live about a year, with a documented 90-day floor.** Listed versions: v26.0 (29 Jul 2026, expiry TBD), v25.0 (18 Feb 2026, expiry TBD), v24.0 (8 Oct 2025 → **6 October 2026**). The rule is "at least a 90-day grace period" after the next release, not the Graph API's two years (§6). - **Marketing API auto-upgrade exists since May 2024 but only partly saves you** — it covers endpoints unaffected between versions; endpoints the new version changed fail outright (§6). - **Facebook Login for Business is the stated preferred path** for "tech providers building integrations with Meta's business tools," it requires a Business-type app, and in it `config_id` "has replaced `scope`" (§1). If the App Dashboard does not look like this, stop and report what you actually see rather than clicking on. ## 1. The two products, and the configuration that carries the real decision Create the app as a **Business** type per the base skill, then in the dashboard: 1. **Add Product** → set up **Marketing API**. This is what makes the ads permissions available; Meta grants them "Automatically granted when you add the Marketing API product to your app," at Standard level only (§3). 2. **Add Product** → set up **Facebook Login for Business**. 3. Left menu → **Configurations** → **+ Create configuration** (or **Create from template**). Name it, then **choose the access token type**: a *User access token* (your app users log in with their personal Facebook account) or a *System-user access token* (granted by the business client's portfolio), plus token expiration. Select the assets and permissions the configuration asks for. The configuration is what a customer is shown at consent time, and its ID is the `config_id` you send instead of `scope`. Redirect URIs are registered under **App Dashboard → Products → Facebook Login (for Business) → Settings → Client OAuth Settings → Valid OAuth Redirect URIs**. The matching rules, the trailing-slash trap and the callback list are in the base skill — re-read the saved list there before debugging anything else. Two properties of Facebook Login for Business that change how you design the consent step: - **It is all-or-nothing.** "All permissions that your app asks for during login must be granted by your app user or your app won't be granted any permissions." There is no partial grant to fall back on, so an over-broad configuration converts worse, not merely slower. - `email` and `public_profile` "are automatically granted to all apps but at least one other supported permission must be included for each app installation." They are identity-only and grant no ads access. You can create several configurations and present different ones to different users — a read-only tier and a full management tier without a second app. **Ad account IDs.** Meta writes them conventionally as `act_`; the bare numeric form and the prefixed form are both in circulation and mixing them is a routine configuration error. Which form a platform wants is a platform question, not a Meta question. ## 2. Permissions Three permissions matter. Each one you add is a separate App Review submission and widens the Access Verification description you have to defend. | Permission | Grants | Add it when | | --- | --- | --- | | `ads_read` | Reading ad accounts, campaigns, ad sets, ads, creatives and insights | Always | | `ads_management` | Everything `ads_read` does, plus creating and modifying ads | Only if the connector writes | | `business_management` | Reading and managing the business portfolio and assets it owns | Only if you resolve the owning business or list business-owned assets such as product catalogs | Do not request `ads_management` for a read-only connector; the base skill explains what an unused permission costs at review time. **Product facts about the connector, as of 2026-09-20.** Recorded from one day's configuration; confirm with the connector's owner before relying on any of it. - It requests `ads_read` for every readable ads object, `ads_management` for every write, and `business_management` on exactly one path — the promoted-object listing that must resolve the owning business before it can enumerate that business's product catalogs. `email` and `public_profile` are its login/identity scopes. - It uses the **classic Facebook Login manual flow, not the Facebook Login for Business `config_id` flow**: it authorizes at `https://www.facebook.com/v25.0/dialog/oauth` sending `client_id`, `redirect_uri`, `state` and `scope`, and exchanges the code at `https://graph.facebook.com/v25.0/oauth/access_token` with `client_id`, `client_secret`, `code` and `redirect_uri` as POST parameters. No PKCE (`code_challenge` / `code_verifier`) is sent, and no `appsecret_proof` is computed — so do **not** turn on "Require App Secret" for this app. - It presents the access token as a **URL query parameter** (`access_token=…`) rather than an `Authorization: Bearer` header. Meta accepts both; the query-parameter form is the one that ends up in proxy and access logs. - It pins **v25.0** — not only in the authorize and token URLs but hard-coded into the request path of essentially every object it implements. Re-pinning is a multi-file change, not a config edit (§6). - It **carries no long-lived-token exchange and no token renewal at all**. Nothing in it turns the short-lived token from the code exchange into a 60-day token, and nothing renews one (§5). This is the single most consequential gap to raise with the connector's owner. - It also supports a non-OAuth path: a directly supplied access token plus a numeric ad account ID, documented for a long-lived user token or a Business-Settings System User token (§5). - On connect it reads the authorizing identity and enumerates that identity's ad accounts, storing their IDs. It deliberately requests only id and name there so the call needs `ads_read` and not `business_management`. - It parses Meta's error envelope and remaps HTTP 500 responses to 400 so the provider message survives. It does **not** read `X-Business-Use-Case-Usage`, `X-Ad-Account-Usage` or `x-fb-ads-insights-throttle` (§7). - Its published partnership/info link points at a Marketing API partners page that returns **404** today. **Partial-grant risk.** Under the classic `scope` flow a user can decline individual permissions, so the connector can hold a token that authorized cleanly and then fails only on the promoted/catalog path — a late, confusing failure. Facebook Login for Business inverts this: all-or-nothing, so it fails early instead (§1). Decide which failure mode you want before launch. ## 3. What the base skill's gates mean here All four review regimes in the base apply unchanged. Three Ads-specific consequences: **Standard Access is handed to you, which is why people miss the problem.** Adding the Marketing API product grants the ads permissions immediately — at Standard level, reaching only app-role users. Nothing in the dashboard looks wrong, and your own testing passes. Advanced Access is the fix, per permission, through App Review. **All three permissions are on the Access Verification list.** `ads_management`, `ads_read` and `business_management` are all covered, so a multi-tenant ads connector needs Tech Provider status for the claiming business on top of App Review and Business Verification. Expect the `(#100)` symptom described in the base skill. **Facebook Login for Business reduces the ongoing burden.** "Apps using Facebook Login for Business have reduced requirements for certain ongoing compliance reviews because they are limited to accessing business permissions and features" — a second, quieter reason to build on the business login product rather than the consumer one. ## 4. The Marketing API access tier: Limited → Full Independent of every gate above, and about rate limits rather than who can connect. Check at **App Dashboard → App Review → Permissions and Features**; upgrade with **+Upgrade** on the Marketing API Access Tier feature. The requirements are usage-based, not a form: "at least 500 Marketing API calls in the last 15 days" with "an error rate of less than 15% in the last 500 calls." Note the ordering trap. You cannot accumulate 500 production calls until customers can connect, and customers cannot connect until the gates clear — so plan for a window in which the connector is live, correct and heavily throttled. This is also the strongest argument against spinning up a fresh app: a new App ID starts at Limited with zero call history toward the threshold, on top of losing its App Review approvals. The tier also caps system users on your own portfolio: Limited allows "1 system user and 1 admin system user," Full allows "10 system users and 1 admin system user." ## 5. Tokens: 60 days, no renewal, and the way out | Token | How you get it | Lifetime | | --- | --- | --- | | Short-lived user token | The code exchange at `graph.facebook.com/v{n}/oauth/access_token` | "about one to two hours" | | Long-lived user token | `GET oauth/access_token?grant_type=fb_exchange_token&client_id=…&client_secret=…&fb_exchange_token=` | "about 60 days" | | Business Integration System User token | Chosen as the token type in a Facebook Login for Business configuration (§1) | Defaults to **never expire** | | System User token (your own business) | Meta Business Settings, on your own portfolio | Long-lived / non-expiring | The facts that decide whether connections survive: - **Expiry is terminal.** Meta: "You can not use an expired token to request a long-lived token. If the token has expired, your app must send the user through the login flow again to regenerate a new short-lived access token." So re-authorization at around day 55 is a **scheduled operational task with a customer email attached**, not an error condition. A connector that treats a 60-day expiry as an incident pages someone every 60 days per customer. - **A connector that never exchanges dies in an hour or two**, not in 60 days. That is the more common failure and it presents as an intermittent auth bug. - **The Business Integration System User token is the multi-tenant answer.** It is "associated with your business client's business portfolio rather than a specific user," uses the **Authorization Code grant only**, defaults to never expire, and access "is explicitly delegated at the time of authorization" — your app reaches only the assets the client designated. If you want an expiring one, `set_token_expires_in_60_days` exists, and a 60-day system-user token can be renewed through the system-user token API without sending the human back through consent. - **Plain System Users do not help a multi-tenant platform.** A system user represents "servers or software making API calls to assets owned or managed by a Business Manager" — *that* business manager. They are the right answer for a customer automating their own ad account and the wrong answer for a platform connecting many customers, because you would need a system user created inside each customer's portfolio by each customer's admin. The Business Integration System User token is the productized version of that idea, and the one that scales. - Picking the token type is a **dashboard decision on the login configuration** (§1). Switching a live app between user tokens and system-user tokens changes the grant flow and re-authorizes every customer. Decide before launch. ## 6. The Marketing API version clock The base skill's Graph API two-year rule does **not** govern `/v{n}/act_…` calls. The Marketing API "has its own versioning scheme," and it is far shorter: - New versions land "approximately every four months." - The previous version is supported "for at least 90 days"; after that "the deprecated version stops working." - **Auto-upgrade is partial.** Since May 2024 a call to a deprecated version is upgraded to the next available version *only if that endpoint is unaffected between the two versions*. Endpoints the new version changed fail outright. The Marketing API changelog for each release lists exactly which endpoints changed — that list is your actual upgrade checklist. - **Unversioned calls are rejected outright**: "Unversioned calls are invalid and will fail when made against Marketing API endpoints." This is stricter than the Graph API fallback behaviour described in the base. - Consequence: a connector can pass every test and then start failing on *some* endpoints on a date nobody deployed, while other endpoints quietly answer from a newer version than the one in the URL. Both halves are worse than a clean break. - A connector that uses one version string for both its OAuth dialog/token calls and its Marketing API calls is accepting the **shorter** of the two clocks for everything. ## 7. Rate limits: two more ceilings beyond the base The base skill covers `X-App-Usage` and `X-Business-Use-Case-Usage`. Meta Ads adds two more, and the access tier is worth a factor of several hundred on the first. **What the tier is worth.** The published business-use-case formulas: Ads Management is `300 + 40 × active ads` per hour at the lower tier versus `100000 + 40 × active ads` at the higher one; Ads Insights is `600 + 400 × active ads − 0.001 × user errors` versus `190000 + 400 × active ads`. That is why §4 is not optional for production. **Per ad account.** `X-Ad-Account-Usage` carries `acc_id_util_pct` (percentage of this ad account's limit consumed) and `reset_time_duration` in seconds. A single heavy customer can throttle themselves while your app-level headroom looks fine, and vice versa — so backoff keyed only to app-level usage will be wrong for that customer. **Insights has its own load limit on top of both.** `/insights` calls are subject to "a fixed load limit per application per second," measured by resource cost rather than call count, reported in `x-fb-ads-insights-throttle` as `{"app_id_util_pct":…, "acc_id_util_pct":…, "ads_api_access_tier":…}`, and applied "to each app sending synchronous and asynchronous `/insights` calls combined." Breaching it returns `error_code = 4`. Meta's guidance: "Try sync calls first and then use async calls in cases where sync calls timeout," "spread your `/insights` queries by pacing them with wait time in your job," and "add a back-off mechanism … when you come close to hitting 100% utility for your application, or for your ad account." **Which 80000-series code is which**, since the two ads buckets are easy to confuse: `(#80000)` is **Ads Insights** and `(#80004)` is **Ads Management**, both with subcode `2446079`. One dated reporting change worth carrying into any insights work: since **10 June 2025**, `reach` (and `frequency`, `cpp`) is not returned for standard queries that apply `breakdowns` with a `start_date` more than 13 months old; asynchronous jobs can still retrieve it, limited to 10 requests per ad account per day and metered by `x-Fb-Ads-Insights-Reach-Throttle`. ## 8. Capture the credentials, and verify The App ID and App Secret, and where they live, are in the base skill. Unlike the Instagram surface there is **no second, separately-displayed pair** here — if someone hands you an "Instagram App ID" for an ads connector, they are on the wrong product. Record these Ads-specific values alongside the base skill's list: - The **Facebook Login for Business configuration ID** (`config_id`) and, per configuration, the token type and expiration you chose (§1, §5). - The **pinned Marketing API version** and the date its grace window ends (§6). - The **Marketing API access tier**, Limited or Full (§4) — separate from each permission's access level. Verification follows the base skill's round trip, with the Ads-specific steps layered on: exchange the short-lived token for a long-lived one and confirm the connector **stored the new value**; enumerate ad accounts; make one real read against a campaign or insights endpoint with the pinned version; then repeat the whole thing against an ad account owned by someone with no role on the app. | Symptom | Cause | | --- | --- | | `(#294)` managing ads requires `ads_management` and allowlisted app access | A read-only credential attempting a write, or the app is not approved for the Marketing API (§2, §3) | | Works for ~1–2 hours after each connect, then `(#190)` | The short-lived → long-lived exchange was never performed (§5) | | Works for ~60 days, then `(#190)` for everyone at once | Long-lived tokens expired; there is no renewal, so this is scheduled re-authorization, not an outage (§5) | | `(#200)` on one customer only | That user lacks the required role on the ad account — customer-side, not app-side (§2) | | Authorization succeeded but only the catalog/promoted path fails | `business_management` was declined individually under the classic scope flow (§2) | | `(#80000)` subcode `2446079` | **Ads Insights** business-use-case rate limit (§7) | | `(#80004)` subcode `2446079` | **Ads Management** business-use-case rate limit — not the insights one (§7) | | `(#4)` on `/insights` specifically | The per-app insights load limit; read `x-fb-ads-insights-throttle` (§7) | | One customer throttled while your app-level usage looks healthy | Per-ad-account ceiling; read `X-Ad-Account-Usage` (§7) | | Throttled constantly in production despite correct code | App still on the **Limited** Marketing API access tier (§4) | | Some endpoints start failing on a date nobody deployed | The pinned Marketing API version passed its 90-day grace and those endpoints changed between versions (§6) | | Responses subtly change shape without a deploy | The other half of the same cause: unaffected endpoints were auto-upgraded (§6) | | `reach` missing from an insights response that used to include it | `breakdowns` with a `start_date` over 13 months old, since 10 June 2025 (§7) | ## 9. Hand off Follow the base skill's handoff rules and add, to its closing list: the login configuration ID, token type and expiry choice; the Marketing API access tier; the pinned Marketing API version and the date its grace window ends; and the re-authorization cadence the platform must run if it is on user tokens. ## Stop and ask The base skill's stop-and-ask list applies. Additionally, hand back to a human when: the choice between user access tokens and Business Integration System User tokens is not clearly implied, since it changes the grant flow, the ongoing operational burden and, if changed later, re-authorizes every customer; someone proposes requesting `ads_management` or `business_management` that the connector does not actually call; the platform has no re-authorization story for 60-day tokens; the connector's pinned Marketing API version is inside or past its 90-day grace window and re-pinning touches many call sites; or the business decision about read-only versus full management has not been made and it determines the review scope. ## References Meta Ads specific. The generic Meta developer-account, app-creation, review, credential and Graph API references are in `meta-graph-app`. Every link below verified to return HTTP 200 on 2026-09-20. - Marketing API — https://developers.facebook.com/docs/marketing-api - Marketing API authorization (permissions, access tiers, system user counts) — https://developers.facebook.com/docs/marketing-api/overview/authorization - Marketing API get started: authorization — https://developers.facebook.com/docs/marketing-api/get-started/authorization - Marketing API get started: authentication (token types) — https://developers.facebook.com/docs/marketing-api/get-started/authentication - Marketing API access tiers — https://developers.facebook.com/docs/marketing-api/access - Marketing API versioning, the 90-day grace and auto-upgrade — https://developers.facebook.com/docs/marketing-api/versions - Marketing API changelog (per-version changed endpoints) — https://developers.facebook.com/docs/marketing-api/changelog - Marketing API best practices — https://developers.facebook.com/docs/marketing-api/best-practices - Ads Insights best practices, throttle headers and async jobs — https://developers.facebook.com/docs/marketing-api/insights/best-practices - Marketing API system users — https://developers.facebook.com/docs/marketing-api/system-users - System users: install apps, generate, refresh and revoke tokens — https://developers.facebook.com/docs/business-management-apis/system-users/install-apps-and-generate-tokens/ - Facebook Login for Business (configurations, `config_id`, token types) — https://developers.facebook.com/docs/facebook-login/facebook-login-for-business - Manual login flow (dialog URL, redirect URI panel, code exchange) — https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow - Get long-lived tokens (`fb_exchange_token`, 60 days) — https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived ## meta-graph-app URL: https://docs.unified.to/skills/meta-graph-app/SKILL.md # Meta App Dashboard — shared OAuth2 registration mechanics Everything that is the same for every Meta Graph connector: the developer account, the business portfolio, the app, its redirect URIs, its **App ID and App Secret**, the review gates that decide whether anyone but you can use it, and the versioning and rate-limit rules the credentials then live under. The product skills on top of this one add only their own permission strings, hosts, token ladders and quirks. Two things are worth internalising before you start, because they are what make Meta slower than most portals. **The app is not the credential — the approvals are.** A perfectly configured app with a valid App ID and Secret can authorize every user on Facebook and still be usable only by your own team, because permissions default to an access level that excludes strangers. Getting past that is not one process but **four**: App Review, Business Verification, Access Verification, and the ongoing reviews that can take it all away again. They run on different tracks, with different owners and different failure symptoms. Budget weeks. **Several choices here are permanent.** App type, and any use case you add, cannot be changed or removed afterwards. Correcting either means a new app, a new App ID, and every existing customer re-authorizing. Read §3 before clicking. ## Inputs to collect before you start Ask in one batch; never invent. | Input | Notes | | --- | --- | | **App name**, contact email, icon, category | Shown on the consent screen; required before review (§3, §6) | | **Which Meta product** the connector actually uses | Decides app type and use case — both irreversible (§3) | | **Meta developer account** — whose personal Facebook account owns the app | (§2) | | **Business portfolio** that will claim the app, and its verification status | Blocking for two of the four review regimes (§2, §6) | | **Redirect URIs** — every callback host the platform serves | (§4) | | **Permission set** — what the connector genuinely calls | Each one is a separate review submission (§5, §6) | | **Privacy policy URL, terms URL, data deletion URL** | Required before submission (§6) | | **A test account** you control, and a **second** one with no role on the app | (§10) | | **Legal entity details** for Business Verification | A human decision — do not answer these (§6) | | **New app or edit to an existing one?** | (§1) | ## Quick Start 1. Read **Platform state** (below) and the product skill's own Platform state — Meta changes this surface often. 2. Confirm a **new app** is justified; a new App ID orphans every existing customer connection (§1). 3. Register the developer account and decide which **business portfolio** claims the app (§2). 4. Create the app, choosing app type and use case deliberately — neither can be undone (§3). 5. Add **every** redirect URI exactly, then re-open the saved list and read what was actually stored (§4). 6. Request only the permissions the connector calls, and know each one's access level (§5). 7. Give yourself a role, make real calls, then run the review regimes in parallel (§6). 8. Capture the App ID and App Secret; decide about `appsecret_proof` before enabling anything (§7). 9. Pin an API version explicitly and calendar its expiry (§8). 10. Verify as a user with **no role on the app** — the only test that exercises the gates (§10), then hand off (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **App types cannot be changed.** Meta: "App types cannot be changed. If your app needs products, permissions, or features that are unavailable to its current type you must create a new app with a different type instead." - **Use cases cannot be removed.** "Use cases cannot be removed after you create your app. You can add compatible use cases to an existing app, however, once added, a use case cannot be removed." - **Business apps have no app modes.** "Business apps do not have app modes and instead rely exclusively on access levels to determine who can grant them permissions." Development vs Live mode is a Consumer-app concept. If a ticket says "flip the app to Live mode" for a Business app, the ticket is describing a different surface (§5). - **Access Verification / Tech Provider is live** and gates ~30 permissions across ads, Instagram, Pages, Threads and WhatsApp. It is, in Meta's words, "independent of App Review and permission access levels" — a fourth thing to clear, not a rename of one you already know (§6c). - **Graph API versions expire on the two-year rule**: "A version will no longer be usable two years after the date that the subsequent version is released." Latest is **v26.0, released 29 July 2026**. Recent versions and expiries: v25.0 (18 Feb 2026 → 29 Jul 2028), v24.0 (8 Oct 2025 → 18 Feb 2028), v23.0 (29 May 2025 → 8 Oct 2027), v22.0 (21 Jan 2025 → 20 May 2027), v21.0 (2 Oct 2024 → 21 Jan 2027), v20.0 (21 May 2024 → 24 Sep 2026). - **Individual products set their own, shorter clocks.** The two-year rule is the Graph API's. At least one Meta API built on the same host expires versions in about 90 days. Check the product skill before assuming two years (§8). - **Meta issues no OAuth refresh tokens on any surface.** There is no `grant_type=refresh_token` anywhere in this platform. Each product has its own token-extension mechanism with its own endpoint, lifetime and terminal-expiry behaviour — read the product skill's token section, and plan re-authorization as routine operations, not as an incident (§7). - **Meta's own docs are inconsistent in places**, including tier vocabulary and "current version" statements that disagree between pages. Where two Meta pages conflict, prefer the changelog and the reference pages over the guides, and say in your handoff which one you followed. If the App Dashboard does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new App ID, and every existing customer connection is bound to the old one** — every customer re-authorizes. On Meta that is worse than on most portals, because approvals do not travel: App Review is per app and per permission, and a new app starts with none of it. Reuse the existing app for: adding a redirect URI, adding a permission (which still needs its own App Review submission), rotating a compromised secret, adding a login configuration, or diagnosing a failure. Register a **new** app only when the user explicitly wants one: a first app for this connector, a replacement for a compromised one, a deliberate split of deployment tiers, or a correction to an app type or use case that cannot be undone (§3). One approval does carry across: **Access Verification is judged on the claiming business, not the app**, so a second app under an already-verified business inherits Tech Provider status (§6c). Nothing else does. Say which path you are taking, and why, before you touch anything. ## 2. Accounts: developer and business portfolio Two distinct account objects, routinely conflated. - **Meta developer account** — a personal Facebook account registered as a developer at `https://developers.facebook.com/docs/development/register`. Apps are owned by a person, not a company, until a business portfolio claims them. - **Business portfolio** (Meta Business Manager, `business.facebook.com`) — required if the app "will access data that you don't own or manage," which is exactly what a multi-tenant connector does. At app creation you may defer it, but you will need one before Advanced Access. It is the entity that completes Business Verification, the entity Access Verification judges, and the entity whose "restricted" status silently un-verifies you (§6c). Connecting it up front avoids a re-submission. **Hand control back to the user for anything only a human can do**: Facebook account creation, phone and email verification, two-factor enrollment, accepting developer terms, and Business Verification document upload. Do not retry a blocked step in a loop. **If this session has no browser automation** — the usual case for a CLI or cloud run — do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (the redirect URIs from §4 and the permission list from §5), then continue once they report back with the App ID. ## 3. Create the app — and the two choices you cannot undo Start at `https://developers.facebook.com/apps/creation/`. The wizard asks for app details, then **use cases**, then a business portfolio, then shows the requirements for what you picked, then hands you the dashboard. **Use cases** shape the dashboard and pre-add permissions and products. They are one of the two permanent choices: adding the wrong one cannot be undone. Note also that a use case may pre-add permissions you do not need — prune them, because requesting a permission you do not use is a review rejection, not a harmless extra (§5). **App type** is the other. To choose it explicitly rather than have a use case choose for you, take the "Other" branch: **Other** → **Next** → select **Business** or **Consumer** → **Next** → connect a business portfolio (now or later) → **Create app**. The two types differ in more than name: | | **Business** | **Consumer** | | --- | --- | --- | | Intended for | Apps that "help businesses, creators, and organizations manage their presence and advertise" | Apps giving individual users a personalized experience | | App modes | **None** — access levels alone decide who can grant permissions | Development / Live modes apply | | Access levels | Yes; auto-approved for Standard Access on everything available to the type | Yes | Then **Add Product** in the left menu and **Set up** each product the connector needs. Products, permissions and features available to you are constrained by the app type you can no longer change. ## 4. Redirect URIs Where they are configured depends on the login product — the product skill names the exact panel. Add every URI and save. The rules below are the same everywhere and are the single most common source of "but I registered it": - **Matching is exact.** Meta's wording: "Make sure this exactly matches one of the base URIs in your list of valid oAuth URIs." No subdirectory matching, no trailing-slash forgiveness. - **The dashboard may silently append a trailing slash.** Meta documents this: "The App Dashboard may have added a trailing slash to your URIs, so we recommend that you verify by checking the list." Re-open the saved list and read what is actually stored, character by character, before concluding anything else is wrong. - **HTTPS only.** Multiple URIs are allowed; add them all. - **The `redirect_uri` sent on the token exchange must be identical to the one sent on authorize.** A mismatch there fails *after* the user has already consented, which presents as a token-endpoint bug rather than a config error. For this platform the callback list is one URI per data center. Confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` ## 5. Permissions and access levels Every Meta permission carries an **access level**, and this is the mechanism behind the complaint that shows up in every Meta integration post-mortem — *it works for us and for nobody else*. - **Standard Access**: "Permissions with Standard Access can only be requested from app users who have a role on the requesting app." Business, Consumer and Gaming apps "are automatically approved for Standard Access for all permissions and features available to their app type," so this is where you start, free, for everything. - **Advanced Access**: "Permissions with Advanced Access can be requested from any app user, and features with Advanced Access are active for all app users." This is what a multi-tenant connector needs. It "must be approved on an individual permission and feature basis through the App Review process," and "Business Verification is required to get Advanced Access." Two consequences people get wrong: - **Flipping a Consumer app to Live mode does not grant anything.** Live mode makes the app visible and lets it request permissions from non-role users; only permissions already approved through App Review will actually be granted. For Business apps there is no mode to flip at all (§3). - **Request only what the connector calls.** Meta: "The most common reason an app is rejected during Meta App Review is for requesting advanced access to a permission or feature the app doesn't need or doesn't use to function properly. Do not request permissions or features your app doesn't need or won't use." Each permission is reviewed individually against a screencast of it being used, so an unused request is a rejection, not a rounding error. The exact permission strings, and which of them your connector needs, belong to the product skill. ## 6. The four review regimes Independent tracks, different owners, different failure symptoms. Run them in parallel; clearing one clears none of the others. ### 6a. App Review → Advanced Access Path: **App Review → Permissions and Features** → **Request advanced access** per permission. Then **App Review → Requests** → **Edit** each item → **Complete App Settings** (app icon, category, privacy policy URL, data deletion information) → for each permission, the **How this app uses…** panel → **Submit for Review**. What the submission asks for: - Complete app settings, including **privacy policy URL, terms URL and data deletion instructions**. - Per permission: a description of how the app uses it, plus a **screencast showing the end-to-end user experience** for that specific permission. Screencast UI must be in **English**. - **Test credentials** and step-by-step instructions, for each platform the app runs on, so a reviewer can log in and exercise the integration. A reviewer who cannot reproduce the flow rejects it. - An agreement that you will comply with the allowed usage of each permission. **Order matters.** Build first, add your own account as an app role, exercise the integration for real, and only then submit. Some permissions additionally require a successful API call before Advanced Access can even be requested — which you can only make while restricted to app-role users. A cold app gets bounced. **Do not answer on the user's behalf**: compliance attestations, data-handling claims, volume or user-count projections, or anything in the form that makes a legal claim. Draft them, hand them over, and say so. ### 6b. Business Verification "A process that allows us to gather information about you and your Business so we can verify your identity as a business entity." Meta: "Apps that request advanced access for permissions and apps that allow other Businesses to access their own data must be connected to a Business that has completed Business Verification." Without it, "app users from other Businesses will be unable to grant these apps permissions and all features will be inactive." The stated exception — "If your app will only be used by app users who have a role on the app itself you do not need to complete verification" — is fine for a prototype and useless for a connector. It needs legal-entity documents and a business address or phone: **a human decision, not yours.** It is also the hard prerequisite for 6c. ### 6c. Access Verification → Tech Provider status The gate most Meta write-ups miss entirely, and the one whose failure looks like something else. "Tech providers are businesses that have a legitimate need to access business data owned by other businesses in order to provide services or functionality to those businesses." Any business that has created or claimed an app used by other businesses and needs any of ~30 listed permissions "must be verified as a Tech Provider before other businesses can use the app." Access verification "is independent of App Review and permission access levels." **How the check runs, which is why you will never see it in your own testing.** When a call arrives, Meta first asks whether the person who granted the permission **has a role on the app**. If yes, the call proceeds untouched. If no, Meta checks the claiming business's Tech Provider status, and if it is unverified returns: > Error code: `100` — `Unsupported get request. Object with ID does not exist, cannot be loaded due to > missing permissions, or does not support this operation.` An error that reads like a bad object ID. You have a role, so you are exempt; only customers hit it. **Mechanics.** Business admins are emailed when an app admin requests Advanced Access for any covered permission; the form is also at **App Dashboard → Basics → Verifications → Access verification**. A business admin must "categorize and describe how the business uses other businesses' data to provide a service for those businesses." Decision in **approximately 5 days**. Businesses that already claim such apps get **60 days** from the notification email before the checks begin applying. Prerequisites: Business Verification complete, and no restrictions on the business account. **It can lapse.** A verified Tech Provider becomes unverified if the business's verification status reverts, the app becomes disconnected from the business that created or claimed it, or the business account becomes restricted — and returns automatically once the condition is reversed. So a live, previously working app can lose access with no deploy and no notice. ### 6d. Ongoing Review and Data Use Checkup Approval is not permanent. Apps with Advanced Access "are required to undergo Ongoing Review to retain access," and "if we are unable to access your app for testing and you do not reply to the required action in the requested time frame, your app may be deactivated." On top of that, **Data Use Checkup** is an annual assessment of "whether a developer's continued use of and access to specific data via Meta APIs is in compliance"; developers who do not certify in the allotted window risk losing platform access. A **Data Protection Assessment** may also apply depending on the data the app holds. Put the Ongoing Review and Data Use Checkup dates on a calendar at handoff. Losing access this way is the most avoidable outage on this platform. ## 7. Capture the credentials For most Meta products the credentials are at **App Dashboard → App Settings → Basic**: - **App ID** → your OAuth `client_id` - **App Secret** → your OAuth `client_secret` Some product surfaces display a *second, separate* pair for their own login flow; where that is true the product skill says so, and handing over the wrong pair produces an error that reads like a redirect-URI problem. Record which product surface the app is built on, next to the credentials. Also record, because the run is not reproducible without them: the claiming business portfolio; the access level of each permission; the pinned API version and its expiry; and the status of each of the four review regimes. **`appsecret_proof` and "Require App Secret."** Meta supports signing calls with `appsecret_proof` — "a sha256 hash of your access token, using your app secret as the key" — to prove a call comes from your server rather than from a stolen token. Turning on **App Settings → Advanced → Security → Require App Secret** makes it mandatory; every call without the parameter then fails. **Do not enable it unless the client already computes the proof**, and verify which secret keys the HMAC for your product surface before flipping it. **Rotation.** Rotating the app secret invalidates the old one immediately: existing access tokens keep working until they expire, but every code exchange and every token extension fails until the new secret is deployed. Never rotate without explicit go-ahead and a cutover plan. **Token lifetimes and extension are product-specific.** There is no OAuth refresh token anywhere on this platform; what replaces it differs per surface. Read the product skill's token section and treat re-authorization as a scheduled operation. ## 8. API versions Pin a version explicitly in every path. Unversioned calls fall back to whatever version is set on the app dashboard's **Settings → Advanced → Upgrade API Version** card, so they drift under you; on some Meta APIs unversioned calls are rejected outright. The Graph API rule: "Each version is guaranteed to operate for at least two years. A version will no longer be usable two years after the date that the subsequent version is released" — dated from the *next* release, not from yours. See **Platform state** for the current table. What happens at expiry is the part that bites: "once a version is no longer usable, any calls made to it will be defaulted to the next oldest, usable version." Calls do not cleanly fail; they silently answer from a different version, so behaviour changes without an error. Calendar the upgrade rather than waiting for a symptom. **Individual Meta APIs override this with shorter clocks and different expiry behaviour.** Check the product skill before assuming two years. ## 9. Rate limits Meta returns throttling as an HTTP 400 with an error code in the body, not as a 429. A platform that keys retry logic on status codes alone will misdiagnose every one of these. **Per app — platform rate limits.** Read the `X-App-Usage` header: `call_count`, `total_cputime` and `total_time`, each a percentage of the allowance over a rolling hour. For apps the allowance is "200 * Number of Users" calls per hour, where that is unique daily active users — "not a per User limit but a limit on calls made by your app." **Per business use case — BUC limits**, which is where most product APIs actually live. Read `X-Business-Use-Case-Usage`: `call_count`, `total_cputime`, `total_time`, `business-id`, `type` (one of `ads_insights`, `ads_management`, `custom_audience`, `instagram`, `leadgen`, `messenger`, `pages`), `estimated_time_to_regain_access` in minutes, and `ads_api_access_tier`. Per-product allowances and any additional headers belong to the product skill. **When limited, stop.** Meta: "When the limit has been reached, stop making API calls. Continuing to make calls will continue to increase your call count, which will increase the time before calls will be successful again." ## 10. Verify end-to-end Authorizing with the account that owns the app proves almost nothing. That account has a role, so it bypasses both the Standard/Advanced Access check (§5) and the Tech Provider check (§6c) — the two things most likely to be wrong. 1. Run the full authorize → callback → code exchange round trip with your own account, to prove the redirect URIs and the exchange work at all. 2. Extend the token by whatever mechanism the product uses, confirm the connector **stored the new value**, and make a read call with the stored token. 3. Make one real read call against the product's host, with the pinned version. 4. Now repeat the whole thing as a user with **no role on the app**. Before Advanced Access this correctly fails; before Tech Provider verification it fails as `(#100)`. Those two failures are the proof that §6 is unfinished, and this is the only test that surfaces either. 5. Re-open the saved redirect URI list and compare it character for character with what the client sends. | Symptom | Cause | | --- | --- | | `(#1)` *An unknown error occurred* | Usually transient; retry with backoff before investigating | | `(#4)` *Application request limit reached* | App-level platform or load limit — check `X-App-Usage` (§9) | | `(#10)` *Application does not have permission for this action* | Permission not approved in App Review, or not requested at authorize time (§5, §6a) | | `(#17)` *User request limit reached* | Per-user rate limit (§9) | | `(#100)` *Invalid parameter* | A malformed request — field name, value or object ID | | `(#100)` *object does not exist, cannot be loaded due to missing permissions* — for customers only, never for you | Business not verified as a **Tech Provider**; the check is skipped for app-role users (§6c) | | `(#102)` *Session key invalid or no longer valid* | Stale session; the user must re-authorize | | `(#190)` *Invalid OAuth 2.0 Access Token* | Token expired, revoked, password changed, or never extended — read the subcode (§7) | | `(#200)` *Permission error* | The permission was never granted for this object, or the user lacks the required role on the asset — customer-side as often as app-side (§5) | | `(#80000)` / `(#80004)` / `(#80003)` / `(#80002)` / `(#80005)` / `(#80006)`, subcode `2446079` | Business-use-case rate limits — the product skill says which bucket is which (§9) | | `(#32)` / `(#80001)` | Page-level rate limits, by token type (§9) | | Only your own team can connect; strangers fail at consent | Permission is at Standard, not Advanced, Access (§5, §6a) | | Consent succeeds, token exchange fails | `redirect_uri` differs between authorize and exchange, or the code was reused or has expired (§4) | | Redirect mismatch after consent | URI not registered exactly, or the dashboard appended a trailing slash (§4) | | Every call fails a signature check | "Require App Secret" is on but the client does not send `appsecret_proof` (§7) | | Calls behave differently on a date nobody deployed | The pinned API version expired and calls defaulted to another version (§8) | | A previously working app loses access with no deploy | Business Verification reverted, the app was disconnected from its business, or the business was restricted (§6c) | ## 11. Hand off — never commit the secret - **Do not** write the App Secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the user, for their secret store or console. Report it once, say plainly that it is now in the transcript and can be rotated, then move on. - If a code change is needed (a redirect host, a permission, a re-pinned version, a token schedule), keep it secret-free and say what the human must set out of band. - Close with: app name and **App ID**; where the secret was delivered; app type and use cases; the claiming business portfolio; the exact permission strings and each one's access level; the status of all four review regimes; the pinned API version and its expiry; the Ongoing Review and Data Use Checkup dates; and anything left for the user to do. The product skill adds its own items to this list. ## Stop and ask Hand back to a human rather than guessing when: Business Verification or Access Verification asks for legal-entity documents, ownership, addresses, or a description of how the business uses other businesses' data; App Review asks for compliance attestations, data-handling claims or volume projections; the app type or a use case would have to change, which is impossible and means a new app and a re-authorization of every customer; someone proposes requesting a permission the connector does not call; a customer cannot connect for reasons on their side; the platform has no re-authorization story for tokens that expire; or the App Dashboard does not match the **Platform state** section above. ## Other Meta surfaces you might arrive with Everything in this file applies to all of them. What differs is the product's host, permission strings, login product, token ladder and version clock. | Surface | Skill | What it adds on top of this file | | --- | --- | --- | | **Marketing API** (Meta Ads / Facebook Ads) | `meta-ads-oauth-app` | Business app type, Facebook Login for Business configurations, `ads_read` / `ads_management` / `business_management`, the Limited→Full access tier, a 90-day version clock, per-ad-account and insights throttles | | **Instagram Platform** | `instagram-oauth-app` | The Instagram-Login vs Facebook-Login-for-Business product split, `instagram_business_*` permissions, `graph.instagram.com`, a *separate* Instagram App ID and Secret, and a three-stage token ladder | | **Facebook Pages** | `facebook-oauth-app` | `pages_*` permissions (several of them on the Access Verification list, §6c) and **Page access tokens**, derived from a user token rather than issued at authorize time | | **Messenger Platform** | none yet | Page-scoped messaging permissions, its own BUC rate-limit bucket (`messenger`, error `#80006`), and webhook subscriptions as a first-class part of setup | | **WhatsApp Business Platform** | none yet | `whatsapp_business_management` (also on the Access Verification list), per-WABA request budgets rather than per-app ones, and its own onboarding on top of Business Verification | | **Threads** | none yet | `threads_*` permissions (five of them on the Access Verification list) and an impression-based rate-limit formula | If you arrive with a surface that has no skill, this file plus that product's own docs is the whole job — and consider writing the product skill afterwards. ## References All verified to return HTTP 200 on 2026-09-20. Product-specific references live in the product skills. - Register as a developer — https://developers.facebook.com/docs/development/register - Create an app — https://developers.facebook.com/docs/development/create-an-app/ - Other app types (the explicit Business/Consumer path) — https://developers.facebook.com/docs/development/create-an-app/other-app-types - App types — https://developers.facebook.com/docs/development/create-an-app/app-dashboard/app-types - App creation wizard — https://developers.facebook.com/apps/creation/ - App roles — https://developers.facebook.com/docs/development/build-and-test/app-roles - App modes (Development vs Live) — https://developers.facebook.com/docs/development/build-and-test/app-modes - Standard vs Advanced Access — https://developers.facebook.com/docs/graph-api/overview/access-levels - App Review — https://developers.facebook.com/docs/app-review - Business Verification — https://developers.facebook.com/docs/development/release/business-verification - About Business Verification (Business Help Center) — https://www.facebook.com/business/help/1095661473946872 - Access Verification (the Tech Provider check, error 100, the 60-day window) — https://developers.facebook.com/docs/development/release/access-verification/ - Tech Providers — https://developers.facebook.com/docs/development/release/tech-providers/ - Ongoing Reviews — https://developers.facebook.com/docs/resp-plat-initiatives/individual-processes/ongoing-reviews - Data Use Checkup — https://developers.facebook.com/docs/development/maintaining-data-access/data-use-checkup/ - Data Protection Assessment — https://developers.facebook.com/docs/resp-plat-initiatives/data-protection-assessment - Access token types — https://developers.facebook.com/docs/facebook-login/guides/access-tokens - Securing requests / `appsecret_proof` — https://developers.facebook.com/docs/graph-api/securing-requests - Graph API versioning — https://developers.facebook.com/docs/graph-api/guides/versioning - Platform versioning — https://developers.facebook.com/docs/apps/versions - Graph API changelog (version release and expiry table) — https://developers.facebook.com/docs/graph-api/changelog - Graph API rate limiting (`X-App-Usage`, `X-Business-Use-Case-Usage`, 80000-series codes) — https://developers.facebook.com/docs/graph-api/overview/rate-limiting - Graph API error handling — https://developers.facebook.com/docs/graph-api/guides/error-handling - Meta error code reference — https://developers.facebook.com/docs/marketing-api/error-reference - Graph API Explorer — https://developers.facebook.com/tools/explorer/ - Access Token Debugger — https://developers.facebook.com/tools/debug/accesstoken/ ## microsoft-dynamics-oauth-app URL: https://docs.unified.to/skills/microsoft-dynamics-oauth-app/SKILL.md # Dynamics 365 / Dataverse (Microsoft Entra ID) OAuth2 App Registration Get a working OAuth2 client for Dynamics 365 — an Entra ID app registration whose tokens are accepted by a *specific customer's* Dataverse environment. Registering the app is the base skill's job. Two things make Dynamics different from every other Entra product, and both of them fail *after* the registration looks perfect. First, there is no single API host: the resource you request a token for is the customer's own environment URL, so the connector has to discover it per customer (§2). Second, for app-only access an Entra app registration is **not enough** — a customer admin must create an **application user** in the environment and give it a **security role**, or a perfectly valid token gets 401/403 forever (§3). Everything else here is the product-family map (§1), which decides whether any of this applies at all. ## Built on: `microsoft-entra-app-registration` **Read the base skill first.** It is the source of truth for the Entra mechanics this one assumes: - **Supported account types** — single tenant vs multitenant vs personal Microsoft accounts, and `AADSTS50194` when a single-tenant registration is called through `/common`. Dynamics is work/school only, so pick **Multiple Entra ID tenants**; personal-account audiences buy nothing here and cost permission ceiling. - **Redirect URIs** — the Web vs SPA vs public-client platform choice (only **Web** may present a client secret), exact case-sensitive matching, the count and length limits, and the Unified.to callback hosts. - **Delegated vs application permissions, and admin consent** — how the two modes differ, which permissions need an administrator, and how to send a customer admin an admin-consent URL rather than instructions. - **Client secrets and certificates** — the 24-month cap with no never-expires option, the **Value** shown exactly once, `AADSTS7000222` when it lapses, add-then-delete rotation, and certificates as the recommended alternative. - **`offline_access`, publisher verification, and the generic AADSTS symptom table** — a Dataverse scope carries no refresh token of its own; `offline_access` is what gets you one, and risk-based step-up consent can block ordinary users from consenting to an unverified multitenant app at all. The base also holds the inputs to collect, the run order, tenant ownership, the credential hand-off and the generic stop-and-ask conditions. Without it you are missing all of that — everything below assumes it and covers only what Dynamics adds. **Three places the base needs reading with a Dynamics correction:** 1. The base says Microsoft Graph **application** permissions require a **Privileged Role Administrator**. Dataverse and Business Central app permissions are on their *own* APIs, not Graph — a Cloud Application Administrator or Application Administrator can consent those. Do not tell a customer they need a Privileged Role Administrator for a Dataverse-only app; you will cost them a scheduling round trip for nothing. 2. The base's per-cloud table gives authority and **Graph** hosts. Dataverse has its own per-cloud hosts (§2) and the Graph host is irrelevant to it. 3. The base assumes the resource you request a token for is a fixed, known host. For Dataverse it is the customer's environment URL, which you do not know at registration time (§2). ## 1. Which Dynamics is this? The product family is not one API "Dynamics 365" is a brand over several unrelated APIs. Establish which one before anything else — the answer changes the host, the scope, the admin step and the throttling model. | Product | Data platform | API surface | Token resource | | --- | --- | --- | --- | | **Sales** | Dataverse | Dataverse Web API, `/api/data/v9.x` | The environment URL | | **Customer Service / Customer Engagement** | Dataverse | Dataverse Web API | The environment URL | | **Field Service, Project Operations** | Dataverse | Dataverse Web API | The environment URL | | **Contact Center** | Dataverse (Customer Service lineage) | Dataverse Web API | The environment URL | | **Customer Insights – Journeys** (was Marketing) | Dataverse | Dataverse Web API, `msdyncrm_*` tables | The environment URL | | **Customer Insights – Data** | Its own store | Its own REST API **plus a subscription key**; Microsoft now recommends reading the same tables through Dataverse instead | A different Entra API | | **Business Central** | Its own (BC) database | `api.businesscentral.dynamics.com/v2.0/{environment}/api/v2.0` | One fixed host (§5) | | **Finance & Operations** (Finance, Supply Chain, Commerce, HR) | Its own (AOS) | OData at `{environment}/data` | The environment root URL (§5) | | **Dynamics GP / SL** | On-premises SQL | Web services the customer hosts; no Entra OAuth | Not applicable (§5) | **This skill covers the Dataverse-backed products** — Sales, Customer Service / Customer Engagement, Contact Center, Customer Insights – Journeys and the rest of the first block. §5 covers what changes for Business Central, Finance & Operations, Customer Insights – Data and GP, which are genuinely different auth surfaces and not this file's subject. One consequence worth stating early: **all the Dataverse products in an environment share one Dataverse**. A token scoped to `https://{org}.crm{n}.dynamics.com` reaches Sales tables, Customer Service tables and Journeys tables alike, subject to the security role. There is no per-app scope separating them. If a customer expects "read-only access to Sales and nothing else", that separation comes from the **security role** (§3), not from OAuth. ## Extra inputs to collect On top of the base skill's batch: | Input | Notes | | --- | --- | | **Which Dynamics product** | Decides whether this file applies at all (§1) | | **Delegated (a user authorizes) or app-only (client credentials)?** | App-only adds a per-customer admin step that cannot be self-served (§3) | | **The customer's environment URL**, or agreement to discover it | Per customer, per environment — never a constant (§2) | | **If app-only: who creates the application user, and which security role?** | Needs a Power Platform / environment admin per customer (§3) | | **Which cloud** | Commercial, GCC, GCC High, DoD, China — different Dataverse *and* discovery hosts (§2) | | **Does the customer have more than one environment?** | Production/sandbox/UAT each have their own URL and their own application user | ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The Dataverse Web API is at `{environment-url}/api/data/v9.2/`.** Microsoft documents v9.0, v9.1 and v9.2 as having **identical behavior** with no breaking changes between them, and the guidance is to pin the version that was current when the code was written rather than follow the newest. v9.2 is current. Older `v8.x` is a separate, frozen surface with documented behavioral differences (FetchXML character encoding, duplicated table/column names). - **The scope is resource-scoped, and Microsoft's own wording is precise:** `"/user_impersonation"` for a public client, `"/.default"` for a confidential client. Both are documented on the same page; neither is a Graph scope, and neither works against a host other than that customer's environment. - **The delegated permission in the Entra portal is named "Access Dynamics 365 as organization users"**, under the **Dynamics CRM** API (older docs say "Dynamics 365", and older-still docs say "Dynamics CRM Online"). It is one permission; there is no read-only variant. Least privilege is expressed in the Dataverse security role, not here. - **Application-user management lives in the Power Platform admin center** — *Manage → Environments → (environment) → Settings → Users + permissions → Application users → + New app user*. Microsoft's own docs note this was moved out of the legacy web client and that some pages still describe the old path. - **Dataverse and Dynamics 365 Customer Engagement docs still carry pre-Entra portal wording.** The current OAuth page still refers to a "Keys area under API Access in the Settings for the app registration", and the server-to- server page still walks through `portal.azure.com`, "Required permissions" and ADAL's `AuthenticationContext` against `login.windows.net`. Those blades are gone. Match on meaning; use the base skill's portal path and the v2.0 endpoints. - **Outbound marketing was removed from Customer Insights – Journeys in May 2026.** The tables and their data were largely left in place and are still reachable through Dataverse, but they are no longer updated, outbound segments are unusable (the membership table was removed), and outbound-era events must be recreated. A connector reading those tables will see stale data, not an error. - **Dynamics GP support ends 2029-12-31** (moved out from a previously announced 2029-09-30), with security updates and patches available until **2031-04-30**. Microsoft is steering customers to Business Central. - **Finance & Operations user-based service protection API limits are off.** They were optional from 2023-03-31, disabled by default in version 10.0.35, and in **10.0.36 they are disabled on all environments and the option to enable them was removed**. Resource-based limits remain. The 6,000-requests-per-5-minutes figure people quote at F&O is the *Dataverse* number (§6). If the Power Platform admin center or the Web API does not behave like this, stop and report what you actually see. ## 2. The per-environment resource URL — there is no API host to hardcode This is the structural difference from every other Entra product. Microsoft Graph is always `graph.microsoft.com`. **Dataverse is a different host for every customer, and the host is part of the OAuth scope**, so you cannot even ask for a token until you know it. The URL is composed as `https://{environment-name}.{region}.{base}` — for example `https://contoso.crm.dynamics.com` for North America, `https://contoso.crm4.dynamics.com` for Europe. The region segment carries a digit that varies by datacenter, and the documented list is: | Region | Host segment | Region | Host segment | | --- | --- | --- | --- | | NAM | `crm.dynamics.com` | IND | `crm8.dynamics.com` | | SAM | `crm2.dynamics.com` | GCC | `crm9.dynamics.com` | | CAN | `crm3.dynamics.com` | GBR | `crm11.dynamics.com` | | EUR | `crm4.dynamics.com` | FRA | `crm12.dynamics.com` | | APJ | `crm5.dynamics.com` | ZAF | `crm14.dynamics.com` | | OCE | `crm6.dynamics.com` | UAE | `crm15.dynamics.com` | | JPN | `crm7.dynamics.com` | GER | `crm16.dynamics.com` | | CHE | `crm17.dynamics.com` | NOR | `crm19.dynamics.com` | | SGP | `crm20.dynamics.com` | KOR | `crm21.dynamics.com` | | SWE | `crm22.dynamics.com` | DEU | `crm.microsoftdynamics.de` | | GCC High | `crm.microsoftdynamics.us` | CHN | `crm.dynamics.cn` | The list is not contiguous, it grows, and a customer can be **migrated between datacenters**, which changes their host. Microsoft's discovery documentation says so outright: server and organization allocation changes as part of datacenter management and load balancing, which is why a discovery service exists at all. **So: hardcoding a region digit fails.** A connector whose fallback URL template embeds one region works for customers in that region and returns nothing, or fails DNS, for everyone else — and the failure looks like a credentials problem, not a routing problem. Take the host from the customer or from discovery, never from a default. ### Discovering it: the Global Discovery Service The Global Discovery Service (GDS) is a separate OData v4 endpoint that lists the environments the **signed-in user** can reach. Per cloud: | Cloud | GDS host | | --- | --- | | Commercial | `https://globaldisco.crm.dynamics.com` | | GCC | `https://globaldisco.crm9.dynamics.com` | | GCC High (USG) | `https://globaldisco.crm.microsoftdynamics.us` | | DoD | `https://globaldisco.crm.appsplatform.us` | | China | `https://globaldisco.crm.dynamics.cn` | ```http GET https://globaldisco.crm.dynamics.com/api/discovery/v2.0/Instances?$select=ApiUrl,FriendlyName,UrlName,State Authorization: Bearer ``` - **`ApiUrl` is the value to connect to**, and it is the one to store — note it comes back in the `{org}.api.crm{n}.dynamics.com` form (an `api.` label the browser URL does not have). Use `FriendlyName` for a picker so the user recognises the environment; the other properties are mostly for filtering. - **It returns an array, always.** Unlike the Web API, GDS will not retrieve a single instance by key — filtering by `Id` or `UniqueName` still yields a one-element array. Its string filters are also **case sensitive**, which the Web API's are not. - **GDS is a different endpoint from the Web API** with different behavior, and it needs its own token for its own resource host. - **It is documented as user-scoped.** Its stated limitations are all about the calling user: nothing is returned if the account is disabled, if an environment security group filters the user out, or if access comes via delegated administration, and a user with no environments gets an empty list — not an error. Treat an empty list as "ask the customer", not as "the customer has no Dynamics". - **For app-only there is no user to enumerate for.** Collect the environment URL from the customer's admin during onboarding and store it per connection. This is the normal shape of a Dynamics connector. The customer can read their own value from the Power Platform admin center environment page, or from Power Apps → Settings → **Developer resources**, which shows the Web API endpoint verbatim. ## 3. The application user — the headline trap **A valid Entra token is not access to Dataverse.** For app-only (client credentials) access, the customer's admin must create an **application user** in *each* environment and assign it at least one **security role**. Until they do, the token exchange succeeds, the JWT looks correct, and every API call returns 401 or 403. Nothing in Entra ID tells you this is missing. Dataverse authorizes against a `systemuser` record, not against an app registration. The application user is that record: an unlicensed user whose identity is your app's client ID. Mechanically, the customer's admin: 1. Opens **Power Platform admin center → Manage → Environments → (the environment) → Settings → Users + permissions → Application users**. 2. Selects **+ New app user**, then **+ Add an app**, and picks the Entra app registration. 3. Chooses a **Business Unit** and enters an **Email address**. 4. Assigns one or more **security roles** — and this is the step people skip, because the pane lets you save the user first and come back to roles. A role-less application user authenticates and can read nothing. Five things that bite in practice: - **It is per environment, per customer.** Production, sandbox and UAT each need their own. Your platform cannot self-serve any of it. Design onboarding around an out-of-band admin step and say so on the connect screen. - **Only one application user per Entra app per environment.** You cannot create a second one with a different role for the same client ID. - **Enterprise applications do not appear in the picker — only Entra *app registrations* do.** For a multitenant connector the customer's tenant has a service principal (an enterprise application), created when they consented, but not a registration. Microsoft's instruction is to **search for the multitenant application by name or client ID** rather than scrolling the list. This is the single most common reason an admin reports "your app isn't there". - **Application users bypass the environment's security group.** An environment locked down by security group does not need your app added to that group; the role is what governs it. Do not let anyone "fix" a 403 by adding the app to a security group. - **Deleting is restricted.** Only an *inactive* application user can be deleted, and records it owns must be reassigned first. Deactivate rather than delete when revoking. Delegated connections do **not** need an application user — the signed-in human already is a Dataverse user with roles. That asymmetry is worth putting in front of a customer who is choosing between the two modes. ## 4. Delegated `user_impersonation` vs app-only `.default`, and the intersection | | Delegated | App-only (client credentials) | | --- | --- | --- | | Scope requested | `https://{env}/user_impersonation` (plus `openid`, `offline_access`, …) | `https://{env}/.default` | | Entra permission | **Access Dynamics 365 as organization users** (delegated, Dynamics CRM API) | Application permission on the same API | | Who is the Dataverse user | The signed-in person | The **application user** (§3) | | Refresh token | Yes, if `offline_access` was requested | No — re-request with the secret | | Licence | The user must be licensed | The application user is unlicensed | | Per-customer admin step | None beyond consent | Application user **and** security role, per environment | **Effective access is the intersection of the OAuth grant and Dataverse security**, and the Dataverse half is the tighter one. Dataverse security is role-based and row-level: a security role carries privileges per table, each with an access level (User / Business Unit / Parent–Child Business Unit / Organization), and on top of that there is record ownership, business-unit hierarchy, teams, and record sharing. Consequences a connector owner should hear before they design around scopes: - `user_impersonation` grants "whatever this user can already do" — no more. A delegated connector for a sales rep sees that rep's accounts and their business unit's, not the organization's. **A list that comes back short or empty is usually a role, not a bug.** Ask which security roles the connecting user holds before debugging code. - There is no read-only OAuth scope. If a customer wants read-only, they give the application user a **read-only custom security role**. Say this explicitly in a security review; "we only request one scope" reads badly until you explain the role is where the least privilege lives. - Mixing does not help. As in the base skill, any scope in the token is honored, and `/.default` must not be mixed with individual scopes in one request. - The environment URL in the scope and the environment URL you call must match. A token minted for one environment is rejected by another, which is exactly what you want but produces a 401 that looks like an expired token. ## 5. The other family members — different APIs, not different scopes Covered here only so you can tell them apart and route correctly. Each deserves its own run. **Business Central.** Not Dataverse. The API is `https://api.businesscentral.dynamics.com/v2.0/{environment}/api/v2.0` — the environment is a **path segment**, not a hostname, so unlike Dataverse the resource host is a single constant: the scope is `https://api.businesscentral.dynamics.com/.default` for S2S. The Entra API is **Dynamics 365 Business Central**, with application permissions `API.ReadWrite.All` (APIs and web services) and `Automation.ReadWrite.All` (the `/api/microsoft/automation` route). Then comes BC's exact analogue of the application-user trap: the customer opens the **Microsoft Entra applications** page *inside Business Central*, creates a record with your client ID, sets **State = Enabled**, and assigns permission sets. Microsoft is explicit that applications **cannot** be assigned the `SUPER` permission set; `D365 AUTOMATION` and `EXTEN. MGT. - ADMIN` cover most automation objects. BC also has its own throttling story (§6). **Finance & Operations.** Not Dataverse either (though an environment can be Dataverse-linked). OData lives at `{environment-root}/data`, and the token resource is the environment root URL **with no trailing slash** — per environment again, typically an `*.operations.dynamics.com` or `*.cloud.dynamics.com` host from Lifecycle Services. The Entra API is **Microsoft Dynamics ERP** (`Microsoft.ERP`); Microsoft warns that searching for it by partial name makes it look unavailable, so search the full name. Delegated permissions to select include *Access Dynamics AX data*, *Access Dynamics AX Custom Service* and *Access Dynamics AX online as organization users*. And a third analogue of the application user: in the app, **System administration → Setup → Microsoft Entra applications**, where the client ID is bound to a **service account user ID** whose permissions the calls then run under. OData paging is server-driven with a maximum page size of 10,000, and cross-company reads need `?cross-company=true`. **Customer Insights – Data.** A separate REST API that needs an admin to **enable API access**, which mints primary and secondary **subscription keys** on top of the OAuth token. Its Entra API is *Dynamics 365 AI for Customer Insights* with a `user_impersonation` delegated permission. Microsoft's own recommendation is to read Customer Insights tables through the **Dataverse** APIs instead, for better filtering, throughput and latency — which usually means this becomes a Dataverse job after all. **Customer Insights – Journeys** *is* Dataverse (§1) and belongs to the main path; only its table family and the removed outbound-marketing module are special. **Dynamics GP (and SL).** On-premises. There is no Entra OAuth path: the customer hosts a web service endpoint and the connector authenticates to it directly with a username and password over HTTPS, scoped to a company id. It is a credentials-collection job, not an app-registration job, and it is on a support clock (Platform state). ## 6. Versioning, limits, and what is retiring **Versioning.** Pin `v9.2` in the Dataverse Web API path and change it deliberately. v9.0/v9.1/v9.2 behave identically today, but Microsoft reserves version numbers for breaking changes and says not to assume a newer version is backward compatible. Two hard request limits worth knowing: a URL may be up to **32 KB** (64 KB inside a `$batch` body), and any **single OData segment may not exceed 260 characters** — use parameter aliases for long function arguments rather than inlining them. **Service protection limits (Dataverse).** Throttled calls return **429** with a **`Retry-After`** header in seconds; honor it. The limits are evaluated **per authenticated user, per web server**, over a **five-minute (300 second) sliding window**, on three facets: | Measure | Default limit per web server | | --- | --- | | Number of requests | **6,000** within the 5-minute window | | Combined execution time | **20 minutes (1,200 seconds)** within the 5-minute window | | Concurrent requests | **52 or higher** | Four things that follow: - **Per user** is the important word. An app-only connector funnels *all* its traffic through one application user, so it concentrates everything against a single user's budget — the same shape Microsoft calls out for portals. A delegated connector spreads naturally across users. - **Most environments have several web servers** (a trial has one), and the number scales with licences, so observed headroom varies between customers and is not something to calibrate against. - **Batching does not buy you a free ride.** A `$batch` may hold up to 1,000 operations and dodges the request count, but the execution-time and concurrency facets exist precisely to catch that. Exceeding concurrency errors *immediately*; the other two take effect after the window. - Sustained demanding traffic **extends** the Retry-After duration. Back off to a steady rate rather than retrying hard; plug-ins and custom workflow activities triggered by your calls do not count as requests, but their computation time is added to the request that triggered them. **Business Central limits** are a different model: **429** on throttling with no documented `Retry-After` — the guidance is client-side backoff — plus a hard **10-minute request execution limit** that returns **504 Gateway Timeout**. Split long requests; prefer webhooks over polling; use `$filter`, `$expand` and deep inserts to cut call counts. **Finance & Operations limits**: user-based limits are disabled from version 10.0.36 (Platform state); resource-based limits still throttle on environment resource utilization. Do not quote the Dataverse 6,000/5-minutes figure at an F&O environment — it is not the same service, and the page people cite for it now says the user-based limits are off. **Retiring / retired**: outbound marketing in Customer Insights – Journeys (removed May 2026); Dynamics GP support (2029-12-31, security patches to 2031-04-30). ADAL is long dead — the Dataverse pages that still show `AuthenticationContext` and `login.windows.net` are stale, not a supported alternative. ## 7. Product fact — the connectors as of 2026-09-20 There are **several separate Dynamics connectors in this family**, not one. Dataverse-backed surfaces each have their own connector: **Sales**, **Customer Engagement**, **Contact Center** and **Customer Insights – Journeys**. Outside Dataverse there are separate connectors for **Business Central**, **Finance & Operations** and **Dynamics GP**. The pattern across them: - **All the OAuth ones are delegated, not app-only.** Every one declares the same authorization-code shape: a `prompt=select_account` authorize with a space-delimited scope list, a form-POST token exchange with client ID and secret, and a refresh call on the same parameters. None of them ships a client-credentials path, so **none of them needs the application-user step** — which also means none of them can run unattended. If a customer asks for app-only sync, that is new work, and §3 is the cost. - **The per-object scope sets are uniform and minimal**: `openid`, `email`, `offline_access` and a bare `user_impersonation`, for every object, read and write alike. There is a separate sign-in-only scope set of `openid`, `email`, `profile` and `User.Read`. Least privilege genuinely lives in the customer's security role (§4), and a security reviewer should be told that rather than left to infer it from a one-scope request. - **The bare `user_impersonation` is rewritten to a resource-scoped scope at authorize time** — prefixed with the customer's environment host for the Dataverse connectors, and replaced outright with the fixed Business Central resource plus `/.default` for that one. This is the §2 rule implemented, and it is why the environment URL must be collected before the authorize redirect, not after. - **The environment URL is collected from the customer as a connect-time input**, labelled as the environment URL (Finance & Operations asks for the bare host, Business Central asks instead for an environment *name*, matching the path-segment shape in §5). There is no Global Discovery Service call anywhere; discovery is manual. Accepted input is normalised, and at least one connector's fallback URL templates **hardcode a single region digit** — fine for that region, wrong for the rest (§2). Worth confirming before you assume a customer outside that region works. - **Authorization is sent to `/organizations`** at run time even where the stored metadata says `/common`, which is correct for a work/school-only product — `/common` would let a personal Microsoft account start a flow it can never finish. - **Dynamics GP is not OAuth at all**: it collects a web service URL, a company id, a username and a password, and authenticates with HTTP Basic against a customer-hosted endpoint (§5). - **Two dated documentation defects** in the connector metadata: one Dataverse-backed surface cites the *Finance & Operations* service-protection page for the Dataverse 6,000/5-minute figure (wrong page, and that page now says F&O user-based limits are off — §6), and one product's linked API documentation URL currently 404s. **Confirm all of this with the connector's owner before you register anything.** Scope sets, hosts and auth modes change; this is a snapshot, not a contract. ## 8. Verify the Dynamics-specific parts Run the base skill's end-to-end check first (authorize from a **different tenant**, confirm a `refresh_token` came back, decode the token's `scp`/`roles`). Then: 1. Confirm the token's audience is the **customer's environment URL**, not Graph and not your own environment. This is the fastest way to catch a scope that was built from a default host. 2. Call `GET {env}/api/data/v9.2/WhoAmI`. It is the cheapest possible proof and it returns the `UserId` the call is running as — for app-only that is the application user, which is how you confirm §3 actually happened. 3. Then read one business table you actually need. `WhoAmI` succeeding proves authentication; it does **not** prove the security role grants anything. 4. For app-only, confirm access is **denied before** the security role is assigned and **allowed after**. If it works before, someone assigned a broader role than agreed. 5. If the customer has more than one environment, test the second one. Nothing generalizes across environments. 6. Force a token refresh and repeat step 3 — Dataverse access tokens last about an hour. | Symptom | Cause | | --- | --- | | Token exchange succeeds, every call is 401/403 | App-only with no application user, or one with no security role (§3) | | The admin says your app "isn't in the list" when creating the app user | The picker shows app *registrations*; search the multitenant app by name or client ID (§3) | | Works in the customer's production, 401 in their sandbox | Application user and role are per environment (§3) | | `WhoAmI` works, business tables return 403 | The security role lacks that table's privilege, or the access level is too narrow (§4) | | Delegated calls return far fewer rows than the admin expects | Row-level security: ownership, business unit or access level, not a query bug (§4) | | DNS failure or 404 for one customer, fine for others | A hardcoded region digit in the host (§2) | | A customer that used to work stops resolving | Environment migrated between datacenters; rediscover the URL (§2) | | Discovery returns an empty array | Disabled account, environment security group filtering, or delegated-admin access — not "no Dynamics" (§2) | | `invalid_scope` / `AADSTS70011` on authorize | Bare `user_impersonation` sent unqualified, or `/.default` mixed with individual scopes (§4) | | Auth succeeds but no `refresh_token` | `offline_access` not requested (base skill) | | 429 under load, then longer and longer pauses | Service protection; honor `Retry-After` and back off to a steady rate (§6) | | 504 on a Business Central call | The 10-minute request execution limit; split the request (§6) | | Customer Insights – Journeys data looks frozen | Outbound-marketing tables, removed May 2026 and no longer updated (Platform state) | ## Hand off — the Dynamics additions The base skill's hand-off list applies. Add to the closing summary: **which Dynamics product** and therefore which API surface; whether the connection is **delegated or app-only**; for each customer environment, its **exact environment URL** and how it was obtained (admin-supplied or discovered); for app-only, **who created the application user, in which environment, with which security role, and when**; the **Web API version** pinned; and the cloud, if it is not commercial. ## Stop and ask On top of the base skill's conditions, hand back to a human when: - The product has not been identified. Registering an Entra app for "Dynamics 365" without knowing whether it is Dataverse, Business Central, Finance & Operations or GP produces a credential that fits nothing. - App-only is proposed and nobody has confirmed a customer admin will create the application user and assign a role, in every environment, at every customer. That is an onboarding commitment, not a config detail. - Someone proposes defaulting or guessing the environment URL, or hardcoding a region digit. - A customer asks for read-only and the plan is to express it in OAuth. It has to be a security role, and designing that role is the customer's decision (§4). - The security role being proposed is broad (System Administrator, or any role chosen because it "just works"). Over-privileging an application user is the finding a customer security review will lead with. - An environment security group is being changed to fix a 403 — it is the wrong lever (§3). - A customer is on a national cloud (GCC, GCC High, DoD, China): different Dataverse *and* discovery hosts, different authority, and publisher verification is unavailable. - Dynamics GP or SL is in scope, or an on-premises Dynamics 365 Customer Engagement deployment — neither is an Entra OAuth job, and GP is on an end-of-support clock. - A plan depends on outbound marketing in Customer Insights – Journeys. It was removed; the plan needs rewriting. ## References The base skill carries the shared Entra references. These are the Dynamics/Dataverse-specific ones; verified 2026-09-20, every link returned HTTP 200. - Use the Microsoft Dataverse Web API — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/overview - Use OAuth authentication with Microsoft Dataverse (`user_impersonation` vs `.default`) — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/authenticate-oauth - Authentication with Dataverse (overview) — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/authentication - Walkthrough: register an app with Microsoft Entra ID (Dataverse) — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/walkthrough-register-app-azure-active-directory - Discover user organizations (Global Discovery Service) — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/discovery-service - Compose HTTP requests and handle errors (Web API URL parts, URL/segment limits) — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/compose-http-requests-handle-errors - View developer resources (find the environment's Web API endpoint) — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/view-download-developer-resources - Dataverse Web API versions — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/web-api-versions - Datacenter regions (the `crm{n}` host table) — https://learn.microsoft.com/en-us/power-platform/admin/new-datacenter-regions - Environments overview — https://learn.microsoft.com/en-us/power-platform/admin/environments-overview - Manage application users in the Power Platform admin center — https://learn.microsoft.com/en-us/power-platform/admin/manage-application-users - Use multi-tenant server-to-server authentication — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/use-multi-tenant-server-server-authentication - Use single-tenant server-to-server authentication — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/use-single-tenant-server-server-authentication - Security roles and privileges — https://learn.microsoft.com/en-us/power-platform/admin/security-roles-privileges - Dataverse security concepts — https://learn.microsoft.com/en-us/power-platform/admin/wp-security-cds - Dataverse database security — https://learn.microsoft.com/en-us/power-platform/admin/database-security - Dataverse service protection API limits — https://learn.microsoft.com/en-us/power-apps/developer/data-platform/api-limits - Dynamics 365 table/entity reference — https://learn.microsoft.com/en-us/dynamics365/developer/reference/about-entity-reference - Business Central: API endpoints — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/endpoints-apis-for-dynamics - Business Central: using OAuth to authorize web services — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/webservices/authenticate-web-services-using-oauth - Business Central: service-to-service authentication — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/automation-apis-using-s2s-authentication - Business Central: working with API limits — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/dynamics-rate-limits - Finance & Operations: service endpoints overview (Entra app + service account) — https://learn.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/services-home-page - Finance & Operations: OData — https://learn.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/odata - Finance & Operations: service protection API limits — https://learn.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/service-protection-api-limits - Finance & Operations: priority-based throttling — https://learn.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/priority-based-throttling - Customer Insights – Data APIs — https://learn.microsoft.com/en-us/dynamics365/customer-insights/data/apis - Customer Insights – Journeys: outbound marketing removal — https://learn.microsoft.com/en-us/dynamics365/customer-insights/journeys/developer/marketing-developer-guide - Dynamics 365 Contact Center documentation — https://learn.microsoft.com/en-us/dynamics365/contact-center/ - Dynamics 365 Customer Service implementation overview — https://learn.microsoft.com/en-us/dynamics365/customer-service/implement/overview - Dynamics GP documentation (end-of-support dates) — https://learn.microsoft.com/en-us/dynamics-gp/ ## microsoft-entra-app-registration URL: https://docs.unified.to/skills/microsoft-entra-app-registration/SKILL.md # Microsoft Entra ID — shared app-registration mechanics Everything common to registering an OAuth2 client for **any** Microsoft Graph–backed product: the owning tenant, the app registration, supported account types, redirect URIs, Graph permissions and consent, the client secret, and publisher verification. The product skills — Microsoft Graph sign-in, SharePoint — assume this file and cover only what their product adds (a `Sites.Selected` grant, a dated scope set, a retirement deadline). **Read this first, then the product skill.** If you are registering a plain Entra app with no particular Microsoft product in play, this file is the whole job. Every Microsoft Graph connector — Outlook, OneDrive, SharePoint, Teams, Entra directory, Excel, To Do — registers exactly the same way; only the permission strings differ. Three decisions here are expensive to reverse, and none of them fail at save time: 1. **Supported account types.** One dropdown decides whether customer tenants can use the app at all, caps how many Graph permissions you may ever request, and changes the redirect-URI rules. Changing it later touches every customer. §3. 2. **Permissions and admin consent.** Which Graph permissions you pick decides whether an ordinary user can click through your connect flow or whether every customer needs a tenant administrator first — and for Graph *application* permissions, an Application Administrator is not enough. You find this out at the customer's consent screen, not in the portal. §5, §6. 3. **The client secret expires.** Microsoft caps secret lifetime at **24 months** with no never-expires option, and says secrets should not be used in production at all. Every Entra app you register is a recurring calendar event, not a one-off. §7. Everything else — the portal path, the redirect URIs, the endpoints — is mechanical. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name** (customer-visible), publisher/contact, logo | Shown on the consent screen in every customer tenant; changeable later | | **Which tenant owns the registration** | An existing work/school tenant, or a new one (§2). It cannot be moved afterward | | **Who will use the app** | Your org only / any Microsoft 365 org / also personal Microsoft accounts (§3) — the decisive input | | **Redirect URIs** | Every callback host your platform serves (§4) | | **Graph permission set** | The exact permission strings the connector calls — the product skill supplies these (§5) | | **Delegated (user authorizes) or application (app-only)?** | Different permission lists, different consent story (§5) | | **Is a tenant admin available for consent, and with which role?** | Blocking for admin-restricted permissions (§6) | | **Secret or certificate?** | Secrets expire in ≤24 months; certificates are Microsoft's recommendation (§7) | | **Which cloud** | Commercial, US Gov L4/L5 (DoD), or China (21Vianet) — different hosts (§7) | | **Publisher verification status** | CPP/Partner One ID + verified publisher domain, if you have them (§8) | | **New app or edit to an existing one?** | Editing is almost always the right answer (§1) | ## Quick Start 1. Confirm an **edit** is not enough — a new registration means a new client ID and mass re-authorization (§1). 2. Sign in to the Microsoft Entra admin center with the tenant that will own the app (§2). 3. **Entra ID → App registrations → New registration**, and choose **Supported account types** deliberately (§3). 4. **Authentication → Add Redirect URI → Web**, and add every callback host exactly (§4). 5. **API permissions → Add a permission → Microsoft Graph**; pick delegated or application, and note which need an admin (§5). 6. Get admin consent from an account that actually can grant it (§6). 7. **Certificates & secrets → New client secret**; copy the **Value** before you leave the page, and diary the expiry (§7). 8. Decide whether publisher verification is required for your customers to consent at all (§8). 9. Verify by authorizing from a **different tenant** than the one that owns the app (§9). 10. Hand the credentials over — never commit them (§10). ## Platform state (verified 2026-09-20 — re-verify before trusting) Microsoft renames this surface constantly. Check against the linked docs before following any older runbook. - **The portal is the Microsoft Entra admin center at `entra.microsoft.com`.** The host blocks non-browser requests, so it is deliberately not linked in References — reach it from the registration quickstart, which links it. The current registration doc gives the path as **Entra ID → App registrations → New registration**. Older docs and runbooks say *Identity → Applications → App registrations*, and some enterprise-app pages still use the *Identity →* form; both land in the same place. The Azure portal (`portal.azure.com`) still hosts the same blades. **Microsoft's own docs disagree about the label** — if your left-hand nav reads differently from both, stop and report what you see rather than guessing. - **"Azure AD" is gone as a name**, not as a product. Entra ID, Azure AD and "the Microsoft identity platform" are the same thing for this task. App registration objects, client IDs and secrets are unchanged. - **The supported-account-types labels were reworded.** The registration form now reads **Single tenant only - \**, **Multiple Entra ID tenants**, **Any Entra ID Tenant + Personal Microsoft accounts** and **Personal accounts only**. Docs elsewhere still use the older long forms ("Accounts in any organizational directory…"), and the underlying manifest `signInAudience` values (`AzureADMyOrg`, `AzureADMultipleOrgs`, `AzureADandPersonalMicrosoftAccount`, `PersonalMicrosoftAccount`) have not changed. Match on meaning, not wording. - **Client secret lifetime is capped at 24 months, with no non-expiring option**, and Microsoft recommends under 12. The secret **Value** is shown once and never displayed again. Microsoft's current guidance goes further: client secrets "should **not be used** in production environments" — use a certificate or a federated credential. - **New app registrations are hidden from users by default** (My Apps). That is a visibility setting under Enterprise apps, not a functional block on OAuth; don't chase it while debugging consent. - **Risk-based step-up consent (in effect since November 2020) blocks user consent to most multitenant apps that aren't publisher verified** — apps registered after 2020-11-08, requesting more than basic sign-in and profile read, in tenants other than the app's home tenant. For a multitenant connector this is a launch blocker, not polish. §8. - **Endpoints are the v2.0 ones**: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize` and `.../oauth2/v2.0/token`, where `{tenant}` is `common`, `organizations`, `consumers`, or a tenant ID/domain. If the Entra admin center or Graph does not behave like this, stop and report what you actually see. **This skill does not click through the portal.** If the session has no browser automation — the usual case for a CLI or cloud run — do not pretend to. Hand the user an exact ordered click path with the literal values to paste (the redirect URIs from §4, the permission strings from the product skill), and continue once they report back with the client ID and tenant ID. ## 1. Decide: reuse the existing registration, or create a new one A new registration means a **new Application (client) ID**, and every existing customer consent grant is bound to the old one. Every customer would have to re-authorize, and admin-consented tenants would need their admin again. **Edit the existing app** for: adding a redirect URI, adding or narrowing a Graph permission, rotating an expiring or compromised secret, adding a certificate, changing branding, or diagnosing a sign-in failure. All of these are safe, same-day changes. **Register a new app** only when the user explicitly wants one: a separate product, a replacement for a compromised app, or a move to a different owning tenant — **app registrations cannot be moved between tenants**, so that is always a new app. Say which path you are taking before you touch anything. One thing that looks like an edit but is not: **changing supported account types from single-tenant to multitenant** is a real change with real conditions (§3), and widening the audience to include personal Microsoft accounts can *invalidate* an existing permission set (§3). Treat it as a project. ## 2. The tenant that owns the registration The app object lives in exactly one Microsoft Entra tenant, forever, and is *used* by every customer tenant. - **An existing work or school tenant** is the normal home for a company's connector. You need at least the **Application Developer** role to create a registration; **Cloud Application Administrator** or **Application Administrator** to consent on behalf of the tenant (§6 for the exception that catches people). - **If you hold access to several tenants, switch tenants with the Settings icon before registering.** Registering in the wrong tenant is unrecoverable except by deleting and starting over. - **A new tenant** — create one via the Entra fundamentals quickstart if the user has no suitable tenant. The registration quickstart also lists an Azure account with an active subscription as a prerequisite; a free Entra tenant is sufficient for registering an app, so if the user is blocked on billing, say so rather than improvising. - **Not a personal Microsoft account.** An app registered with a personal Microsoft account **cannot be publisher verified** (§8). For a multitenant connector, register from a work or school tenant. - **A Microsoft 365 developer tenant** is the right place to *test*, but not necessarily the right place to own a production app. - **National clouds** (US Gov, China) are separate instances with different endpoint hosts (§7), and publisher verification is not supported there at all. If a customer is in one, that is a distinct app and a distinct task. Hand control back for anything only a human can do: tenant creation, email/phone verification, MFA enrollment, accepting terms, or granting themselves a directory role. Do not retry a blocked step in a loop. ## 3. Register the app — and choose Supported account types deliberately **Entra ID → App registrations → New registration.** Give it the customer-visible **Name** (changeable later), pick the account type, optionally set the first redirect URI under the **Web** platform, and press **Register**. The **Overview** page then shows the **Application (client) ID** and **Directory (tenant) ID** — record both. The account type is the one field on this form that is hard to walk back: | Option (current label) | Manifest `signInAudience` | What it means for a connector | | --- | --- | --- | | **Single tenant only - \** | `AzureADMyOrg` | Only users in *your* tenant can sign in. A customer's tenant cannot use the app at all. Correct for internal tools; wrong for a multi-customer connector. | | **Multiple Entra ID tenants** | `AzureADMultipleOrgs` | Any Microsoft 365 / Entra organization can consent and sign in. **This is the normal choice for a SaaS connector.** | | **Any Entra ID Tenant + Personal Microsoft accounts** | `AzureADandPersonalMicrosoftAccount` | Adds Outlook.com / Hotmail / Xbox / Live accounts. Needed only if consumer accounts are genuinely in scope — and it costs real capability (below). | | **Personal accounts only** | `PersonalMicrosoftAccount` | Consumer accounts only. Almost never right for a business connector. | Three consequences that are not obvious from the dropdown: - **The permission ceiling collapses when personal accounts are included.** An app whose audience is `AzureADMyOrg` or `AzureADMultipleOrgs` may request **400** permissions, 400 of which may be Microsoft Graph, with roughly 155 delegated or 300 application permissions consentable in a single request. An app that includes personal Microsoft accounts — `AzureADandPersonalMicrosoftAccount` or `PersonalMicrosoftAccount` — is capped at **30**, full stop. A broad Graph connector will hit 30. Additionally, **not every delegated Graph permission is valid for personal Microsoft accounts**; the Graph permissions reference marks which are. - **Redirect URI rules tighten.** Query parameters in redirect URIs are allowed only for work/school audiences and are *not* allowed once personal accounts are included; the redirect-URI ceiling drops from 256 to 100; and wildcard redirect URIs are unsupported for that audience. §4. - **Application permissions don't exist for consumer accounts.** App-only (application) permissions are supported for `AzureADMyOrg`, `AzureADMultipleOrgs` and `AzureADandPersonalMicrosoftAccount`, but not for `PersonalMicrosoftAccount`. **A multitenant app must authorize against `/common` or `/organizations`**, not your own tenant ID, and your token validation must handle `iss`/`tid` coming back as the *customer's* tenant. A single-tenant registration called through `/common` fails with **`AADSTS50194`**. **If you are converting an existing single-tenant app to multitenant** (Authentication → Supported account types → accounts in any organizational directory): the app's **Application ID URI must be globally unique** across all tenants — e.g. `https://.onmicrosoft.com/` — or the change fails. While you are in the manifest, consider setting an **app instance property lock**, which stops administrators in customer tenants from adding credentials to your app's service principal there. ## 4. Redirect URIs **Authentication → Add a platform → Web.** The platform type is not cosmetic: | Platform | Client type | Consequence | | --- | --- | --- | | **Web** | Confidential | The only kind that may present a `client_secret` at the token endpoint. **This one**, for a hosted connector. | | Single-page application (`spa`) | Public | Cannot carry a client secret; forces PKCE; client-credentials calls are refused. Surfaces later as a cross-origin / `invalid_request` failure, not at save time. | | Mobile and desktop | Public | Loopback and custom-scheme redirects for native apps; no secret. | Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` The rules Entra actually enforces — all of them failing at connect time, not at save time: - **Exact match, and case-sensitive on the path.** `/oauth/Code` is not `/oauth/code`. A mismatch returns **`AADSTS50011: InvalidReplyTo — The reply address is missing, misconfigured, or doesn't match reply addresses configured for the app`**. - A URI with **no path segment** comes back with a trailing slash (`https://contoso.com` → `https://contoso.com/`); one with a path does not. - **`https` only**, except `http://localhost` (and `127.0.0.1`, which the portal text box refuses for `http` — it has to go in via the manifest). Prefer `127.0.0.1` over `localhost` for local testing. - **Maximum 256 redirect URIs** for work/school audiences, **100** once personal Microsoft accounts are included; **256 characters** per URI. The limits cannot be raised. - **No wildcards** for audiences that include personal accounts, and Microsoft's own guidance is not to use them anywhere — a wildcard match strips query strings and fragments. Use the `state` parameter to carry per-tenant or per-subdomain routing instead of multiplying URIs. - **No `! $ ' ( ) , ;`**, no internationalized domain names. - **Add redirect URIs to the application object only**, never to a service principal — service-principal values get wiped on sync. - Trim URIs you no longer own. A lapsed DNS record on a registered redirect URI is an app compromise. ## 5. Permissions: delegated vs application **API permissions → Add a permission → Microsoft Graph**, then choose the permission kind. The difference is not cosmetic: | | Delegated permissions | Application permissions | | --- | --- | --- | | Acts as | The signed-in user | The app itself, no user present | | Ceiling | Never more than the user can already do | Everything the permission covers, tenant-wide | | Who can consent | The user, *or* an admin for everyone | **Admin only, always** — and see §6 for which admin | | Consent style | Static (registered list) or dynamic (per request) | Static only; requested as `.default` | | Use for | A customer-authorizes-us connector | Daemons and background sync with no user | Prefer **delegated**. Microsoft's own guidance is to use delegated access whenever it meets the requirement, and customer security reviewers treat application permissions as a much bigger ask. **Scope strings.** Graph permissions are named `{resource}.{operation}.{constraint}` — `User.Read`, `Mail.ReadBasic`, `Calendars.ReadWrite`, `Files.Read.All`, `Directory.Read.All`, `Sites.Read.All`. In the authorize request they are space-delimited, and may be bare or resource-qualified: `scope=User.Read` is equivalent to `scope=https://graph.microsoft.com/User.Read`. App-only calls use the literal **`https://graph.microsoft.com/.default`** (adjusted to the cloud's Graph host) rather than individual names, and **mixing `/.default` with individual scopes in one request is an error**. **A trap worth stating plainly:** any scope present in the token is honored. Requesting a narrow permission *alongside* a broad one does not produce a least-privilege app — it produces a broad app with a least-privilege label, and a customer security review will find it. Narrow means the broad scopes come out of the request. **The four OIDC scopes**, which are not Graph data permissions: | Scope | Effect | | --- | --- | | `openid` | Required for sign-in; yields the `sub` claim and the ID token ("Sign you in" on the consent screen) | | `profile` | Name, preferred username, object ID | | `email` | The `email` claim — **may be absent** even when requested, if the account has no email address | | `offline_access` | **Must be requested explicitly to receive a refresh token** ("Maintain access to data you have given it access to") | `address` and `phone` are not supported at all. ### `offline_access` and refresh tokens `offline_access` is the single most common cause of "the connection dies after an hour". The v2.0 token response returns `refresh_token` *only if* `offline_access` was in the authorize request. Access tokens last about an hour; refresh tokens are typically valid for 90 days. Microsoft's own scopes page comes close to contradicting itself here, so read it carefully rather than quoting it: it says that "if any delegated permission is granted, `offline_access` is implicitly granted" and that you may assume the app has it — and then, a few lines later, that on the Microsoft identity platform "your app must **explicitly request** the `offline_access` scope to receive refresh tokens." It also notes that the permission appears on *all* consent pages, including flows such as the **implicit** grant that never return a refresh token at all, to support a client that starts in the implicit flow and moves to the code flow. The operative rule is the strict one: **request `offline_access` explicitly and confirm a `refresh_token` actually came back** (§9). Do not infer it from a granted delegated permission or from what the consent screen displayed. ## 6. Admin consent Two independent questions decide whether this step succeeds: whether an admin is needed, and whether the admin in the room is the right kind. **Whether an admin is needed.** Two reasons, and neither is fixed by re-registering: 1. **The permission is admin-restricted.** Every application permission, and many higher-privilege delegated ones (`User.Read.All`, `Group.Read.All`, `Directory.ReadWrite.All`, …), require an administrator. The Graph permissions reference marks these. An ordinary user who tries gets an error, not a prompt — **`AADSTS90094: AdminConsentRequired`**. That is the expected behavior, not a bug in your authorize URL. 2. **The customer's tenant disabled user consent**, or restricted it to verified publishers and low-impact permissions (**Entra ID → Enterprise apps → Consent and permissions → User consent settings**, policies `microsoft-user-default-legacy` vs `microsoft-user-default-low`). Nothing about your permission set changes this; it is their policy. Tenants can enable an **admin consent request workflow** so users can ask. **Which admin can grant it.** This is the trap that stalls runs: | Role | Can grant | | --- | --- | | **Privileged Role Administrator** | Any permission for any API — **including Microsoft Graph application permissions** | | Cloud Application Administrator / Application Administrator | Any permission for any API **except Microsoft Graph app roles (application permissions)** | | Application Developer | Register apps — **not** grant consent | So an app-only Graph connector cannot be consented by an Application Administrator. That restriction is **specific to Microsoft Graph application permissions**. Application permissions on other Microsoft APIs — Dataverse, Business Central — are consentable by a Cloud Application Administrator, so telling a customer they need a Privileged Role Administrator for those costs a scheduling round trip they did not need. Say so up front; it saves a scheduling round trip with the customer. **How to grant it.** In your own tenant: App registrations → your app → **API permissions** → **Grant admin consent for ``**, then confirm every permission shows **Granted for ``**. In a customer tenant, give the admin a URL rather than instructions — either form works: ``` https://login.microsoftonline.com/{organization}/adminconsent?client_id={client-id} ``` ``` https://login.microsoftonline.com/{tenant}/v2.0/adminconsent ?client_id={client-id} &scope=https://graph.microsoft.com/Calendars.Read https://graph.microsoft.com/Mail.Send &redirect_uri={a redirect URI registered on the app} &state={your state} ``` `{tenant}` / `{organization}` must be a tenant ID, a verified domain, or the literal `organizations` — **never `common`**, because a personal account cannot grant admin consent. The `redirect_uri` must already be registered (§4). Success comes back as `admin_consent=True&tenant={guid}&scope=…`; never authenticate or authorize off that returned `tenant` value. Four more things worth knowing before you pick permissions: - Consenting creates a **service principal** for your app in the customer's tenant and pre-consents every user; after that, no user in that tenant sees a consent prompt. Until that service principal exists, nothing customer-side can reference your app at all. - An admin who signs in **without** `prompt=consent` consents only for their own account — other users still cannot sign in. If your flow depends on an admin unlocking the tenant, send `prompt=consent` or the admin-consent URL. - Granting tenant-wide admin consent **may revoke permissions already granted tenant-wide** for that app. Re-consent after a permission change is not purely additive, so do not re-consent a live production app casually. - A tenant admin can disable user consent entirely. In such a tenant, admin consent is required even for permissions that would normally be user-consentable. ## 7. Client secrets, certificates, and capturing the credentials **Certificates & secrets → Client secrets → New client secret.** Add a description, choose an expiry, **Add**, then copy the **Value** column immediately. **The expiry is the part people get wrong.** Entra caps a client secret at **24 months** and offers no never-expires option; Microsoft recommends under 12 months. When it lapses, every token exchange and refresh fails with **`AADSTS7000222: InvalidClientSecretExpiredKeysProvided`** — for every customer at once, with no warning in the product. So: - Put the exact expiry date in a shared calendar and in the credential-store entry, the day you create the secret. - The **Value** is shown once and **never displayed again**. Copy the *Value*, not the *Secret ID* — the Secret ID is not the secret and is useless for authentication, and mistaking one for the other produces **`AADSTS7000215: Invalid client secret is provided`**. - **Rotate by adding a second secret before the first expires** — an app can hold more than one — deploying the new value, confirming every environment exchanges and refreshes, then deleting the old. Never rotate by deleting first: issued access tokens live out their lifetime, but every exchange and refresh fails until the new secret is live. - **Microsoft's position is that client secrets should not be used in production.** Certificates are the supported alternative and the recommendation: upload a `.cer`/`.pem`/`.crt`, record the thumbprint, and exchange a signed `client_assertion` in place of `client_secret`, with `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. Certificates expire too — on *your* schedule, and rotatable without a portal visit. Federated credentials remove the secret entirely for workloads on GitHub Actions, Kubernetes or Azure. If the platform only supports a secret, note that as a known gap rather than arguing. Capture: - **Application (client) ID** — the OAuth `client_id`, on the Overview page, readable any time - **Directory (tenant) ID** — the owning tenant; needed for tenant-scoped authority URLs and for admin consent - **Client secret Value**, delivered to the user for their secret store, with its **exact expiry date** - The exact permission strings, and whether they are delegated or application - **The authority and Graph host for the target cloud:** | Cloud | Authority | Graph host | | --- | --- | --- | | Commercial (global) | `https://login.microsoftonline.com` | `https://graph.microsoft.com` | | US Government L4 | `https://login.microsoftonline.us` | `https://graph.microsoft.us` | | US Government L5 (DoD) | `https://login.microsoftonline.us` | `https://dod-graph.microsoft.us` | | China (21Vianet) | `https://login.chinacloudapi.cn` | `https://microsoftgraph.chinacloudapi.cn` | Endpoints are `/{tenant}/oauth2/v2.0/authorize` and `/{tenant}/oauth2/v2.0/token`, with `{tenant}` = `common` (org + personal), `organizations` (org only), `consumers`, or a tenant ID/domain. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 8. Publisher verification, and whether customers can consent at all For a **multitenant** app this is not branding — it can be the difference between a connect button that works and one that dead-ends. Since November 2020, **risk-based step-up consent blocks ordinary users from consenting to most multitenant apps that are not publisher verified**: apps registered after 2020-11-08, requesting anything beyond basic sign-in and profile read, in a tenant other than the app's home tenant. The user sees a warning that the app was created by an unverified publisher. Separately, tenants that apply the `microsoft-user-default-low` policy allow user consent *only* for verified-publisher apps. Both push every customer into the admin-consent path (§6). Verification is free and fast once the prerequisites exist — the prerequisites are the slow part: - A verified **Microsoft AI Cloud Partner Program** (CPP, formerly MPN) account with a **Partner One ID**, which must be the organization's **partner global account** (a location Partner One ID will not work). - The app must be **registered in a work or school tenant** — apps registered with a personal Microsoft account can never be verified — and the tenant must be associated with the partner global account. - The app must have a **publisher domain** set, and it **cannot be `*.onmicrosoft.com`**. The email domain used for CPP verification must match it, or be a DNS-verified custom domain on the tenant. - The person doing it needs **Application Administrator or Cloud Application Administrator** in Entra *and* CPP Partner Admin or Account Admin in Partner Center, and must sign in with MFA. - Not supported in national clouds. Whether to join the partner program, and under which legal entity, is a business decision. Surface the requirement and the blockers; do not sign anything up. ## 9. Verify end-to-end Authorizing in the tenant that owns the app proves very little — that tenant already trusts it. Test the path a customer takes: 1. Authorize from a **different tenant** through your platform's real connect flow, with a **non-admin** user if customers will use one. That is the run that reveals admin-consent requirements. 2. Confirm the token response carries a `refresh_token` — if it does not, `offline_access` was not requested (§5). 3. Force a refresh, then make one real Graph call with the refreshed token (`GET https://graph.microsoft.com/v1.0/me` is the cheapest). 4. Decode the access token's `scp` (delegated) or `roles` (application) claim and check it against what you intended to request. This is the fastest way to catch a permission that was declared in the portal but never sent, or consented but never granted. 5. Repeat with an admin user, after admin consent, and confirm ordinary users then connect without a prompt. 6. If personal Microsoft accounts are in scope, test one — permission validity and redirect rules differ (§3). | Symptom | Cause | | --- | --- | | `AADSTS50011` reply address mismatch | Redirect URI not registered, or case/trailing-slash mismatch (§4) | | `AADSTS50194` — app "isn't configured as a multitenant application", `/common` unsupported | Single-tenant registration being called through `/common` (§3) | | `AADSTS700016` — application not found in the directory/tenant | Wrong client ID, or no service principal in that tenant yet — not consented/installed (§3, §6) | | `AADSTS500011` — resource principal not found in tenant | The resource isn't provisioned in the customer tenant; needs admin consent, or the service isn't licensed there | | `AADSTS65001` — user or administrator hasn't consented | No consent grant yet; send an interactive authorize, or the admin-consent URL (§6) | | `AADSTS90094` — administrator consent is required | An admin-restricted permission, or the tenant disabled user consent (§6) | | Admin clicks "Grant admin consent" and it fails | Application Administrator cannot consent Graph **application** permissions — needs Privileged Role Administrator (§6) | | `AADSTS65004` — user declined consent | The consent screen scared them; check whether you're over-requesting or unverified (§5, §8) | | `AADSTS7000215` — invalid client secret provided | Wrong secret, or the Secret **ID** was used instead of the **Value** (§7) | | `AADSTS7000222` — provided client secret keys are expired | The 24-month cap arrived, and every customer broke on the same day (§7) | | `AADSTS70011` / `invalid_scope` | A permission string that doesn't exist, isn't valid for that account type, or `/.default` mixed with individual scopes (§5) | | `AADSTS50020` — user account from identity provider doesn't exist in tenant | Guest/B2B user signing into the wrong tenant | | Auth succeeds, no `refresh_token` in the response | `offline_access` not requested (§5) | | Consent screen warns "unverified publisher", users can't proceed | Risk-based step-up consent on a non-verified multitenant app (§8) | | Token request rejected for cross-origin / client credentials in browser | Redirect URI registered as `spa` instead of `Web` (§4) | Error bodies carry `error_codes`, `trace_id`, `correlation_id` and a timestamp. Give the customer's admin the correlation ID — it is what Microsoft support and their own sign-in logs key on. ## 10. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the user, for their secret store or console. - The client ID and tenant ID are not secrets, but treat them as configuration, not constants in code. - If a code change is needed (a callback host, a scope, `offline_access`, a tenant-scoped authority), keep it secret-free and say what the human must set out of band. - Close with: app name and Application (client) ID; owning tenant (Directory ID), its domain and which cloud; supported account types as chosen; every redirect URI registered and under which platform; the exact permission strings, whether they are delegated or application, and which require admin consent; whether admin consent was granted and by which role; where the secret was delivered and **its exact expiry date**; the authorize and token endpoints with the `{tenant}` value in use; publisher verification status; and anything left for the user or the customer's admin to do. ## Stop and ask Hand back to a human rather than guessing when: - The account type would have to change on a live app, or the app would have to move tenants (both re-authorize every customer). - A required Graph permission is admin-restricted and no tenant admin is available for testing — report it; do not substitute a lower permission and hope. - Consent is blocked because the available admin is not a Privileged Role Administrator. - An application (app-only) permission is being proposed where delegated would do; that is a security decision the customer's reviewers will weigh. - Publisher verification is blocking consent and the organization is not in the partner program — that is a business and legal commitment. - The user asks you to rotate or delete a secret on a live app without a cutover plan. - Anyone proposes a wildcard redirect URI, `http` outside localhost, or a permission requested "just in case". - Privacy/terms URLs or a security questionnaire requires attesting to something you cannot verify. - A national cloud, an Entra External ID / B2C tenant, or SAML is involved — different endpoints, different rules, a different task. - The portal does not match the **Platform state** section above. **Not every Microsoft API has a fixed resource host.** Graph is always `https://graph.microsoft.com`, but Dataverse scopes embed the customer's own environment host and Business Central has its own resource — so for those products the scope string is discovered per customer rather than hardcoded. The product skill says which applies. ## References Verified 2026-09-20; every link returned HTTP 200. - Register an app in Microsoft Entra ID — https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app - Create a new Microsoft Entra tenant — https://learn.microsoft.com/en-us/entra/fundamentals/create-new-tenant - Single-tenant and multitenant apps — https://learn.microsoft.com/en-us/entra/identity-platform/single-and-multi-tenant-apps - Convert a single-tenant app to multitenant — https://learn.microsoft.com/en-us/entra/identity-platform/howto-convert-app-to-be-multi-tenant - Application and service principal objects — https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals - App types and authentication flows — https://learn.microsoft.com/en-us/entra/identity-platform/v2-app-types - Add a redirect URI — https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-redirect-uri - Redirect URI restrictions and limitations — https://learn.microsoft.com/en-us/entra/identity-platform/reply-url - Permissions and consent overview — https://learn.microsoft.com/en-us/entra/identity-platform/permissions-consent-overview - Scopes and permissions (`openid`, `offline_access`, `.default`) — https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc - Microsoft Graph permissions overview (naming, per-audience limits) — https://learn.microsoft.com/en-us/graph/permissions-overview - Microsoft Graph permissions reference — https://learn.microsoft.com/en-us/graph/permissions-reference - Microsoft Graph auth concepts — https://learn.microsoft.com/en-us/graph/auth/auth-concepts - Get access without a signed-in user (app-only / `.default`) — https://learn.microsoft.com/en-us/graph/auth-v2-service - OAuth 2.0 authorization code flow (v2.0 endpoints) — https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow - OAuth 2.0 client credentials flow — https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow - Protocols and endpoints — https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols - Admin consent protocol — https://learn.microsoft.com/en-us/entra/identity-platform/v2-admin-consent - Grant tenant-wide admin consent — https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent - Configure how users consent to applications — https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent - Admin consent request workflow — https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-admin-consent-workflow - Review/revoke permissions granted to enterprise apps — https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/manage-application-permissions - Risk-based step-up consent — https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-risk-based-step-up-consent - Microsoft Entra role permissions reference — https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference - Add and manage app credentials (secrets, certificates, federated) — https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials - Certificate credentials — https://learn.microsoft.com/en-us/entra/identity-platform/certificate-credentials - Security best practices for app registration — https://learn.microsoft.com/en-us/entra/identity-platform/security-best-practices-for-app-registration - Publisher verification overview — https://learn.microsoft.com/en-us/entra/identity-platform/publisher-verification-overview - Mark an app as publisher verified — https://learn.microsoft.com/en-us/entra/identity-platform/mark-app-as-publisher-verified - Configure an app's publisher domain — https://learn.microsoft.com/en-us/entra/identity-platform/howto-configure-publisher-domain - National cloud endpoints — https://learn.microsoft.com/en-us/entra/identity-platform/authentication-national-cloud - AADSTS error code reference — https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes ## microsoft-entra-directory-oauth-app URL: https://docs.unified.to/skills/microsoft-entra-directory-oauth-app/SKILL.md # Entra ID directory data (users, groups, org) through Microsoft Graph Getting an Entra app registered is the base skill's job and it is mechanical. **Choosing the directory permissions is the whole negotiation**, and that is what this file covers. Reading a customer's user and group directory is one of the most heavily gated things Microsoft Graph exposes: almost every permission that reaches beyond the signed-in user is admin-restricted, the broadest one in the family is the one security teams refuse by name, and the shape of what comes back changes depending on who authorized and whether the tenant syncs from on-premises Active Directory. ## Built on: `microsoft-entra-app-registration` **Read the base skill first.** It is the source of truth for the Entra mechanics this file assumes: - **Supported account types** — single tenant vs multitenant vs personal Microsoft accounts, the permission ceilings each imposes, and `AADSTS50194` when a single-tenant registration is called through `/common`. Directory reads are work/school only, so **Multiple Entra ID tenants** is the answer here. - **Redirect URIs** — Web vs SPA vs public client, exact case-sensitive matching, the count and length limits, and the Unified.to callback hosts. - **Delegated vs application permissions and admin consent** — how the two modes differ, how to send an admin-consent URL, and that Microsoft Graph *application* permissions need a **Privileged Role Administrator**, not an Application Administrator. Directory connectors stall on that more than on anything else. - **Client secrets and certificates** — the 24-month cap, the **Value** shown exactly once, `AADSTS7000222` on expiry, add-then-delete rotation, and the per-cloud authority and Graph hosts. - **`offline_access`, publisher verification, the v2.0 endpoints and the generic AADSTS symptom table** — no `User.*` or `Directory.*` scope carries a refresh token of its own; `offline_access` is what gets you one. The base also holds the inputs to collect, the run order, tenant ownership, the credential hand-off and the generic stop-and-ask list. **If this file is all you have loaded, you are missing all of it** — read the base before you touch the portal, then come back here for the permissions. Everything below is directory-specific. ## Extra inputs to collect On top of the base skill's batch: | Input | Notes | | --- | --- | | **Read-only, or write back to the directory?** | Write means `*.ReadWrite.*`, which roughly doubles the review difficulty (§1) | | **Which objects: users only, or users + groups + memberships?** | Groups cost a second permission family, and `Group.Read.All` reaches further than it sounds (§1, §2) | | **Are managers / reporting lines in scope?** | Own permission, and app-only has a documented gap (§7) | | **Delegated (a user authorizes) or application (app-only sync)?** | Decides who consents and what a guest authorizer can see (§3, §4) | | **Full sync every run, or incremental?** | Decides whether you build on delta and store tokens (§6) | | **Does the customer sync from on-premises Active Directory?** | Changes which attributes exist and which are writable (§8) | | **Is a Privileged Role Administrator available in each customer tenant?** | Blocking for app-only directory sync (§3) | ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Microsoft's own permissions reference now carries a standing caution on the `Directory.*` family**: directory permissions "grant broad access to directory (Microsoft Entra ID) resources such as user, group, and device", you should "choose permissions specific to these resources and avoid using directory permissions", and **"Directory permissions might be deprecated in the future."** Treat `Directory.Read.All` as a legacy convenience with an expiry risk attached, not as the default. - **Granular user permissions now exist** and are marked least-privileged on the user APIs: `User-Mail.ReadWrite.All`, `User-Phone.ReadWrite.All`, `User-PasswordProfile.ReadWrite.All`, `User.EnableDisableAccount.All`, `User.ReadUpdate.All` and `User-LifeCycleInfo.Read.All`. If a connector needs one narrow write, these are how you avoid asking for `User.ReadWrite.All`. - **`GET /groups` currently lists `Group-NestingSupport.ReadWrite.All` as its "least privileged" permission**, which reads like a documentation artifact rather than advice — it is a *write* permission. The permissions actually worth requesting to list groups are `Group.ReadBasic.All`, `GroupMember.Read.All` and `Group.Read.All` (§1). - **Legacy directory-role residue from before December 2020**: granting `Directory.Read.All` / `Directory.ReadWrite.All` as an *application* permission used to also assign the Directory Readers / Directory Writers directory role to the app's service principal, and **that role is not removed when consent is revoked**. Microsoft disabled the behavior between 2020-12-03 and 2021-01-11. In a long-lived customer tenant, revoking consent may not fully revoke access — the admin has to remove the directory role too. - **Delta (change tracking) is not supported in Microsoft Entra External ID external tenants or Azure AD B2C tenants.** If a customer is on one of those, incremental sync is off the table (§6). - **`$count` and `$search` are not available in Azure AD B2C tenants** either, which removes most advanced queries. If Graph or the Entra admin center does not behave like this, stop and report what you actually see. ## 1. The permission families All of these are Microsoft Graph permissions, requested as space-delimited scopes in the authorize request (delegated) or granted as app roles and requested as `.default` (application). Admin-consent status is from the Graph permissions reference, verified 2026-09-20. | Permission | Delegated | Application | Admin consent | | --- | --- | --- | --- | | `User.Read` | Yes | — | **No** — the signed-in user's own profile only | | `User.ReadBasic.All` | Yes | Yes | **Delegated: no. Application: yes.** The only `.All` here a user can self-consent | | `User.Read.All` | Yes | Yes | Yes | | `User.ReadWrite.All` | Yes | Yes | Yes | | `Group.ReadBasic.All` | Yes | Yes | Yes | | `GroupMember.Read.All` | Yes | Yes | Yes | | `Group.Read.All` | Yes | Yes | Yes | | `Directory.Read.All` | Yes | Yes | Yes | | `Directory.ReadWrite.All` | Yes | Yes | Yes | | `Organization.Read.All` | Yes | Yes | Yes | What each actually returns — this is the part that decides the ask: - **`User.ReadBasic.All`** is deliberately constrained to a fixed property set: `displayName`, `givenName`, `id`, `mail`, `photo`, `securityIdentifier`, `surname`, `userPrincipalName`. Nothing else. No `jobTitle`, no `department`, no `employeeId`, no `accountEnabled`, no manager. For a people-directory feature that is often enough, and it is the only rung on this ladder that an ordinary user can consent to without an admin. - **`User.Read.All`** reads the full user profile for every user in the tenant. This is the real floor for an HRIS-style employee sync, because `department`, `jobTitle`, `employeeId`, `employeeType`, `employeeHireDate`, `officeLocation`, `accountEnabled` and the manager relationship all live above the basic set. - **`GroupMember.Read.All`** reads memberships and *basic* group properties. It is the right permission when you want group membership and nothing else. - **`Group.Read.All`** reads group properties **and, for Microsoft 365 groups, the group's content** — Microsoft states plainly that `Group.*` permissions grant access to conversations, files, notes and calendar for those groups. A customer who hears "we need to read your groups" and then reads that description in the consent screen will escalate. Ask for `GroupMember.Read.All` unless you genuinely need group properties beyond membership. - **`Directory.Read.All`** reads "data in your organization's directory, such as users, groups and apps" — users, groups, devices, service principals, applications, roles, domains, policies. It is the permission a security team refuses by name, and the one Microsoft itself now tells you to avoid. **It is almost never necessary**: the resource-specific permissions above cover users and groups, and Graph's `directoryObject` relationships return limited information (the object's `@odata.type` and `id`, other properties null) rather than erroring when your app cannot read a member type — which is exactly the design that lets you skip `Directory.*`. - **`Directory.ReadWrite.All`** does not allow deleting users or groups, and in delegated mode does not allow password resets. It allows essentially everything else across the directory. Expect this one to end a conversation. - **`Organization.Read.All`** reads the tenant's own profile and related resources — subscribed SKUs, tenant branding. Note the cheap alternative: with only `User.Read`, `GET /organization` still returns `id`, `displayName` and `verifiedDomains`, with every other property null. If all you need is the company name and domains, you do not need this permission at all. **Mixing defeats the point.** Every scope in the token is honored, so requesting `User.ReadBasic.All` *alongside* `Directory.Read.All` is not a least-privilege app — it is a broad app with a least-privilege label, and the reviewer reading the consent screen will see the broad one. Narrowing means the broad scopes come out of the request. ## 2. The least-privilege ladder Climb it one rung at a time, and stop at the first rung that satisfies the feature: ``` User.Read the signed-in user only no admin needed User.ReadBasic.All 8 fixed properties, all users no admin needed (delegated) User.Read.All full user profiles, all users admin + GroupMember.Read.All memberships + basic group props admin + Group.Read.All group properties (and M365 content) admin Directory.Read.All everything in the directory admin, and refused on sight ``` Two rules that survive every review: 1. **Every rung below `Directory.Read.All` exists precisely so you do not have to request `Directory.Read.All`.** If someone proposes it, the question to ask is which specific object the resource-specific permissions fail to reach. Usually the answer is "none" and the real reason is that a single scope was easier to configure. 2. **The jump from `User.ReadBasic.All` to `User.Read.All` is the expensive one** — not because of the data, but because it is the step where the connector stops being self-serve (§3). If a feature can ship on the basic property set, it can ship without a tenant admin, and that is worth real product effort to preserve. ## 3. Admin consent: why directory sync cannot be self-serve Only two things in this whole family are user-consentable: `User.Read`, and delegated `User.ReadBasic.All`. Everything else — every `.All` permission in delegated mode past the basic one, and **every** application permission without exception — requires a tenant administrator. That is a documented property of the permissions, not a configuration you can change, and no amount of re-registering alters it. The practical consequences, which belong in the product conversation before anyone writes code: - **An HRIS-style directory sync cannot be a self-serve "connect" button.** The end user clicks, hits **`AADSTS90094: AdminConsentRequired`**, and the flow dead-ends. Onboarding has to include a step that only the customer's tenant administrator can complete — either the admin runs the connect flow themselves, or you send them an admin-consent URL (base skill §6), or their tenant has an admin-consent request workflow and the user files a request and waits. - **App-only sync raises the bar again.** Application permissions on Microsoft Graph cannot be consented by an Application Administrator or a Cloud Application Administrator — it takes a **Privileged Role Administrator**. Find out whether the customer has one available *before* scheduling the call. - **An admin who signs in without `prompt=consent` consents only for themselves.** Other users in that tenant still dead-end. If your design is "the admin unlocks the tenant once", send `prompt=consent` or the admin-consent URL. - **Re-consent is not purely additive.** Granting tenant-wide admin consent can revoke permissions already granted tenant-wide for the same app, so changing the directory scope set on a live connector is a migration, not an edit. - **The tenant may block user consent entirely**, in which case even `User.ReadBasic.All` needs an admin. Their policy, not your permission set. Design the connect flow so the admin requirement is stated *before* the user clicks, and so the failure is a readable "your Microsoft administrator has to approve this" rather than a raw AADSTS code. ## 4. Guests and B2B users Directory results are not a clean list of employees, and delegated results depend on who authorized. - **Guests appear in `/users`.** A B2B guest is a full user object with `userType: "Guest"`, plus `externalUserState` (`PendingAcceptance`, `Accepted`, or null) and `externalUserStateChangeDateTime`. In a tenant that collaborates with partners, guests can be a large fraction of the directory. An employee sync that does not filter them will import consultants, vendors, and the other side of every shared channel as staff. Filter on `userType` — it requires `$select` to retrieve — and decide deliberately, rather than discovering it in a customer's data. - **A guest cannot call `GET /users` at all.** Microsoft states it outright on the list-users API. If your delegated connector is authorized by a guest account — easy to do by accident when the person connecting is a contractor in the tenant — the call fails regardless of the scopes on the token. - **Default guest permissions are a reduced directory view**, and tenants can reduce it further to "restricted guest". A default guest can read only display name, email, sign-in name, photo, UPN and user type of other users (plus manager and direct-report information); a restricted guest can read essentially nothing about other users, and only the object ID of groups they joined. A delegated connector authorized by a guest can therefore return a small, strange subset of the directory with no error at all — which reads as a bug and is not one. - **Member users are not unrestricted either.** By default a member can enumerate users and groups, but tenants restrict this; if `/users` returns `403 Authorization_RequestDenied` with correct scopes, the tenant's default user permissions are the thing to check. ## 5. Querying: `$select`, `$filter`, and `ConsistencyLevel: eventual` Directory collections do not behave like a normal REST list endpoint, and three of these will bite. - **`GET /users` returns a fixed short property set by default**: `businessPhones`, `displayName`, `givenName`, `id`, `jobTitle`, `mail`, `mobilePhone`, `officeLocation`, `preferredLanguage`, `surname`, `userPrincipalName`. Every other property — `department`, `employeeId`, `employeeType`, `accountEnabled`, `userType`, `createdDateTime`, `onPremises*`, and all extension attributes — **requires `$select` and is silently absent without it.** A connector that "loses" fields between a single-user read and a list read is almost always missing `$select`. - **Some properties can only be read one user at a time.** `aboutMe`, `birthday`, `hireDate`, `interests`, `mySite`, `pastProjects`, `preferredName`, `responsibilities`, `schools`, `skills` and `mailboxSettings` are not returnable in a user collection, and `$select`-ing them on `/users` returns **`501 Not Implemented`**. - **Advanced queries need two things together.** `$search`, `$count`, and the `ne` / `not` / `endsWith` and `$filter`-on-null forms are served from a separate index and require the header **`ConsistencyLevel: eventual`** — and, for everything except `$search`, the `$count` query parameter as well. Without them the request fails rather than degrading. Plain `eq` filters (for example `$filter=accountEnabled eq false`) work without either. `$search` on groups tokenizes only `displayName` and `description`. - **Paging**: default page size 100, maximum 999, for both `/users` and `/groups`. Follow `@odata.nextLink` verbatim until it stops appearing; never construct the next URL. `$skip` is not supported on these collections. `$select=signInActivity` (or filtering on it) drops the maximum page size to **500** and triggers stricter throttling — `$top=500` is the documented way to keep that case cheap. - **`$expand` returns a maximum of 20 objects**, which quietly truncates direct reports and group members. Page the relationship instead of expanding it whenever the count can exceed 20. - **Extension attributes each need their own treatment**: `onPremisesExtensionAttributes` 1–15, schema extensions and directory extensions are returned only with `$select`; open extensions only with `$expand=extensions`. ## 6. Delta queries — the right way to sync `GET /users/delta` and `GET /groups/delta` are change tracking, and they are what an incremental directory sync should be built on. - The call pattern: request `/delta`, follow `@odata.nextLink` until a **`@odata.deltaLink`** arrives, store the deltaLink, and call it next round to get only what changed. A page never carries both links. Tokens are opaque — copy the URL, do not parse it. - **Deletions arrive as `@removed`**, on an object that carries only its `id`. `"reason": "changed"` means deleted (to the recycle bin); `"reason": "deleted"` means permanently deleted. Creations and restores carry no annotation, so treat "seen in delta without `@removed`" as upsert. - **Query parameters are encoded into the token.** Specify `$select` on the *initial* request only, then stop sending parameters; repeating them on subsequent calls is wrong. And note the consequence: if a property is not in your initial `$select`, a change to it will not surface the object in later delta responses at all. Widening the synced field set means starting a new delta chain. - **For users and groups, `$expand`, `$top` and `$orderby` are not supported** in delta. Do not assume any ordering — the same object can appear anywhere in the `nextLink` sequence, so merge logic must be idempotent. - **`$select` does support the `manager` (users) and `members` (groups) navigation properties**, which is how you track reporting-line and membership changes incrementally. - **"Sync from now"**: appending `$deltatoken=latest` returns a deltaLink with no data, skipping the initial full read. Useful when a customer only wants changes from the moment they connected. - Delta is unavailable in External ID external tenants and Azure AD B2C tenants (Platform state). ## 7. Managers and direct reports Reporting structure is a separate permission and a separate shape, and it has one documented gap worth planning around. - **`GET /users/{id}/manager`** — delegated least privilege is **`User.Read.All`**, so org-chart features cannot ride on `User.ReadBasic.All`. As documented today, **application permissions are listed as "Not supported"** on this endpoint. - **`GET /users/{id}/directReports`** — delegated least privilege is `User.Read` *and* `User.ReadBasic.All`; application least privilege is `User.Read.All`. So app-only reads work here. - For an app-only sync, get reporting lines by expanding on the users collection — `GET /users?$expand=manager` and `GET /users/{id}?$expand=manager($levels=n)` for a management chain — which is governed by the list/get permissions rather than the `/manager` endpoint's. **`$levels=n` requires `ConsistencyLevel: eventual`.** Confirm this against live behavior for your permission set before designing around it; the `/manager` permission table and the `$expand` route do not obviously agree. - `$expand`'s 20-object cap (§5) applies to direct reports. A VP with 30 reports silently returns 20. - `employeeLeaveDateTime` is gated separately again: reading it needs **`User-LifeCycleInfo.Read.All`**, and in delegated scenarios the admin needs the Lifecycle Workflows Administrator or Global Reader role. `employeeHireDate` is an ordinary `$select`-able property; the pair is not symmetric, which surprises HRIS-style connectors. - Memberships: prefer `GET /users/{id}/transitiveMemberOf` and `GET /groups/{id}/transitiveMembers` when nested groups matter — the direct `memberOf` / `members` relationships do not expand nesting — and price them accordingly (§9). ## 8. On-premises-synced attributes If the customer runs Microsoft Entra Connect (directory synchronization from on-premises Active Directory), the directory you are reading is partly a mirror, and that changes what you can write. - **`onPremisesSyncEnabled`** tells you whether a given user is synced. It is the flag to branch on, per user, not per tenant — most hybrid tenants hold both synced and cloud-only accounts. - **For a synced user, the on-premises directory is the source of authority and the synced properties are read-only in Graph.** A write against one fails or is silently overwritten on the next sync cycle. This is the single most common cause of "our update went through and then reverted". - **`onPremisesExtensionAttributes` 1–15** (the Exchange custom attributes) each hold up to 1024 characters and are where customers most often park employee IDs, cost centers and org codes. They require `$select`, support `$filter` with `eq` / `ne` / `not` / `in`, and follow the same source-of-authority rule: writable only for cloud-only users. A user previously synced and then cut over to cloud-only stays read-only in Graph and is managed from Exchange instead. - Other on-premises fields worth knowing: `onPremisesImmutableId` (the anchor back to the AD account; subject to sensitive-action restrictions so only privileged roles can update it), `onPremisesSamAccountName`, `onPremisesDomainName`, `onPremisesUserPrincipalName`, `onPremisesLastSyncDateTime` and `onPremisesProvisioningErrors`. All read-only, all requiring `$select`. - Ask during onboarding whether the tenant is hybrid. It changes which fields are authoritative, which are writable, and where a customer's real employee identifier lives. ## 9. Throttling — directory economics Directory calls are metered by **resource units** on a token-bucket, not by request count, and the costs are not uniform. - **Quota per application+tenant pair**, scaled by tenant size: **3,500** resource units per 10 seconds for tenants under 50 users (S), **5,000** for 50–500 (M), **8,000** above 500 (L). Per application across all tenants: **150,000** units per 20 seconds. Writes are counted separately — 3,000 requests per 2.5 minutes per application+tenant pair, 18,000 per 5 minutes per tenant across all apps. Exceeding any of them returns **`429 Too Many Requests`**, and 429s can also arrive below the limits under load. - **Base costs that matter for directory sync**: `GET /users` costs **2**; `GET /groups/{id}/members` **3**; `GET /groups/{id}/transitiveMembers` **5**; `POST /directoryObjects/getByIds` **5**; `GET /me/memberOf` and `/transitiveMemberOf` **2**; anything else on an identity path **1**. - **The modifiers are worth real money**: `$select` **decreases** cost by 1, `$top` under 20 decreases by 1, `$expand` **increases** by 1. A cost can never fall below 1. So `$select` is not only how you get the fields you need (§5) — it is also the cheapest way to call the endpoint, and `$expand` is the most expensive. - **Reports are throttled far harder than the directory**: identity and access reports allow only **5 requests per 10 seconds** per app per tenant, and `GET signInActivity` **10 per minute**, full stop. Do not fold sign-in activity into a user sync. - **Honor `Retry-After`**, and prefer delta-with-token over repeated full reads — Microsoft's own stated reason for delta is that it lowers request cost and the chance of being throttled (§6). - `x-ms-throttle-priority` exists; reserve any high priority for user-initiated requests, not background sync. ## Product fact — Unified.to's Entra directory connector, as of 2026-09-20 Recorded from the connector's own configuration on this date. **Scope lists change; confirm the current set with the connector's owner before you register anything.** - The connector supports **both** modes against the same platform: a **delegated OAuth2** flow where a user authorizes, and an **app-only client-credentials** connection where the customer supplies a client ID, a client secret and a tenant ID, requesting `https://graph.microsoft.com/.default`. - **The delegated scope set is the broad `Directory.*` kind.** Every employee and group scope set carries `Directory.Read.All` (read) or `Directory.ReadWrite.All` (write) *alongside* the resource-specific permission — for employees, `User.Read` plus `User.Read.All` or `User.ReadWrite.All`; for groups, the same plus `Group.Read.All`. Device scope sets are narrower, carrying only `Device.Read.All` or `Device.ReadWrite.All`. - Every data-bearing scope set includes **`offline_access`** (so refresh tokens come back) together with `openid`, `email` and `profile`. The sign-in-only variant requests just `openid`, `email`, `profile`, `User.Read` — and **not** `offline_access`, which is correct for a login-only flow and worth not "fixing". - The authorize and token endpoints are the multitenant `/common` v2.0 ones, and the authorize request sends `prompt=select_account`. - The connector is categorized for HRIS, auth and SAML, so the same registration may be in play for single sign-on as well as directory sync. **What that predicts.** Every one of those delegated scope sets is admin-consent-only, so no customer connects this self-serve (§3) — and `Directory.Read.All` / `Directory.ReadWrite.All` in the list is exactly the pairing a security review escalates (§1), especially now that Microsoft's own reference tells customers to avoid directory permissions. If a customer pushes back, the conversation to have is whether the resource-specific permissions alone would serve the objects they actually need. Do not quietly drop a scope from a live connector: re-consent is not additive (§3). ## 10. Verify the directory-specific parts Run the base skill's end-to-end check first (authorize from a second tenant, confirm `refresh_token`, decode `scp` or `roles`). Then: 1. `GET /users?$top=1` — proves the permission reaches the collection at all. 2. The same call **with `$select`** for every field your connector maps, and confirm each one comes back. Fields missing here are a `$select` bug, not a permission bug (§5). 3. `GET /users?$count=true&$filter=userType eq 'Guest'` with `ConsistencyLevel: eventual` — tells you how much of this tenant is guests before you sync them as employees (§4). 4. One membership read, transitive: `GET /users/{id}/transitiveMemberOf`. 5. One manager read, in the mode you will actually ship (§7). 6. `GET /users/delta`, then call the returned `@odata.deltaLink` immediately and confirm a near-empty response (§6). 7. If the tenant is hybrid: `$select=onPremisesSyncEnabled,onPremisesLastSyncDateTime` on a synced user, and confirm nobody is planning to write to a synced attribute (§8). | Symptom | Cause | | --- | --- | | `403 Authorization_RequestDenied` on `/users` with the scope in the token | Admin consent never granted tenant-wide, or the tenant restricted default user permissions (§3, §4) | | `AADSTS90094` at the consent screen, ordinary user | An admin-restricted permission; only `User.Read` and delegated `User.ReadBasic.All` avoid this (§3) | | Admin clicks "Grant admin consent" and it fails | Graph **application** permissions need a Privileged Role Administrator (base §6, §3) | | `/users` returns 403 for one particular connecting user only | That user is a guest — guests cannot call list users (§4) | | Delegated connector returns a small, odd subset of the directory | Authorized by a guest, or by a member in a tenant with restricted default permissions (§4) | | Fields present on `GET /users/{id}` but missing from `GET /users` | No `$select`; the collection returns a fixed short property set (§5) | | `501 Not Implemented` on a `$select` | A property that can only be read one user at a time (§5) | | `$filter` with `ne` / `not` / `endsWith`, or `$count`, fails | Missing `ConsistencyLevel: eventual` and `$count` (§5) | | Direct reports or members capped at exactly 20 | `$expand` returns at most 20 objects — page the relationship (§5, §7) | | Nested group members missing | `members` is direct-only; use `transitiveMembers` (§7) | | A property changes upstream but never appears in delta | It was not in the initial `$select`, which is baked into the token (§6) | | Updates to a user revert after a while | The user is `onPremisesSyncEnabled`; on-premises is the source of authority (§8) | | Sudden `429` under load | Resource-unit quota; honor `Retry-After`, add `$select`, drop `$expand`, move to delta (§9) | | Revoking consent does not fully stop directory access | Pre-2021 residue: a Directory Readers/Writers role left on the service principal (Platform state) | | Delta endpoint unavailable in a customer tenant | External ID external tenant or Azure AD B2C — change tracking is unsupported there (Platform state) | ## Hand off — the directory additions The base skill's hand-off list applies. Add to the closing summary: the exact directory permission strings requested and whether each is delegated or application; **which of them required admin consent and which admin role granted them, per customer tenant**; whether guests are included or filtered in the sync; whether the tenant is hybrid and therefore which attributes are read-only; and whether the connector runs full or delta sync, plus where the delta tokens are stored (losing them forces a full re-read). ## Stop and ask On top of the base skill's conditions, hand back to a human when: - `Directory.Read.All` or `Directory.ReadWrite.All` is proposed and nobody has checked whether the resource-specific permissions would do. That is the permission that ends customer security reviews, and Microsoft has flagged the family as possibly deprecated in future. - The product plan assumes a self-serve connect button for a directory sync. It cannot be one (§3); that is a roadmap conversation, not a scope tweak. - App-only sync is required and no customer tenant has a Privileged Role Administrator available. - Someone wants to change the scope set on a live connector — re-consent can revoke existing tenant-wide grants. - Write access to the directory is requested. `User.ReadWrite.All` and `Directory.ReadWrite.All` are a different category of ask, and the granular `User-*` write permissions may cover the actual need instead (Platform state). - The customer is hybrid and the design writes back to attributes owned by on-premises Active Directory (§8). - Guest/B2B users are in the directory and nobody has decided whether they are in scope (§4). - The tenant is an Entra External ID external tenant or an Azure AD B2C tenant — different query and sync capabilities, effectively a different task. ## References The base skill carries the shared Entra registration references. These are the directory-specific ones; verified 2026-09-20, every link returned HTTP 200. - Microsoft Graph permissions reference (`User.*`, `Group.*`, `Directory.*`, `Organization.*`) — https://learn.microsoft.com/en-us/graph/permissions-reference - Microsoft Graph permissions overview, incl. limited information for inaccessible member objects — https://learn.microsoft.com/en-us/graph/permissions-overview - user resource type (every property, and which require `$select`) — https://learn.microsoft.com/en-us/graph/api/resources/user - List users (default property set, page sizes, guest restriction) — https://learn.microsoft.com/en-us/graph/api/user-list - Get a user — https://learn.microsoft.com/en-us/graph/api/user-get - Update a user — https://learn.microsoft.com/en-us/graph/api/user-update - group resource type — https://learn.microsoft.com/en-us/graph/api/resources/group - List groups — https://learn.microsoft.com/en-us/graph/api/group-list - List group members — https://learn.microsoft.com/en-us/graph/api/group-list-members - List group transitive members — https://learn.microsoft.com/en-us/graph/api/group-list-transitivemembers - List a user's transitive memberOf — https://learn.microsoft.com/en-us/graph/api/user-list-transitivememberof - Get a user's manager — https://learn.microsoft.com/en-us/graph/api/user-list-manager - List a user's direct reports — https://learn.microsoft.com/en-us/graph/api/user-list-directreports - directoryObject resource type — https://learn.microsoft.com/en-us/graph/api/resources/directoryobject - Get directory objects by ID — https://learn.microsoft.com/en-us/graph/api/directoryobject-getbyids - Get organization — https://learn.microsoft.com/en-us/graph/api/organization-get - onPremisesExtensionAttributes resource type — https://learn.microsoft.com/en-us/graph/api/resources/onpremisesextensionattributes - Advanced query capabilities on Microsoft Entra ID objects (`ConsistencyLevel: eventual`) — https://learn.microsoft.com/en-us/graph/aad-advanced-queries - OData query parameters — https://learn.microsoft.com/en-us/graph/query-parameters - Paging Microsoft Graph data — https://learn.microsoft.com/en-us/graph/paging - Use delta query to track changes — https://learn.microsoft.com/en-us/graph/delta-query-overview - Get incremental changes for users — https://learn.microsoft.com/en-us/graph/delta-query-users - Get incremental changes for groups — https://learn.microsoft.com/en-us/graph/delta-query-groups - user: delta — https://learn.microsoft.com/en-us/graph/api/user-delta - group: delta — https://learn.microsoft.com/en-us/graph/api/group-delta - Microsoft Graph throttling guidance — https://learn.microsoft.com/en-us/graph/throttling - Microsoft Graph service-specific throttling limits (identity resource units) — https://learn.microsoft.com/en-us/graph/throttling-limits - Microsoft Graph best practices — https://learn.microsoft.com/en-us/graph/best-practices-concept - Default user permissions in Microsoft Entra ID (member vs guest vs restricted guest) — https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions - Restrict guest access permissions — https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions - B2B collaboration overview — https://learn.microsoft.com/en-us/entra/external-id/what-is-b2b - Microsoft Entra Connect Sync overview (hybrid/on-premises attributes) — https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-sync-whatis - Microsoft Entra service limits and restrictions — https://learn.microsoft.com/en-us/entra/identity/users/directory-service-limits-restrictions - Privileged roles and permissions in Microsoft Entra ID — https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/privileged-roles-permissions ## microsoft-oauth-app URL: https://docs.unified.to/skills/microsoft-oauth-app/SKILL.md # Microsoft Graph OAuth2 App Registration Get a working Microsoft OAuth2 client — an app registration, a Web redirect URI, Microsoft Graph permissions, a client ID and a client secret — for a platform that connects *other organizations'* Microsoft 365 tenants on behalf of many customers. Every Microsoft Graph–backed connector registers identically. Outlook mail and calendar, OneDrive, Teams, Entra directory, Excel, To Do: same portal, same form, same consent model. Only the permission strings differ, and the product decides those, not the portal. That is why almost all of this job lives in the base skill. ## Built on: `microsoft-entra-app-registration` **Read the base skill first.** It is the source of truth for the Entra mechanics this one assumes, and it carries the facts that cost a launch when they are wrong: - **Supported account types** — single tenant vs multitenant vs personal Microsoft accounts, the 30-permission ceiling once personal accounts are in scope, and `AADSTS50194` when a single-tenant app is called through `/common`. - **Redirect URIs** — the Web vs SPA vs public-client platform choice (only **Web** may present a client secret), exact case-sensitive matching, the count and length limits, and the Unified.to callback hosts. - **Delegated vs application permissions, and admin consent** — which permissions need an admin, and the fact that Microsoft Graph *application* permissions need a **Privileged Role Administrator**, not an Application Administrator. - **Client secrets** — the 24-month cap with no never-expires option, the **Value** shown exactly once, `AADSTS7000222` on expiry, add-then-delete rotation, and certificates as Microsoft's preferred alternative. - **`offline_access`, publisher verification and the generic AADSTS symptom table** — including risk-based step-up consent, which blocks ordinary users from consenting to a non-verified multitenant app. The base also holds the inputs to collect, the run order, the tenant-ownership rules, the credential hand-off, the stop-and-ask conditions and the doc references. Everything below is what Graph sign-in adds on top. ## This is a sign-in connector, not a data connector The distinction matters more than it sounds. A Graph **data** connector authorizes a user in order to read their mail, files or calendar for as long as the connection lives, so it must request `offline_access` and hold a refresh token. A Graph **authentication** connector authorizes a user only to establish who they are — it reads the signed-in user once, at connect time, and never needs to call Graph again on that user's behalf. Unified.to's Microsoft connector is the second kind. That is why the scope set below carries no `offline_access` and why the absence of a refresh token on this path is the designed behavior rather than the bug it looks like. If the task is actually a data connector — mail, files, calendar, directory sync — it needs `offline_access` and a different permission set, and that is a product decision to confirm with the connector's owner, not an assumption to make here. ## Product fact — as of 2026-09-20 Unified.to's Microsoft connector authorizes against the tenant-agnostic `common` authority (`https://login.microsoftonline.com/common/oauth2/v2.0/authorize` and the matching v2.0 `/token`), with Microsoft Graph at `https://graph.microsoft.com`, and requests the space-delimited sign-in scope set **`openid email profile User.Read`**. It always sends `prompt=select_account` on the authorize request, so users get the account picker instead of silent SSO. Token exchange is a form-encoded POST carrying `client_id` and `client_secret` in the **body** (not HTTP Basic), and it sends `redirect_uri` on refresh as well as on exchange. It identifies the user by reading the signed-in user from Graph and **requires a user principal name to be present**. Two consequences to raise before you register anything: - The `common` authority means the registration **must** be multitenant. A single-tenant registration called through `/common` fails for every customer with `AADSTS50194` (base skill, §3). - **`offline_access` is not in that sign-in scope set**, so this connector's Microsoft sign-in path receives no refresh token. Other Microsoft Graph connectors on the same platform do request it. Confirm all of this with the connector's owner before choosing permissions; **do not infer either answer**. Scope lists change and this one is a snapshot. ## Symptom → cause, specific to this connector The base skill's table covers the generic AADSTS failures. These are the ones that only make sense here: | Symptom | Cause | | --- | --- | | Sign-in succeeds but no `refresh_token` is stored, on *this* connector | Expected — `offline_access` is not in the `openid email profile User.Read` set. Do not "fix" it by adding the scope without the owner's sign-off | | Every customer tenant fails at authorize with `AADSTS50194` | The registration is single-tenant while the connector calls `/common`. Fix the registration, not the authority | | Token exchange returns `invalid_client` although the secret is right | Credentials sent as HTTP Basic; this connector puts `client_id`/`client_secret` in the form body | | Refresh fails although exchange worked | `redirect_uri` omitted on the refresh request — this connector sends it on both | | Connect completes, then the connection is rejected for a missing user | The signed-in account has no user principal name; a Graph identity read cannot identify it | | Users are silently signed in as the wrong account | `prompt=select_account` was dropped; without it the browser's existing Microsoft session is reused | ## References The base skill carries the full reference list. These back the sign-in specifics above: - OAuth 2.0 authorization code flow (v2.0 endpoints, `prompt`, form-encoded exchange) — https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow - Scopes and permissions (`openid`, `email`, `profile`, `offline_access`) — https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc - Get a user (`/me`, `userPrincipalName`) — https://learn.microsoft.com/en-us/graph/api/user-get - Microsoft Graph permissions reference (`User.Read`) — https://learn.microsoft.com/en-us/graph/permissions-reference ## microsoft-onedrive-oauth-app URL: https://docs.unified.to/skills/microsoft-onedrive-oauth-app/SKILL.md # OneDrive (Microsoft Graph files) OAuth2 App Registration Get a working OAuth2 client for OneDrive — an Entra ID app registration with the right `Files.*` permissions — for a platform that connects many customers' drives. Registering the app is the base skill's job. What OneDrive adds is a permission family with a wide blast radius and a narrow set of least-privilege escapes. **`Files.Read.All` and `Files.ReadWrite.All` are not "OneDrive" permissions**: in application mode they reach every file in every site collection in the tenant, which includes every employee's private OneDrive *and* every SharePoint document library. The three narrower models — a delegated token scoped to one signed-in user, the app folder, and per-item `Files.SelectedOperations.Selected` grants — each cost something at onboarding. Choosing among them is the whole negotiation; the portal work is twenty minutes. ## Built on: `microsoft-entra-app-registration` **Read the base skill first.** It is the source of truth for the Entra mechanics this one assumes: - **Supported account types** — single tenant vs multitenant vs personal Microsoft accounts, the permission ceilings each imposes (30 permissions once consumer accounts are in scope), and that application permissions do not exist for a consumer-only audience. OneDrive is the one Microsoft product where this dropdown is a *product* decision (§3). - **Redirect URIs** — the Web vs SPA vs public-client platform choice, exact case-sensitive matching, the count and length limits, and the Unified.to callback hosts. - **Delegated vs application permissions, and admin consent** — how the two modes differ, which permissions need an admin, and that Microsoft Graph *application* permissions require a **Privileged Role Administrator**, not an Application Administrator. - **Client secrets and certificates** — the 24-month cap, the **Value** shown exactly once, `AADSTS7000222` on expiry, add-then-delete rotation, and the per-cloud authority and Graph hosts (commercial, US Gov L4/L5, China). - **`offline_access`, publisher verification and the generic AADSTS symptom table** — no `Files.*` scope carries a refresh token of its own; `offline_access` is what gets you one, and a file-sync connector is worthless without it. The base also holds the inputs to collect, the run order, tenant ownership, the credential hand-off and the generic stop-and-ask conditions. **A reader who loads only this file is missing all of that** — every portal path, every consent rule, and every credential-handling rule this skill silently relies on. Everything below is OneDrive-specific. ## Extra inputs to collect On top of the base skill's batch: | Input | Notes | | --- | --- | | **Whose files? One signed-in user's drive, or every drive in the tenant?** | The decisive question — it picks delegated vs application and narrow vs `.All` (§1, §2) | | **OneDrive for Business only, or consumer OneDrive too?** | Changes supported account types, and caps the app at 30 permissions (§3) | | **Read-only, or read/write?** | Decides the verb, and whether uploads and sharing-link creation are in scope (§5, §6) | | **Does the connector write files larger than 250 MB?** | Forces upload sessions, and in app-only mode a *different* permission (§5) | | **Does it need sharing links or per-item permissions?** | Sharing is priced and throttled separately from file I/O (§6, §7) | | **Is a scanning/sync model intended (delta over many drives)?** | Drives the throttling budget and the app-only decision (§5, §7) | ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Microsoft Graph is the API for all three file backends.** OneDrive (personal), OneDrive for Business and SharePoint document libraries are the same `drive` / `driveItem` resources; the `driveType` property comes back as `personal`, `business` or `documentLibrary`. There is no separate "OneDrive API" to register against. - **`Files.Read.Selected` and `Files.ReadWrite.Selected` are legacy — do not use them.** Both are still listed in the permissions reference, both are marked **(Preview)**, both are delegated-only, and Microsoft states they are "only valid on work or school accounts", are "only exposed for working with Office 365 file handlers (v1.0)", and "should not be used for directly calling Microsoft Graph APIs". They grant access for a few hours after a user picks a file. They are **not** the per-item least-privilege model, despite the name. - **`Files.SelectedOperations.Selected` is the current per-item model**, and it supports **both delegated and application** modes. Unlike the two above, it requires **admin consent in both modes**. It is the file-level member of the same Selected family as `Sites.Selected` (see the SharePoint skill) and works the same way: consent grants nothing until someone stamps a grant on a specific item. - **`Files.ReadWrite.AppFolder` still exists in both modes**, giving an app a private folder inside the user's drive and nothing else. The delegated form is marked (Preview) and is available for consent on personal Microsoft accounts. Reaching it via `/me/drive/special/approot` is **delegated-only** — the special-folder API documents application permissions as "Not supported." - **Microsoft's own permission tables disagree about app-only drive access.** `GET /drives/{id}` (and `/users/{id}/drive`) documents Application as "Not supported.", while the OneDrive scanning guidance tells app-only scanners to enumerate every user's drive with `Files.Read.All`. Treat the scanning guidance as the intent, verify app-only drive access against a real tenant during §8, and report what you actually observe rather than quoting either page. - **OneDrive and SharePoint share one throttling budget** — the same resource-unit economics, measured per app per tenant. OneDrive adds *per-user* limits the SharePoint skill does not cover (§7). If the Entra admin center or Graph does not behave like this, stop and report what you actually see. ## 1. The `Files.*` family Requested as Microsoft Graph permissions, in roughly the order a customer security review will tolerate them: | Permission | Modes | What it reaches | | --- | --- | --- | | `Files.ReadWrite.AppFolder` | delegated + application | Only the app's own folder inside a drive. Nothing else. | | `Files.SelectedOperations.Selected` | delegated + application | **Nothing until an item-level grant is stamped** (§2). Admin consent required in **both** modes. | | `Files.Read` | delegated only | The signed-in user's files. No admin consent. On personal accounts, also files shared *with* that user. | | `Files.ReadWrite` | delegated only | Read/create/update/delete the signed-in user's files. No admin consent. | | `Files.Read.All` | delegated + application | Delegated: everything the signed-in user can already see. **Application: every file in every site collection, admin consent required.** | | `Files.ReadWrite.All` | delegated + application | Same reach, read/write. The broadest ask in this family. | | `Files.Read.Selected` / `Files.ReadWrite.Selected` | delegated only | **Legacy.** Preview, work/school only, Office 365 file handlers only — not for calling Graph (Platform state). | Identity-only scopes (`openid`, `profile`, `email`, `offline_access`, `User.Read`) carry no file access whatsoever. **Delegated is the default answer for a per-user connector.** `Files.Read` and `Files.ReadWrite` need no administrator at all, which means an ordinary employee can click through your connect flow — the single biggest difference between a OneDrive connector that onboards itself and one that needs a scheduled call with IT. Both are capped at what that user can already do, which is usually exactly the semantics a customer wants. **The jump from `Files.ReadWrite` to `Files.ReadWrite.All` is much larger than the name suggests.** It is not "the same thing, for more files". In application mode it is tenant-wide read/write over every employee's private OneDrive and every SharePoint library, granted once and thereafter invisible to the people whose files it covers. **Mixing defeats the point.** Any scope present in the token is honored, so requesting `Files.SelectedOperations.Selected` or `Files.ReadWrite` *alongside* `Files.ReadWrite.All` produces a tenant-wide app with a least-privilege label. If you take a narrow route, the broad scopes come out of the request. **Product fact — as of 2026-09-20.** Unified.to's OneDrive connector is **delegated only**; there is no app-only (client-credentials) path of the kind its SharePoint connector offers. Its OAuth2 configuration requests, per object: - **Reading files:** `Files.Read.All` plus `offline_access` — and nothing else, not even the OIDC scopes. - **Writing files:** `Files.ReadWrite`, `Files.ReadWrite.All`, `Sites.ReadWrite.All`, plus `offline_access`. - **Reading or writing directory people and groups** (the connector also carries HR-style objects): `Directory.Read.All`, `User.Read`, `User.Read.All` and, for groups, `Group.Read.All` — with `Directory.ReadWrite.All` and `User.ReadWrite.All` on the write variants — always with `openid`, `email`, `profile` and `offline_access`. - **Sign-in only:** `openid`, `User.Read`, `email`. `offline_access` is requested in every non-login scope set, so refresh tokens are expected. Authorization carries `prompt=select_account`, the token exchange is form-encoded, and the connector ships Unified.to's own shared client credentials by default across four clouds (global, US Gov L4, US Gov L5/DoD, China), all through the tenant-agnostic `common` authority. **These are the broad `.All` kind that security reviews resist.** A reviewer reading the file-write set sees `Files.ReadWrite.All` *and* `Sites.ReadWrite.All` — tenant-wide read/write over every user's OneDrive and every SharePoint site — for a connector whose stated job is files, and whose delegated upload path would be satisfied by `Files.ReadWrite` alone. The directory scopes (`Directory.Read.All`, `User.Read.All`, `Group.Read.All`) are a second escalation on top. Expect refusal or escalation at any customer with a security review. **Confirm the current scope set with the connector's owner before you register anything** — scope lists change and this one is a snapshot. ## 2. The OneDrive / SharePoint boundary The two products share a storage backend, so the permissions bleed across the line in one direction and not the other. Get this wrong and you either over-request by an order of magnitude or discover at the customer's site that your narrow grant cannot see anything. **`Files.*.All` is tenant-wide across both products.** Microsoft's own wording for the application permissions is "all files in all site collections" — and in SharePoint Online a user's OneDrive *is* a site collection. So an app-only `Files.Read.All` grant reaches every employee's private OneDrive and every SharePoint document library, with one admin consent and no per-site step. That breadth is the reason it gets refused, not a bonus. **Use `Sites.Selected` and the SharePoint skill when** the target is a named site, a team's document library, or anything a customer would describe as "our SharePoint": the grant is per site, stamped by a SharePoint Administrator, and it is the model that passes security review. That skill owns the site-grant mechanics; do not re-derive them here. **Use the OneDrive model in this file when** the unit of access is *a person's own drive*: delegated `Files.Read` / `Files.ReadWrite` against `/me/drive`, with each user authorizing for themselves. No admin, no per-site grant, no tenant-wide reach — and access disappears when the user's own access does. **Use `Files.SelectedOperations.Selected` when** the unit is a specific file or library folder. The grant is stamped with `POST /drives/{driveId}/items/{itemId}/permissions`, and two details differ from the site-level grant the SharePoint skill documents: - The request body takes **`grantedToV2`**, and the API explicitly rejects `grantedToIdentities` and the deprecated `grantedTo` — the site-level body shape does *not* transfer. - Stamping an item-level grant requires a strong scope on the stamper's side: `Sites.FullControl.All`, or `Sites.Selected` / `Lists.SelectedOperations.Selected` already held at **FullControl** or **Owner** role. Two rules that decide arguments: **scope levels never ladder upward** — `Files.SelectedOperations.Selected` can never reach a list or a site, while `Sites.*` can be used to grant file-level permissions. And **an item-level grant breaks SharePoint permission inheritance** on that item, which counts against the tenant's unique-security-scope limits; a site-level grant does not. If a design wants hundreds of per-file grants, that is a service-limit conversation before it is a permissions one. ## 3. OneDrive personal vs OneDrive for Business These are different account systems reached through one API, and the difference lands on the base skill's supported-account-types dropdown — the one field that is expensive to change later. | | OneDrive for Business | OneDrive personal (consumer Microsoft account) | | --- | --- | --- | | Account | Work/school account in an Entra tenant | Outlook.com / Hotmail / Live / Xbox account | | `driveType` | `business` | `personal` | | Audience needed | **Multiple Entra ID tenants** is enough | Must include personal Microsoft accounts — which caps the app at **30 permissions** total (base §3) | | Application permissions | Yes | **No.** No tenant, no admin, no app-only anything | | Admin consent | Available, and required for `.All` and Selected scopes | Does not exist | | Reachable scopes | The whole `Files.*` family | `Files.Read`, `Files.ReadWrite`, `Files.Read.All`, `Files.ReadWrite.All`, `Files.ReadWrite.AppFolder` — **not** `Files.SelectedOperations.Selected`, not the legacy `*.Selected` pair, not `Sites.*` | | Other users' drives | `/users/{id}/drive`, with admin-level permissions | Only the signed-in user's own drive | Behavioral differences that surface as bugs, not errors: - On **personal accounts, `Files.Read` and `Files.ReadWrite` also cover files shared *with* the signed-in user** — broader than the same scope on a work account. Documented, and surprising to reviewers. - **`organization`-scoped sharing links do not exist on consumer OneDrive**; password-protected sharing links exist *only* there (§6). - **Delta's sharing-change headers and the security-event webhooks are SharePoint/OneDrive-for-Business only**, and delta's timestamp-as-token form is too (§5). - Consumer drives carry `description` and `audio` facets and `bundles` (albums) that business drives do not; business drives carry `following` that consumer drives do not. **The practical recommendation**: unless consumer OneDrive is genuinely a product requirement, choose **Multiple Entra ID tenants** and say so explicitly in the hand-off. Adding personal accounts buys a different customer segment and costs the 30-permission ceiling, application permissions, and query parameters in redirect URIs. The connector's endpoints authorizing through `common` does **not** settle this — `common` accepts both audiences, and the registration's account type is what actually decides which one gets in. ## 4. Addressing: drives and driveItems Graph exposes `drive` (a container: one per user for OneDrive, one per document library for SharePoint) containing `driveItem`s (files and folders, distinguished by a `file` or `folder` facet). | Path | What it addresses | | --- | --- | | `/me/drive` | The signed-in user's OneDrive — the delegated connector's entire world | | `/me/drives` | Every drive available to that user | | `/users/{user-id}/drive` | Another user's OneDrive (work/school) | | `/drives/{drive-id}` and `/drives/{drive-id}/items/{item-id}` | A drive, and an item in it, by id | | `/me/drive/root:/path/to/file` | The same item by path — the colon escapes the relative path | | `/me/drive/special/{name}` (e.g. `approot`) | A named special folder. **Delegated only** | | `/sites/{site-id}/drive`, `/sites/{site-id}/drives` | SharePoint libraries — the SharePoint skill's territory | | `/shares/{share-id}` | An item addressed by a sharing URL or share id | Four traps: - **Track items by id, never by path.** Ids persist across renames and moves; paths do not, and delta explicitly omits `parentReference.path`, so a path-keyed store silently duplicates every renamed folder's contents. - **Path addressing needs correct percent-encoding**, per segment. `#` and spaces must be encoded, and you cannot encode the whole URL in one call — the segments have different rules. This is the usual cause of a 400 or a mysterious 404 on a file whose name contains `#`. - **A user's OneDrive may not exist yet.** If the drive is not provisioned, `GET /users/{id}/drive` returns **404**. With **delegated** authentication and a licensed user, the request provisions the drive automatically; app-only does not, and that user stays a 404 until they open OneDrive once themselves. Plan onboarding around it. - **Do not hammer non-existent OneDrive sites.** Microsoft throttles a client's **IP address** for excessive attempts to reach OneDrive site collections that do not exist — which is exactly what enumerating every directory user and requesting their drive produces (§7). ## 5. Content: uploads, downloads, and delta **Uploads split at 250 MB.** `PUT /items/{id}/content` handles files up to **250 MB** in one call. Anything larger needs an **upload session**, and so does any upload that must survive a dropped connection: - `POST .../createUploadSession` returns an `uploadUrl` and an `expirationDateTime`. Each accepted fragment extends the expiry; if fragments stop arriving, everything uploaded is discarded. - **The `uploadUrl` is pre-authenticated. Sending an `Authorization` header to it can return `401`** — send the bearer token on the `POST` that creates the session, and never on the `PUT`s. This one costs an afternoon. - Fragments go **sequentially**, each a **multiple of 320 KiB (327,680 bytes)**, each under **60 MiB**. A fragment size that is not a multiple of 320 KiB can fail *after* the last byte range is uploaded. Microsoft recommends 5–10 MiB fragments and resumable transfers for anything over 10 MiB. - Resume by `GET`ting the `uploadUrl` for `nextExpectedRanges`; cancel with `DELETE`. A `404` on resume means the session is gone — start over. Retry 5xx with exponential backoff; do not back off other errors. - **The app-only permission for creating an upload session is documented as `Sites.ReadWrite.All`, with `Files.ReadWrite.All` listed as "Not available."** Delegated needs only `Files.ReadWrite`. If an app-only connector must write large files, that is a materially bigger ask than reading them — flag it before promising the feature. - Replacing the contents of a file carrying a sensitivity label is **not supported with app-only authentication**. **Downloads.** `@microsoft.graph.downloadUrl` is a pre-authenticated URL that needs no bearer token — convenient, and a liability. Microsoft documents it as short-lived (**about one hour**), not cacheable, and notes that **removing a user's permissions might not immediately invalidate it**. Never persist it as if it were a stable file location, and never hand it to a client you would not hand the file to. **Delta is how you sync, and the only correct way to enumerate.** Microsoft is explicit: paging `children` is not guaranteed to return every item if writes happen during enumeration — `delta` is. - `GET /drives/{id}/root/delta` with no token does the initial crawl; follow `@odata.nextLink` to the end, then keep the `@odata.deltaLink` and call it later for changes. `?token=latest` skips the crawl and returns only a cursor. - A stale or invalidated token returns **`HTTP 410 Gone`** with `resyncChangesApplyDifferences` or `resyncChangesUploadDifferences`, plus a `Location` header starting a fresh enumeration. Handle it; disconnected connectors hit it routinely. - The feed shows each item's **latest state, not each change**, and the same item can appear more than once — use the last occurrence. Deletions arrive as a `deleted` facet. - `?token={timestamp}` is supported **only on OneDrive for Business and SharePoint**, and Microsoft says to prefer a real `deltaLink` anyway. - Permission scanning has its own headers — `Prefer: hierarchicalsharing` and `Prefer: deltashowsharingchanges` (the latter requiring `deltashowremovedasdeleted, deltatraversepermissiongaps` alongside it). **Microsoft states that processing permissions correctly requires `Sites.FullControl.All`** — the single biggest permission in this neighbourhood, and not something to request casually for a file-list feature. **Change notifications instead of polling.** Drives support Graph subscriptions: on **OneDrive for Business** only the **root folder** (`/drives/{id}/root`, `/users/{id}/drive/root`); on **OneDrive personal** any folder. Maximum subscription lifetime is **42,300 minutes (under 30 days)** and must be renewed. Notifications say *something changed*, not what — follow with a delta call. Budget for latency: average under a minute, **maximum six hours**. Acknowledge with `202` before doing any work, or Graph retries and your connector sees phantom events. ## 6. Sharing links and permissions - `POST /items/{id}/createLink` creates a sharing link of `type` `view`, `edit` or `embed`, with `scope` `anonymous`, `organization` or `users`. **`organization` is OneDrive-for-Business/SharePoint only; `password` is OneDrive Personal only.** Anonymous links may be disabled outright by the tenant admin — handle the refusal. - `retainInheritedPermissions` defaults to `true`. Passing `false` on a first share **removes all existing permissions on the item**. Do not set it because an example did. - `createLink` returns the *existing* link if one of that type already exists for the calling application, so it is idempotent per app — not per user. - `POST /items/{id}/invite` sends invitations; `GET /items/{id}/permissions` lists grants. Delegated needs `Files.ReadWrite` (invite/link) or `Files.Read` (list); app-only needs `Files.ReadWrite.All` / `Files.Read.All`. - Every permission operation is priced at **5 resource units** and sharing APIs have their own cap (§7). Enumerating permissions across a drive is the most expensive thing a files connector can do. ## 7. Throttling — what differs from the SharePoint numbers OneDrive is served by SharePoint Online and shares its budget, so the SharePoint skill's economics apply unchanged: resource units of **1** (single-item read, delta-with-token, file download), **2** (multi-item query, create/update/ delete/upload) and **5** (any permission operation, including `$expand=permissions`); **1,250–6,250 units per minute** and **1.2M–6M per 24 hours** per app per tenant, scaling with license count; metering isolated per tenant for multitenant apps; `Retry-After` honored, `RateLimit` headers unsupported; `ISV|Company|App/Version` decoration; and no registering extra app IDs to dodge limits. Do not restate or re-derive those — read them there. What a OneDrive connector hits that a SharePoint one usually does not: - **Per-user limits, which a delegated connector shares with the human.** Roughly **3,000 requests per 5 minutes**, **50 GB ingress per hour** and **100 GB egress per hour** *per user*, aggregated across all apps. A user running the OneDrive sync client while your connector crawls their drive can throttle both. - **Per-app-per-tenant bandwidth caps of 400 GB ingress and 400 GB egress per hour**, license-independent. A backup or migration-shaped connector meets these before it meets the resource-unit ceiling. - **Specific sharing APIs: 300 calls per 5 minutes per app per tenant.** Sharing-link creation is not file I/O and is not budgeted like it. - **Tenant-wide resource units of 18,750–93,750 per 5 minutes**, shared across every app — your connector can be throttled by someone else's. - **IP-level throttling for repeatedly requesting OneDrive site collections that do not exist** (§4). - Microsoft names "using SharePoint and OneDrive as an intermediary service between Microsoft 365 and another repository" as an **unsupported use case** that may be throttled. If that is the connector's actual shape, say so to the customer rather than tuning retries. ## 8. Verify the OneDrive-specific parts Run the base skill's end-to-end check first (a *different* tenant, a non-admin user, `refresh_token` present, token claims decoded). Then: 1. `GET /me/drive` and confirm `driveType` matches the account kind you intended (`business` vs `personal`). 2. List one folder's children **and** read one file's content. Reading metadata proves less than it looks. 3. Run `delta` with no token, follow to the `deltaLink`, change one file, and call the `deltaLink` again. A connector that has never exercised a `deltaLink` has not been tested. 4. If uploads are in scope, upload one file over 250 MB through an upload session, with a deliberately interrupted fragment, and resume it. 5. If the design targets other users' drives, test a user who has **never opened OneDrive** — that is the 404 case. 6. If `Files.SelectedOperations.Selected` is the model, confirm access is **denied before** the item grant and allowed after. If it works before, your token carries something broader (§1). 7. If personal Microsoft accounts are in scope, test one — permission validity, sharing-link options and delta behavior all differ (§3). | Symptom | Cause | | --- | --- | | `404` on `/users/{id}/drive` for some users only | Their OneDrive was never provisioned; app-only will not provision it (§4) | | Auth succeeds, every file call is `403 accessDenied` | `Files.SelectedOperations.Selected` consented but no item grant stamped — or the grant is on a different item (§2) | | `401` on the upload `PUT`s, `200` on the session create | `Authorization` header sent to the pre-authenticated `uploadUrl` (§5) | | Large upload fails *after* the last fragment | Fragment size not a multiple of 320 KiB, or a name conflict at commit (§5) | | Delta returns `410 Gone` | Token stale/invalidated — follow the `Location` header and re-enumerate (§5) | | Sync duplicates a folder's contents after a rename | Items tracked by path instead of id (§4) | | Stored download URLs stop working after ~an hour | `@microsoft.graph.downloadUrl` is short-lived and uncacheable (§5) | | `organization`-scoped sharing link rejected | Consumer OneDrive has no organization scope (§3, §6) | | Item loses all its other permissions on first share | `retainInheritedPermissions: false` (§6) | | `429` on an IP that has never been busy | Repeated requests for OneDrive sites that do not exist (§4, §7) | | App-only large-file upload refused where reads work | App-only upload sessions are documented against `Sites.ReadWrite.All` (§5) | | Consent screen shows far more than "files" | `Sites.ReadWrite.All` / `Directory.Read.All` riding along with the file scopes (§1) | ## Hand off — the OneDrive additions The base skill's hand-off list applies. Add to the closing summary: which access model was chosen (delegated per-user, app folder, per-item Selected, or tenant-wide `.All`) **and the reason**; whether consumer Microsoft accounts are in scope and therefore which supported-account-type was set; for item-level grants, which items were granted, with which role, and who stamped them; and whether uploads over 250 MB are in scope, since that changes the app-only permission requirement. ## Stop and ask On top of the base skill's conditions, hand back to a human when: - Tenant-wide `Files.Read.All` / `Files.ReadWrite.All` in **application** mode is being proposed where a delegated per-user token would do. That is a security decision with a named blast radius: every employee's private OneDrive (§1, §2). - The target is really SharePoint sites or team libraries — that is `Sites.Selected` and the SharePoint skill, not this one (§2). - Consumer OneDrive is being added to a connector that already needs more than 30 permissions, or that relies on application permissions (§3). - A design depends on `Files.Read.Selected` / `Files.ReadWrite.Selected` — those are the legacy file-handler scopes, not per-item access; the plan needs rewriting (Platform state). - Permission scanning is in scope and nobody has weighed `Sites.FullControl.All` (§5). - Hundreds of per-item grants are planned without anyone checking unique-security-scope limits (§2). - The connector's shape is "move data between Microsoft 365 and another repository at volume" — Microsoft calls that an unsupported use case (§7). - Microsoft's own pages disagree with what you observe (the app-only drive tables are a known instance) — report the observation, do not pick a page to believe. ## References The base skill carries the shared Entra references (permissions reference, scopes, consent, secrets, AADSTS codes), and the SharePoint skill carries the site-grant and SharePoint-throttling references. These are the OneDrive- and Graph-files-specific ones; verified 2026-09-20, every link returned HTTP 200. - Working with files in Microsoft Graph (drive/driveItem overview, addressing) — https://learn.microsoft.com/en-us/graph/api/resources/onedrive?view=graph-rest-1.0 - Addressing driveItems (id vs path, percent-encoding) — https://learn.microsoft.com/en-us/graph/onedrive-addressing-driveitems - drive resource (`driveType`, personal vs business differences) — https://learn.microsoft.com/en-us/graph/api/resources/drive?view=graph-rest-1.0 - driveItem resource (facets, `@microsoft.graph.downloadUrl` lifetime) — https://learn.microsoft.com/en-us/graph/api/resources/driveitem?view=graph-rest-1.0 - Get drive (provisioning behavior, 404) — https://learn.microsoft.com/en-us/graph/api/drive-get?view=graph-rest-1.0 - Get a special folder (`approot`; delegated only) — https://learn.microsoft.com/en-us/graph/api/drive-get-specialfolder?view=graph-rest-1.0 - driveItem: delta (sync, resync errors, permission-scanning headers) — https://learn.microsoft.com/en-us/graph/api/driveitem-delta?view=graph-rest-1.0 - Upload or replace file contents (250 MB limit) — https://learn.microsoft.com/en-us/graph/api/driveitem-put-content?view=graph-rest-1.0 - driveItem: createUploadSession (fragments, resume, app-only permission) — https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession?view=graph-rest-1.0 - uploadSession resource — https://learn.microsoft.com/en-us/graph/api/resources/uploadsession?view=graph-rest-1.0 - Upload large files with the Microsoft Graph SDKs — https://learn.microsoft.com/en-us/graph/sdks/large-file-upload - Download driveItem content — https://learn.microsoft.com/en-us/graph/api/driveitem-get-content?view=graph-rest-1.0 - driveItem: createLink (link types, scopes, `retainInheritedPermissions`) — https://learn.microsoft.com/en-us/graph/api/driveitem-createlink?view=graph-rest-1.0 - driveItem: invite — https://learn.microsoft.com/en-us/graph/api/driveitem-invite?view=graph-rest-1.0 - Create a permission on a driveItem (`grantedToV2`) — https://learn.microsoft.com/en-us/graph/api/driveitem-post-permissions?view=graph-rest-1.0 - List driveItem permissions — https://learn.microsoft.com/en-us/graph/api/driveitem-list-permissions?view=graph-rest-1.0 - Access a shared item by sharing URL — https://learn.microsoft.com/en-us/graph/api/shares-get?view=graph-rest-1.0 - Selected permissions in OneDrive and SharePoint (file-level grants, inheritance) — https://learn.microsoft.com/en-us/graph/permissions-selected-overview - Best practices for discovering files and detecting changes at scale — https://learn.microsoft.com/en-us/onedrive/developer/rest-api/concepts/scan-guidance?view=odsp-graph-online - Change notifications overview (drive subscriptions, lifetime, latency) — https://learn.microsoft.com/en-us/graph/change-notifications-overview - OneDrive developer REST API landing page — https://learn.microsoft.com/en-us/onedrive/developer/rest-api/?view=odsp-graph-online ## microsoft-outlook-oauth-app URL: https://docs.unified.to/skills/microsoft-outlook-oauth-app/SKILL.md # Outlook (Microsoft Entra ID) OAuth2 App Registration Get a working OAuth2 client for Outlook mail and calendar in Exchange Online — an Entra ID app registration with the right `Mail.*` and `Calendars.*` Graph permissions — for a platform that connects many customers' Microsoft 365 tenants. Registering the app is the base skill's job and takes twenty minutes. **The delegated-vs-application decision is the whole negotiation**, and it is what this file is about. In Outlook there is no per-mailbox scope you can request at consent time: an application permission named `Mail.Read` means *every mailbox in the tenant, including the CEO's*, and the narrowing happens afterwards, in Exchange Online, by the customer's Exchange administrator. Knowing that — and knowing the exact cmdlets to hand them — is the difference between a connector a security review approves and one that gets escalated for a quarter. ## Built on: `microsoft-entra-app-registration` **Read the base skill first.** It is the source of truth for the Entra mechanics this one assumes: - **Supported account types** — single tenant vs multitenant vs personal Microsoft accounts, the permission ceilings each imposes, and `AADSTS50194` when a single-tenant app is called through `/common`. - **Redirect URIs** — the Web vs SPA vs public-client platform choice, exact case-sensitive matching, the count and length limits, and the Unified.to callback hosts. - **Delegated vs application permissions, and admin consent** — how the two modes differ, which permissions need an admin, and that Microsoft Graph *application* permissions require a **Privileged Role Administrator**, not an Application Administrator. Every app-only Outlook connector hits that one. - **Client secrets and certificates** — the 24-month cap, the **Value** shown exactly once, `AADSTS7000222` on expiry, add-then-delete rotation, and the per-cloud authority and Graph hosts (commercial, US Gov L4/L5, China). - **`offline_access`, publisher verification and the generic AADSTS symptom table** — no `Mail.*` or `Calendars.*` scope carries a refresh token of its own; `offline_access` is what gets you one, and an Outlook connector that stops working after an hour is almost always missing it. The base also holds the inputs to collect, the run order, tenant ownership, the credential hand-off and the generic stop-and-ask conditions. **A reader who loads only this file has none of that** — no portal path, no account-type decision, no secret handling, no consent troubleshooting — and will produce a half-registered app. Everything below is Outlook-specific. ## Extra inputs to collect On top of the base skill's batch: | Input | Notes | | --- | --- | | **Delegated (each user authorizes their own mailbox) or application (app-only, tenant-wide)?** | Blocking product decision — read §1 and §2 first | | **If application: will the customer scope it, and with what?** | RBAC for Applications, or a legacy application access policy (§2). Needs an Exchange Administrator per customer | | **Which mailboxes are actually in scope?** | User mailboxes only, or also shared / room / equipment / Microsoft 365 group mailboxes (§3) — each behaves differently | | **Read-only, read/write, or send?** | `Mail.Send` is a separate permission that neither `Mail.Read` nor `Mail.ReadWrite` implies (§1) | | **Will the connector subscribe to change notifications?** | Changes the minimum permission and adds a renewal treadmill (§5) | | **Is anything on the customer side still on EWS?** | It has weeks, not years (Platform state) | Two base-skill choices have Outlook-specific answers. **Supported account types**: Exchange Online mailboxes live in work/school tenants, and `Mail.Read.Shared`, `Mail.ReadWrite.Shared` and `Mail.Send.Shared` are documented as *valid only for work or school accounts* — so the personal-account audiences buy you Outlook.com and cost you the 30-permission ceiling and the shared-mailbox family. Pick **Multiple Entra ID tenants** unless consumer Outlook.com is genuinely in scope. And on **reuse vs new registration**: a new client ID is worse here than for most vendors, because a scoped app-only deployment has customer-side state your platform cannot recreate — every RBAC role assignment and every application access policy is keyed to the *old* client ID and must be re-created by each customer's Exchange admin. ## Platform state (verified 2026-09-20 — re-verify before trusting) - **EWS is being switched off right now.** Microsoft begins globally disabling Exchange Web Services in Exchange Online in **October 2026**, and EWS is **fully disabled in April 2027**. Any tenant whose `EWSEnabled` is still `Null` on **2026-10-01** has it flipped to `False` as the rollout reaches them, blocking EWS for every app in that tenant; `EWSAllowedAppIDs` is the tenant-level allow list an Exchange admin can use to keep named app IDs working through the phased period. This covers Microsoft's own apps too, and it applies to **Exchange Online only** — EWS in on-premises Exchange Server is not retired. If a plan in front of you still touches EWS, that is the finding. - **The Outlook REST API is gone.** The `https://outlook.office.com/api` v2.0 and beta endpoints were decommissioned on **2024-03-31**, returning HTTP 410 for a 60-day grace window and nothing after. Microsoft Graph is the only supported surface. Some Outlook developer pages still read as if the direct endpoint were an option for features Graph lacks — it is not; treat any such wording as stale. - **Application access policies are superseded.** Microsoft's guidance is now **RBAC for Applications in Exchange Online**, and the `New-ApplicationAccessPolicy` reference carries an explicit notice: *"App Access Policies are replaced by Role Based Access Control for Applications… Don't create new App Access Policies as these policies will eventually require migration."* Existing policies still work, and the Graph permissions reference still calls them "application access policy" and links to an article that now **redirects** to the Exchange RBAC page. No retirement date has been published. §2 covers both. - **The `.All` suffix does not mean what it means elsewhere in Graph.** For mail and calendar, the tenant-wide application permission is the *bare* name: application `Mail.Read` is "Read mail in all mailboxes" and application `Calendars.Read` is "Read events of all calendars". Worse, `Calendars.Read.All` and `Calendars.ReadWrite.All` are **not** tenant-wide calendar permissions at all — they are documented as "Read (and write) all users' **work hours and locations**". Requesting `Calendars.Read.All` expecting SharePoint-style breadth gets you a different, narrower feature and a connector that reads nothing. §1. - **`Mail.ReadBasic` now exists in both modes**, and there is *also* an application-only `Mail.ReadBasic.All` with an identical published description. Either works app-only; prefer whichever the customer's admin sees in their consent list and record which one you actually requested. - **The per-mailbox Outlook throttles are fixed service limits** — 10,000 requests per 10 minutes, four concurrent requests, 150 MB of upload per 5 minutes, per app-ID-and-mailbox pair. They are not adjustable and no support ticket raises them. §4. If the Entra admin center, Exchange Online PowerShell or Graph does not behave like this, stop and report what you actually see. ## 1. The `Mail.*` and `Calendars.*` families Outlook access is requested as Microsoft Graph permissions. The two columns are genuinely different permissions with the same name, and the gap between them is the whole security story: | Permission | Delegated (signed-in user) | Application (app-only) | | --- | --- | --- | | `Mail.ReadBasic` | The user's mail, minus `body`, `previewBody`, attachments, extended properties | **Every mailbox**, same field exclusions | | `Mail.ReadBasic.All` | — | **Every mailbox**, same field exclusions | | `Mail.Read` | The user's mailbox | **Every mailbox in the tenant** | | `Mail.ReadWrite` | Create/read/update/delete the user's mail — **not** send | **Every mailbox** — not send | | `Mail.Send` | Send as the signed-in user | **Send as any user in the tenant** | | `Mail.ReadBasic.Shared` / `Mail.Read.Shared` / `Mail.ReadWrite.Shared` / `Mail.Send.Shared` | Mail the user can already reach, including shared and delegated mailboxes (§3) | **Not available** | | `Calendars.ReadBasic` | The user's events, minus body, attachments, extensions | — (`Calendars.ReadBasic.All` is the app-only form) | | `Calendars.Read` | The user's calendars | **Every calendar in the tenant** | | `Calendars.ReadWrite` | Full access to the user's calendars | **Every calendar in the tenant** | | `Calendars.Read.Shared` / `Calendars.ReadWrite.Shared` | Delegate and shared calendars the user can access | **Not available** | | `Calendars.Read.All` / `Calendars.ReadWrite.All` | **Work hours and locations only** — not calendar contents | Same, tenant-wide | | `MailboxSettings.Read` / `.ReadWrite` | The user's mailbox settings (automatic replies, time zone, language) | Every user's mailbox settings | | `Place.Read.All` | Conference rooms and room lists — **admin consent required even delegated** | Same, admin consent required | Three things that cost time: - **Every delegated permission in that table is user-consentable**; every application permission requires an admin — and per the base skill, an admin who holds **Privileged Role Administrator**, since these are Graph app roles. `Place.Read.All` is the odd one out: it needs admin consent in *both* modes. - **`Mail.ReadWrite` does not include send, and `Mail.Send` does not include read.** A connector that drafts and sends needs both. Conversely `Mail.Send` alone is enough to send *and* save a copy to Sent Items without `Mail.ReadWrite`, which is a genuinely narrower ask when sending is all you do. - **Mixing defeats the point.** Any scope present in the token is honored, so requesting `Mail.Read` alongside `Mail.ReadWrite` does not make the app safer — it makes the consent screen longer for no capability, and a reviewer reads the widest scope in the list. Requesting a delegated `Mail.Read` *and* an application `Mail.Read` makes the app tenant-wide regardless of how the connect flow is described. Identity-only scopes (`openid`, `profile`, `email`, `offline_access`, `User.Read`) carry no mailbox access at all. **Product fact — as of 2026-09-20.** Unified.to's Outlook connector offers two connection styles, and the security conversation is different for each: - Its **delegated (user-authorizes) OAuth2 flow** authorizes through the `/common` endpoint, sends `prompt=select_account`, and requests scopes **per unified object**, always including `openid`, `email`, `profile` and **`offline_access`** (so refresh tokens are expected on every non-login flow): `Mail.ReadBasic` to list mail folders; `Mail.Read` to read messages; `Mail.Read` + `Mail.Send` + `Mail.ReadWrite` to write and send them; `Calendars.Read` for calendars, events and free/busy reads and `Calendars.ReadWrite` for calendar and event writes; and for its HR/people objects the tenant-directory scopes `Directory.Read.All`, `User.Read`, `User.Read.All` — escalating to `Directory.ReadWrite.All` and `User.ReadWrite.All` for writes. There is also a login-only flow that requests just `openid`, `email`, `profile` and `User.Read`. - Its **app-only (client-credentials) connection** takes a **client ID, client secret and tenant ID** supplied by the customer, exchanges them for a token with the literal `https://graph.microsoft.com/.default`, and reads a specific mailbox by passing the target user on the request. It therefore carries **whatever application permissions the customer's own Entra app was granted** — the connector requests nothing and narrows nothing. All of the scoping in §2 is the customer's to do. Three reactions to expect from that, and to get ahead of. The mail-write set requests `Mail.Read` *and* `Mail.ReadWrite`, which is redundant — `Mail.ReadWrite` supersedes it — and reviewers notice. The directory scopes are the escalation trigger: a customer who accepted a mailbox-scoped connector will stop at `Directory.ReadWrite.All`, which has nothing to do with Outlook, so only enable the objects that need it. And because the delegated flow uses `/common`, personal Microsoft accounts can reach the consent screen even though the directory scopes are meaningless for them. **Confirm the current scope set with the connector's owner before you register anything**; scope lists change and this one is a snapshot. ## 2. Application permissions reach every mailbox — and how to narrow that This is the section that makes an app-only Outlook connector approvable. Understand it before choosing a permission. Consenting application `Mail.Read` grants read access to **every mailbox in the tenant, immediately, with no further step**. There is no `Mail.Selected`. The permission does not become narrower by only calling one mailbox, and a customer's security team will read the grant, not your code. Narrowing is done **after consent, in Exchange Online, by an Exchange administrator**, and it is out-of-band work your platform cannot self-serve. There are two mechanisms. Offer the first; be able to read the second, because most existing tenants run it. ### RBAC for Applications (current, recommended) Exchange Online's Role Based Access Control for Applications assigns an *application role* to a service principal over a *resource scope* — a management scope (a recipient filter over mailbox properties) or a Microsoft Entra administrative unit. Requires membership of **Organization Management** in Exchange Online (Exchange Administrator in Entra) to assign. ```powershell # 1. Point Exchange at the app's service principal (get both IDs from Enterprise applications, not App registrations) New-ServicePrincipal -AppId -ObjectId -DisplayName "" # 2. Define which mailboxes are in scope New-ManagementScope -Name "Sales mailboxes" -RecipientRestrictionFilter "CustomAttribute1 -eq 'sales'" # 3. Grant one role over that scope New-ManagementRoleAssignment -App -Role "Application Mail.Read" ` -CustomResourceScope "Sales mailboxes" # 4. Prove it Test-ServicePrincipalAuthorization -Identity "" -Resource | Format-Table ``` - **The roles are named `Application `** — `Application Mail.Read`, `Application Mail.ReadBasic`, `Application Mail.ReadWrite`, `Application Mail.Send`, `Application MailboxSettings.Read`/`.ReadWrite`, `Application Calendars.Read`, `Application Calendars.ReadWrite`, `Application Contacts.Read`/`.ReadWrite`, plus bundles (`Application Mail Full Access`, `Application Exchange Full Access`) and `Application EWS.AccessAsApp`. Pick the weakest that works; the bundles exist to be refused. - **`-RecipientAdministrativeUnitScope `** replaces `-CustomResourceScope` when the customer already organizes mailboxes into administrative units. Microsoft recommends the native AU parameter over a scope that merely points at one. - **The trap that makes this pointless:** RBAC assignments are **a union with** the Entra grant, not a filter on it. If the app still holds an unscoped `Mail.Read` in Entra ID, a resource-scoped `Mail.Read` in Exchange adds nothing and the app still reads every mailbox. Microsoft says so explicitly. **The Entra application permission must be removed** for the RBAC scope to mean anything — which means the run order is: consent, verify, then *un*-consent. Say that out loud to the admin; it is counter-intuitive and it is where scoping silently fails. - **Changes are cached for 30 minutes to 2 hours** depending on how recently the app called. `Test-ServicePrincipalAuthorization` bypasses the cache; a live call does not. Do not conclude a grant failed inside that window. - Other limits worth knowing: **10,000 apps per organization**; exclusive management scopes do not restrict app access; application roles cannot be copied, derived, or placed in role groups; and **Autodiscover does not work** when using RBAC application roles. ### Application access policies (legacy, still everywhere) ```powershell New-ApplicationAccessPolicy -AccessRight RestrictAccess -AppId "" ` -PolicyScopeGroupId "connector-mailboxes@contoso.com" ` -Description " " ``` - **`RestrictAccess`** limits the app to the members of the named group; **`DenyAccess`** does the opposite, and a `DenyAccess` policy always wins over a `RestrictAccess` policy for the same app-and-mailbox pair. **If no policy matches the app at all, access is granted** — a policy that was never created is not a deny. - **`-PolicyScopeGroupId` only accepts security principals**: user mailboxes, mail users, and **mail-enabled security groups**. It explicitly rejects distribution groups, dynamic distribution groups, Microsoft 365 groups, mail contacts, mail-enabled public folders, discovery mailboxes, **room and equipment mailboxes, and shared mailboxes**. To scope to a shared or room mailbox you must add it as a **member of a mail-enabled security group** and point the policy at the group. This is the single most common reason a run stalls. - Unlike RBAC, these policies *do* constrain the permissions granted in Entra ID — that is exactly what they are for, and why they still exist. - Capacity is measured in bytes, not policies: roughly **300 policies** if each description is a single space character, about 100 otherwise, then `The total size of App Access Policies exceeded the limit`. - They apply to Graph **and** EWS, which is why they show up in old runbooks. Do not create new ones; migrate the scoping group into a management scope with a `MemberOfGroup` filter on the group's distinguished name, assign the RBAC role, then remove the Entra consent and the policy. Nested group members are out of scope either way. ### Delegated is the escape hatch If each end user authorizes their own mailbox, none of the above is needed: a delegated token can never exceed what that user can already do, and there is no tenant-wide grant to defend. That is Microsoft's own recommendation and it is the fastest path through a security review. Use application permissions only when there is genuinely no user — scheduled sync of a shared mailbox, a room-booking integration, ingestion from mailboxes nobody signs into. ## 3. Shared, delegated, room and group mailboxes Which mailbox kinds a connector can reach depends entirely on which permission model it uses. | Mailbox kind | Delegated | Application | | --- | --- | --- | | The signed-in user's own | `Mail.*` / `Calendars.*` | Reachable as `/users/{id}` | | **Shared mailbox** (no sign-in, usually unlicensed) | Only via a licensed user who holds Full Access, using the `.Shared` permissions | Reachable as `/users/{shared-mailbox-id}`, like any other | | **Delegated mailbox / shared folder** | `.Shared` permissions, addressed as `/users/{owner}/mailFolders(...)` | Reachable directly | | **Room / equipment mailbox** | `Calendars.Read.Shared` where the room's calendar is shared; `Place.Read.All` for room and room-list metadata (admin consent) | `Calendars.Read` / `Calendars.ReadWrite`; the documented RBAC example is exactly a room-booking app scoped by region | | **Microsoft 365 group mailbox** | Group conversation/thread/post APIs, not the `Mail.*` family | Same; generic group-mailbox item CRUD is confirmed as **never** coming to Graph | The traps: - **A shared mailbox cannot sign in**, so it can never produce a delegated token of its own. The delegated path requires a *licensed* user with Full Access to it, and the app inherits that user's reach — if their access is removed, the connection dies with a `403` and nothing in your logs says why. - **`.Shared` is delegated-only and work/school-only.** There is no `Mail.Read.Shared` application permission, and it is not valid for personal Microsoft accounts. - **`.Shared` permissions cannot subscribe to change notifications.** Microsoft's guidance is blunt: to subscribe to messages in a shared, delegated, or any other user's folder, use the **application** permission `Mail.Read`. So a connector that wants webhooks on a shared mailbox is pushed from a user-consentable delegated permission to a tenant-wide app-only one — which is precisely when §2 stops being optional. §5. - **Addressing is `/users/{id | userPrincipalName}/...`**, not `/me/...`, for anything but the signed-in user's own mailbox. If the owner has not shared or delegated the folder, the same GET returns an error rather than an empty list, so "it returns 403 for one user and works for another" is a sharing problem, not a scope problem. - **Room and shared mailboxes cannot be named directly in an application access policy** (see §2) — they go in a mail-enabled security group first. ## 4. Throttling and mailbox concurrency This is where Outlook connectors fail in production rather than at registration. - **The limits are per app ID *and* mailbox pair**, not per tenant and not per app. Exceeding them against one mailbox does not affect another. Per pair, on both `v1.0` and `beta`: **10,000 API requests per 10-minute period**, **four concurrent requests**, and **150 MB of upload (PATCH/POST/PUT) per 5-minute period**. Treat all three as fixed service limits. - **Four concurrent is the number that bites.** A parallel backfill over one large mailbox hits `ApplicationThrottled` / `MailboxConcurrency` long before it hits the 10,000-request budget. Bound your worker pool per mailbox at four, not per tenant. - **JSON batching does not buy concurrency.** Microsoft Graph sends the Outlook service at most four requests from an unordered batch at a time regardless of which mailboxes they target; a batch using `dependsOn` is executed strictly sequentially. Batching saves round trips, not quota. - **Above all of this sits the global Graph ceiling of 130,000 requests per 10 seconds per app across all tenants.** A multitenant connector can trip that without any single customer noticing anything. - Throttled calls return **HTTP 429** with **`Retry-After`**. Honor it; retrying early still counts against quota. - **Use delta queries for incremental mail and calendar sync** rather than repeated `$filter` scans, and `$select` only the fields you map — the message body is the expensive part of every page. - **Sending has a separate, non-Graph budget.** Exchange Online enforces a per-mailbox **recipient rate limit of 10,000 recipients per 24 hours** and a **message rate limit of 30 messages per minute**, plus a tenant-wide external-recipient limit (TERRL) that scales with license count and is capped at 5,000/day on trial tenants. These are hard service limits that cannot be raised, they apply to mail sent through Graph, and Microsoft states plainly that Exchange Online is not suited to bulk mailing. A connector that sends on customers' behalf should surface these, not absorb them. ## 5. Change notifications — only if the connector subscribes Webhooks change which permission you must request, so settle this before the registration, not after. - **Maximum subscription lifetime for Outlook `message`, `event` and `contact` is 10,080 minutes (under seven days)** — and only **1,440 minutes (under one day)** for rich notifications that include resource data. Anything under 45 minutes is silently rounded up to 45. Renewal is your job; there is no auto-renew, and an expired subscription is simply gone. - Set a **`lifecycleNotificationUrl`** and handle `subscriptionRemoved`, `reauthorizationRequired` and `missed`. Reauthorization is how Graph tells you a token behind a long-lived subscription needs refreshing — which again depends on `offline_access` having been requested at registration. - The notification endpoint must be **public HTTPS**. Graph expects a `2xx` within **3 seconds** or it retries for up to **4 hours** (retries get a 10-second timeout), and subscription creation is gated on echoing back a validation token in plain text. - `includeResourceData: true` requires an **encryption certificate** on the subscription — a second credential with its own expiry, on top of the client secret from the base skill. Diary it the same way. - **Delegated `.Shared` permissions cannot subscribe at all** (§3), and app-only mail subscriptions need application `Mail.Read`. If webhooks are a product requirement on shared mailboxes, the connector is an app-only connector and §2 is mandatory. ## 6. Verify the Outlook-specific parts Run the base skill's end-to-end check first (second tenant, refresh token present, token claim inspection). Then: 1. **Read one message and one event**, not just `/me`. `GET /me/messages?$top=1` and `GET /me/events?$top=1` delegated; `GET /users/{upn}/messages?$top=1` app-only. 2. **For app-only, prove the scoping works in both directions**: a mailbox inside the RBAC scope or policy group succeeds, and one outside it returns `403`. If the out-of-scope mailbox succeeds, the unscoped Entra grant is still in place (§2) — that is the failure this whole file exists to prevent. Allow for the 30-minute-to-2-hour cache, and use `Test-ServicePrincipalAuthorization` to check the intent without it. 3. **If sending is in scope**, send one real message and confirm the copy lands in Sent Items. 4. **If webhooks are in scope**, create a subscription, confirm the validation handshake, then renew it once before expiry rather than assuming renewal works. | Symptom | Cause | | --- | --- | | App-only calls succeed against mailboxes the customer thought were out of scope | The unscoped Entra application permission is still granted; RBAC is a union, not a filter (§2) | | `New-ApplicationAccessPolicy` rejects the mailbox you want to scope to | Shared, room and equipment mailboxes aren't valid security principals — put them in a mail-enabled security group (§2) | | Scoping change made, behavior unchanged for an hour or two | Exchange caches app permissions for 30 minutes to 2 hours (§2) | | Delegated calls to a shared mailbox return `403` for one user only | That user lost Full Access on the mailbox; `.Shared` inherits the user's reach (§3) | | Subscription creation fails on a shared or delegated folder | `.Shared` permissions can't subscribe; needs application `Mail.Read` (§3, §5) | | `Calendars.Read.All` consented, connector still reads no events | `Calendars.Read.All` is work-hours-and-locations, not calendar contents — the tenant-wide read is application `Calendars.Read` (§1) | | Messages read fine, `sendMail` returns `ErrorAccessDenied` | `Mail.ReadWrite` doesn't imply `Mail.Send` (§1) | | Message bodies and attachments come back empty or missing | `Mail.ReadBasic` / `Calendars.ReadBasic` exclude body, previewBody, attachments and extended properties by design (§1) | | `429` with `MailboxConcurrency` / `ApplicationThrottled` on one mailbox only | The four-concurrent-request per app-and-mailbox limit; batching won't help (§4) | | Bulk send stalls or is deferred after a few thousand messages | Exchange Online recipient/message rate limits, not Graph throttling — not raisable (§4) | | Webhooks stop after a week with no error | Outlook subscriptions max out under seven days and must be renewed (§5) | | `HTTP 410 Gone`, or no response at all, from `outlook.office.com/api` | Outlook REST v2.0/beta, decommissioned 2024-03-31 (Platform state) | | EWS calls start failing tenant-wide with no code change | `EWSEnabled` flipped to `False` in the October 2026 rollout (Platform state) | ## Hand off — the Outlook additions The base skill's hand-off list applies. Add to the closing summary: whether the connector is **delegated or application**, and for application — **which mailboxes are in scope, by what mechanism (RBAC management scope / administrative unit / application access policy), who configured it, and confirmation that the unscoped Entra grant was removed**. Also: the exact `Mail.*` / `Calendars.*` strings requested; whether `Mail.Send` is included; whether change-notification subscriptions exist, their renewal cadence and the **encryption certificate's expiry** if resource data is included; and any EWS dependency still outstanding against the October 2026 / April 2027 dates. ## Stop and ask On top of the base skill's conditions, hand back to a human when: - The choice between delegated and application permissions has not been made. It is a product and security decision with an onboarding cost attached, not a config detail — and for a mail connector it is *the* decision. - An application permission is being requested and nobody has agreed who will scope it in Exchange Online, or no one in the room holds Exchange Administrator / Organization Management. - Removing the unscoped Entra grant is required for scoping to take effect (§2) and the app is already live for that customer — there is a window where access changes shape, and it needs a plan. - `Application Mail Full Access` or `Application Exchange Full Access` is proposed, or `Mail.Send` as an application permission: "send as any user in the tenant" is a phishing primitive and reviewers treat it as one. - The design depends on EWS, on `outlook.office.com/api`, on generic Microsoft 365 group or public-folder mailbox CRUD, or on discovery-mailbox access. Those are retired or confirmed as never coming to Graph; the plan needs rewriting, not a workaround. - A customer asks for the per-mailbox throttles or the Exchange sending limits to be raised. They cannot be. - New application access policies are being created rather than RBAC role assignments, and nobody has weighed the eventual migration. - Personal Microsoft accounts (Outlook.com) are in scope alongside work/school mailboxes — that changes the account type, the permission ceiling, and removes the entire `.Shared` family (§1 and the base skill's §3). ## References The base skill carries the shared Entra references. These are the Outlook-specific ones; verified 2026-09-20, every link returned HTTP 200. - Microsoft Graph permissions reference (`Mail.*`, `Calendars.*`, `MailboxSettings.*`, `Place.*`) — https://learn.microsoft.com/en-us/graph/permissions-reference - Role Based Access Control for Applications in Exchange Online — https://learn.microsoft.com/en-us/exchange/permissions-exo/application-rbac - `New-ApplicationAccessPolicy` (legacy scoping, with the replacement notice) — https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/new-applicationaccesspolicy - `Test-ServicePrincipalAuthorization` — https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/test-serviceprincipalauthorization - Outlook mail API overview — https://learn.microsoft.com/en-us/graph/api/resources/mail-api-overview - Outlook calendar overview — https://learn.microsoft.com/en-us/graph/outlook-calendar-concept-overview - Get messages in a shared or delegated folder — https://learn.microsoft.com/en-us/graph/outlook-share-messages-folders - `place` resource (rooms and room lists) — https://learn.microsoft.com/en-us/graph/api/resources/place - Send mail (`sendMail`) — https://learn.microsoft.com/en-us/graph/api/user-sendmail - Get incremental changes to messages (delta) — https://learn.microsoft.com/en-us/graph/delta-query-messages - Microsoft Graph service-specific throttling limits (Outlook service limits) — https://learn.microsoft.com/en-us/graph/throttling-limits - Microsoft Graph throttling guidance — https://learn.microsoft.com/en-us/graph/throttling - Exchange Online limits (recipient and message rate limits) — https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange-online-service-description/exchange-online-limits - Troubleshoot outbound sending limits — https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-sending-limits-troubleshoot - Change notifications overview — https://learn.microsoft.com/en-us/graph/change-notifications-overview - `subscription` resource (maximum lifetimes per resource) — https://learn.microsoft.com/en-us/graph/api/resources/subscription - Change notification delivery via webhooks (validation, retries) — https://learn.microsoft.com/en-us/graph/change-notifications-delivery-webhooks - Reduce missed subscriptions and notifications (lifecycle events) — https://learn.microsoft.com/en-us/graph/change-notifications-lifecycle-events - Deprecation of Exchange Web Services in Exchange Online (timeline) — https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-ews-exchange-online - Exchange Online EWS, Your Time is Almost Up (disablement process) — https://techcommunity.microsoft.com/blog/exchange/exchange-online-ews-your-time-is-almost-up/4492361 - EWS to Microsoft Graph API mapping — https://learn.microsoft.com/en-us/graph/migrate-exchange-web-services-api-mapping - EWS usage reports — https://learn.microsoft.com/en-us/microsoft-365/admin/activity-reports/ews-usage - Outlook REST API v2.0 and beta endpoint deprecation — https://devblogs.microsoft.com/microsoft365dev/outlook-rest-api-v2-0-deprecation-notice/ ## microsoft-sharepoint-oauth-app URL: https://docs.unified.to/skills/microsoft-sharepoint-oauth-app/SKILL.md # SharePoint (Microsoft Entra ID) OAuth2 App Registration Get a working OAuth2 client for SharePoint Online — an Entra ID app registration with the right `Sites.*` permissions — for a platform that connects many customers' SharePoint tenants. Registering the app is the base skill's job and takes twenty minutes. **The permission you request is the whole negotiation**, and it is what this file is about. SharePoint's tenant-wide scopes grant your app every site collection in the tenant, including every user's OneDrive, and customer security teams increasingly refuse them outright. The least-privilege alternative, `Sites.Selected`, grants nothing on consent and then needs an out-of-band step from every customer's admin. That trade decides whether a customer can say yes, and no amount of portal work substitutes for it. ## Built on: `microsoft-entra-app-registration` **Read the base skill first.** It is the source of truth for the Entra mechanics this one assumes: - **Supported account types** — single tenant vs multitenant vs personal Microsoft accounts, the permission ceilings each imposes, and `AADSTS50194` when a single-tenant app is called through `/common`. - **Redirect URIs** — the Web vs SPA vs public-client platform choice (SPA cannot carry a client secret and forces PKCE), exact case-sensitive matching, the count and length limits, and the Unified.to callback hosts. - **Delegated vs application permissions, and admin consent** — how the two modes differ, which permissions need an admin, and that Microsoft Graph *application* permissions require a **Privileged Role Administrator**, not an Application Administrator. That one stalls SharePoint runs more than any other. - **Client secrets and certificates** — the 24-month cap, the **Value** shown exactly once, `AADSTS7000222` on expiry, add-then-delete rotation, and the per-cloud authority and Graph hosts (commercial, US Gov L4/L5, China). - **`offline_access`, publisher verification and the generic AADSTS symptom table** — `Sites.*` scopes carry no refresh token of their own; `offline_access` is what gets you one. The base also holds the inputs to collect, the run order, tenant ownership, the credential hand-off and the generic stop-and-ask conditions. Everything below is SharePoint-specific. ## Extra inputs to collect On top of the base skill's batch: | Input | Notes | | --- | --- | | **`Sites.Selected` or tenant-wide `Sites.*.All`?** | Blocking product decision — read §2 first | | **If `Sites.Selected`: which site(s), and who will stamp the grant?** | Needs a SharePoint Administrator per customer (§2) | | **Read-only, or read/write?** | Decides the role on the per-site grant and the `Sites.*` verb (§1, §2) | Two base-skill choices have SharePoint-specific answers. **Supported account types**: SharePoint Online sites belong to work/school tenants, so the personal-account audiences buy nothing and cost something — pick **Multiple Entra ID tenants**. And on **reuse vs new registration**: a new client ID is worse here than for most vendors, because every `Sites.Selected` grant is customer-side state your platform cannot recreate. Each one must be re-stamped, per site, by each customer's admin. ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Azure ACS for SharePoint Online is retired.** Deprecated 2023-11-27; stopped working for new tenants 2024-11-01; **fully retired April 2, 2026**. Microsoft states plainly there is no option to extend ACS with SharePoint Online beyond that date, and it covers all environments including Government and DoD clouds. The replacement for granting an application access to SharePoint is **Microsoft Entra ID**. Some Microsoft pages still carry pre-retirement future-tense wording ("will stop working from April 2nd 2026") because the articles have not been re-edited — the date has passed; treat ACS as gone. - **SharePoint Add-Ins are retired on the same date.** No new marketplace listings since 2024-03-01, no marketplace acquisition since 2024-07-01, stopped working for all tenants 2026-04-02. The replacement for in-page extensibility is SharePoint Framework (SPFx); the replacement for a provider-hosted add-in's auth is an Entra ID app. SharePoint **on-premises** add-ins are *not* retired and keep working from an on-prem app catalog. - **Remote event receivers** registered against an Entra application get a longer runway — they work until **July 1, 2027**. Ones registered through ACS died with ACS. For new work, use SharePoint webhooks or Microsoft Graph change notifications. - **CSOM, JSOM and the SharePoint REST API are not retired**, but Microsoft recommends Microsoft Graph first: Graph costs fewer throttling resource units for the same work, and CSOM/REST carry extra internal limits (§4). - **`*.Selected` scopes now cover more than sites and support both modes.** Beyond `Sites.Selected` there are `Lists.SelectedOperations.Selected`, `ListItems.SelectedOperations.Selected` and `Files.SelectedOperations.Selected`, and as documented all Selected scopes support **delegated and application** modes. The newer tuple naming is cosmetic — no functional difference from the `Sites.Selected` form. If the Entra admin center or Graph does not behave like this, stop and report what you actually see. ## 1. The `Sites.*` family SharePoint access is requested as Microsoft Graph permissions. The site-level permissions, in the order customer admins will tolerate them: | Permission | What it reaches | | --- | --- | | **`Sites.Selected`** | **Nothing until an admin grants a specific site** — see §2 | | `Sites.Read.All` | Read **every** site collection in the tenant, including every user's OneDrive | | `Sites.ReadWrite.All` | Read/write every site collection | | `Sites.Manage.All` | The above plus list/library management | | `Sites.FullControl.All` | Full control of every site collection — also what it takes to *stamp* a `Sites.Selected` grant (§2) | | `Files.Read.All` / `Files.ReadWrite.All` | Files across sites and OneDrive, independent of the `Sites.*` family | Identity-only scopes (`openid`, `profile`, `email`, `offline_access`, `User.Read`) carry no SharePoint access. **Mixing defeats the point.** Any scope present in the token is honored, so requesting `Sites.Selected` *alongside* `Sites.Read.All` or `Files.Read.All` does not produce a least-privilege app — it produces a tenant-wide app with a least-privilege label, and a customer security review will find it. If you go the `Sites.Selected` route, the broad scopes must come out of the request. **Product fact — as of 2026-09-20.** Unified.to's SharePoint connector requests two different things depending on how the connection is made: - Its **delegated (user-authorizes) OAuth2 flow** requests the **broad tenant-wide** kind — per object: `sites.read.all` for reading SharePoint sites and pages; `files.read.all` + `sites.read.all` for reading files, and `files.readwrite.all` + `sites.readwrite.all` for writing them; plus `Directory.Read.All`, `User.Read.All` and `Group.Read.All` (and the `ReadWrite` equivalents) where it reads directory users and groups — always with `openid`, `profile`, `email`, `User.Read` and `offline_access`, and a login-only flow that requests just `openid`, `User.Read`, `email`, `profile`. It sends `prompt=select_account` on authorize and ships Unified.to's own shared client credentials by default. - Its **app-only (client credentials) connection** is the least-privilege kind: the customer registers **their own** Entra app holding only **`Sites.Selected`**, gives admin consent, has an admin stamp a per-site grant, and supplies a client ID, client secret, tenant ID and a **single Graph site id** that anchors all file access to that one site. That split predicts the reaction you will get. Expect the delegated scope list — `Sites.ReadWrite.All` and `Directory.Read.All` in particular — to be refused or escalated by any customer with a security review, and expect the app-only path to be the one that gets approved. **Confirm the current scope set with the connector's owner before you register anything**; scope lists change and this one is a snapshot. ## 2. `Sites.Selected` — the least-privilege model This is the section that makes a SharePoint connector approvable. Understand it before choosing a permission. Consenting `Sites.Selected` grants **zero** access. Access exists only when all **three** of these are true, and missing any one produces the same opaque `403 accessDenied`: 1. The app is **consented** in Entra ID for the `Sites.Selected` permission (application or delegated). Consent is also what creates the service principal in the customer's tenant — until it exists, nothing customer-side can reference your app at all. 2. An admin has **granted the app a role on a specific site** via `POST /sites/{siteId}/permissions`. 3. The **token actually carries** the `Sites.Selected` scope on the call. ### Stamping the grant ```http POST https://graph.microsoft.com/v1.0/sites/{siteId}/permissions Content-Type: application/json { "roles": ["write"], "grantedToIdentities": [{ "application": { "id": "", "displayName": "" } }] } ``` - **Roles**: `read`, `write`, `owner`, `fullcontrol`. Pick the weakest that works — `read` for a read-only connector, `write` for read/write file sync. - **Who can call it**: the grant on a *site* requires **`Sites.FullControl.All`**, and in delegated workflows the signed-in user must hold **SharePoint Administrator or higher**. Microsoft's own note on why the bar is that high: a `Sites.Selected` grant can itself confer full control of a site collection. - **Where it is done**: this is a Graph API call, not a checkbox in a portal. In practice the customer's admin runs it from Graph Explorer or Microsoft Graph PowerShell (`New-MgSitePermission -SiteId $siteId -BodyParameter $params`). Your run should hand them the exact request body with the client ID filled in. - **It is per customer, per site, out of band.** Your platform cannot self-serve this. Design the connect flow to say so, and expect onboarding to include a step only the customer's admin can do. - **Managing it later**: `GET /sites/{siteId}/permissions` lists grants, `DELETE /sites/{siteId}/permissions/{id}` revokes one. Revoking the Entra consent kills access to every granted site at once; deleting one permission kills only that site. ### Below the site level The same pattern works at list, item and file level with `Lists.SelectedOperations.Selected`, `ListItems.SelectedOperations.Selected` and `Files.SelectedOperations.Selected`, granted via `POST /sites/{siteId}/lists/{listId}/permissions` and the listItem/driveItem equivalents. Two consequences to raise before anyone designs around them: - Granting below the site level **breaks SharePoint permission inheritance** on that resource, which counts against the tenant's unique-security-scope limits per list or library. A site-level grant does not break inheritance, because the site is the root of inheritance. - Scope levels do not ladder upward. `Files.SelectedOperations.Selected` can never reach a list or site; `Sites.*` can be used to grant file-level permissions but not the reverse. ### Delegated `Sites.Selected` In delegated mode, effective access is the **intersection** of the user's SharePoint permissions and the app's granted role. A user who cannot see the site sees nothing through your app, no matter what the grant says — which is usually the behavior a customer wants, and occasionally the reason a connector "randomly" returns empty lists. ## 3. Addressing: site vs drive vs list Graph exposes SharePoint as `site` → `list` → `listItem`, with a document library also surfacing as a `drive` of `driveItem`s. Sites are **read-only** in Graph (you cannot create one); lists, list items and drive items are read-write. | Path | What it addresses | | --- | --- | | `/sites/root` | The tenant's default site | | `/sites/{hostname}:/sites/{path}` | A site by URL — e.g. `/sites/contoso.sharepoint.com:/teams/hr` | | `/sites/{hostname},{spsite-id},{spweb-id}` | A site by composite id — what `Sites.Selected` grants key on | | `/sites/{site-id}/drive` and `/drives` | The default document library, and all of them | | `/sites/{site-id}/lists/{list-id}/items` | List items | | `/drives/{drive-id}/items/{item-id}` | A file or folder | A site id is only unique *within* its site collection, which is why the id is composite. Resolve it once by path and store it; do not try to construct it. For `Sites.Selected`, the **site id of each granted site** — in the composite `{hostname},{siteCollectionId},{webId}` form — is part of what you hand off alongside the credentials. ## 4. Throttling — what the credentials have to survive This is where SharePoint connectors fail in production rather than at registration. - Throttled calls return **HTTP 429** or **503** with a **`Retry-After`** header. Honoring it is required — throttled requests still count against your quota, so retrying early makes things worse and can escalate to an outright block at the app or user level. - Microsoft's throttling doc lists `RateLimit` headers in its summary but then states that SharePoint Online **does not return or support IETF `RateLimit` headers**. Do not build on them; honor `Retry-After`. - Limits are measured in **resource units**, not requests: 1 per single-item read, delta-with-token or file download; 2 per multi-item query or write; **5 per permission operation, including `$expand=permissions`**. Budget for that last one if you enumerate sharing — and note that stamping and auditing `Sites.Selected` grants is exactly that kind of operation. - Per app, per tenant: roughly **1,250–6,250 resource units per minute** and **1.2M–6M per 24 hours**, scaling with the tenant's license count. Multitenant apps are metered **per tenant**, so one noisy customer does not throttle the others. - **Delta with a token is the cheapest way to scan** content — Microsoft deliberately prices it at 1 unit. - **Decorate your traffic.** Undecorated traffic is deprioritized. Send a `User-Agent` of the form `ISV|CompanyName|AppName/Version` (or `NONISV|…` for an in-house app). - Do **not** register multiple app IDs for the same workload to dodge limits — they share the tenant's resources and Microsoft calls this out as a known anti-pattern. - App-only search with `Sites.Read.All` or stronger is throttled at **25 requests/second**. ## 5. Verify the SharePoint-specific parts Run the base skill's end-to-end check first (second tenant, refresh token, token claim inspection). Then: 1. For `Sites.Selected`: confirm access is **denied before** the site grant and **allowed after** it. If it works before the grant, your token is carrying a broader scope than you think — go back to §1. 2. Read one site (`GET /sites/{siteId}`), then one drive item under it. Both, not just the site. 3. If the design depends on a second site, confirm it too — grants do not generalize. | Symptom | Cause | | --- | --- | | Auth succeeds, every SharePoint call is `403 accessDenied` | `Sites.Selected` consented but no per-site grant stamped — or the grant is on a different site (§2) | | Works for the site you granted, 403 on a second site | Grants are per site; each one needs its own `POST /sites/{id}/permissions` (§2) | | Delegated calls return fewer items than the admin expects | Delegated access is the intersection of user and app permissions (§2) | | A `Sites.Selected` connector that used to work returns 403 on every site at once | The Entra consent was revoked, which kills all granted sites together (§2) | | Sudden 429/503 under load | Throttling; honor `Retry-After`, switch scans to delta-with-token (§4) | | Persistent 503 with no recovery | The app has been blocked, not throttled — Microsoft notifies the tenant in the Message Center (§4) | | Instructions mention `appregnew.aspx` / `appinv.aspx` | Retired ACS path — dead since 2026-04-02 (Platform state) | | A remote event receiver stops firing | Registered through ACS (dead) rather than an Entra app (works until 2027-07-01) (Platform state) | ## Hand off — the SharePoint additions The base skill's hand-off list applies. Add to the closing summary: for `Sites.Selected`, **which sites were granted, with which role, and who stamped them**; and the composite Graph **site id** of each, since an app-only connection is anchored to it. ## Stop and ask On top of the base skill's conditions, hand back to a human when: - The choice between `Sites.Selected` and tenant-wide `Sites.*.All` has not been made — it is a product and security decision with an onboarding cost attached, not a config detail. - The customer's admin cannot or will not stamp a per-site grant, or no one in the room holds SharePoint Administrator. - Someone proposes migrating a live app to a new client ID — that re-authorizes every customer *and* re-stamps every site grant. - A plan depends on Azure ACS, SharePoint Add-Ins, `appregnew.aspx`/`appinv.aspx`, or ACS-registered remote event receivers. Those are retired; the plan needs rewriting, not a workaround. - Microsoft commercial-marketplace or Partner Center listing comes up: Microsoft states that Partner Center offers must be **published** before their client IDs are used in production, and listing involves compliance, legal and volume claims that are business decisions. - Granting below the site level is proposed and nobody has weighed the permission-inheritance cost (§2). ## References The base skill carries the shared Entra references. These are the SharePoint-specific ones; verified 2026-09-20, every link returned HTTP 200. - Selected permissions overview (`Sites.Selected` and friends) — https://learn.microsoft.com/en-us/graph/permissions-selected-overview - Grant an app permission to a site — https://learn.microsoft.com/en-us/graph/api/site-post-permissions - List site permissions — https://learn.microsoft.com/en-us/graph/api/site-list-permissions - Delete a site permission — https://learn.microsoft.com/en-us/graph/api/site-delete-permission - Working with SharePoint sites in Microsoft Graph (site/list/drive addressing) — https://learn.microsoft.com/en-us/graph/api/resources/sharepoint - Get a site — https://learn.microsoft.com/en-us/graph/api/site-get - Avoid getting throttled or blocked in SharePoint Online — https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online - Microsoft Graph throttling guidance — https://learn.microsoft.com/en-us/graph/throttling - Azure ACS retirement in Microsoft 365 — https://learn.microsoft.com/en-us/sharepoint/dev/sp-add-ins/retirement-announcement-for-azure-acs - SharePoint Add-In retirement in Microsoft 365 — https://learn.microsoft.com/en-us/sharepoint/dev/sp-add-ins/retirement-announcement-for-add-ins - Add-In and ACS retirement FAQ — https://learn.microsoft.com/en-us/sharepoint/dev/sp-add-ins/add-ins-and-azure-acs-retirements-faq - Microsoft Graph permissions reference (`Sites.*`) — https://learn.microsoft.com/en-us/graph/permissions-reference ## microsoft-teams-oauth-app URL: https://docs.unified.to/skills/microsoft-teams-oauth-app/SKILL.md # Microsoft Teams (Microsoft Entra ID) OAuth2 App Registration Get a working OAuth2 client for Microsoft Teams — an Entra ID app registration carrying the right Teams Graph permissions — for a platform that connects many customers' tenants. Registering the app is the base skill's job and is mechanical. Teams adds three things that decide whether the connector ships: **which permissions a tenant admin will sign off on** (most of the useful ones are admin-restricted, and the bulk-export ones are application-only, so there is no user-consent path at all); **what the protected export APIs now cost**, which changed materially in 2025 and which most runbooks still get wrong; and **two narrowing mechanisms** — resource-specific consent and application access policies — that are the only honest answers to "why does your app need every message in our tenant?". Read the protected-API section before you promise a customer anything about message export. ## Built on: `microsoft-entra-app-registration` **Read the base skill first.** It is the source of truth for the Entra mechanics this one assumes: - **Supported account types** — Teams lives in work/school tenants, so **Multiple Entra ID tenants** is the choice; personal-account audiences buy nothing and collapse the permission ceiling to 30. `AADSTS50194` when a single-tenant registration is called through `/common`. - **Redirect URIs** — Web vs SPA vs public-client platform types (SPA cannot carry a client secret), exact case-sensitive matching, the count and length limits, and the Unified.to callback hosts. - **Delegated vs application permissions, and admin consent** — who may consent to what, and that Microsoft Graph *application* permissions require a **Privileged Role Administrator**, not an Application Administrator. Every app-only Teams design walks into that one. - **Client secrets and certificates** — the 24-month cap, the **Value** shown exactly once, `AADSTS7000222` on expiry, add-then-delete rotation, and the per-cloud authority and Graph hosts. - **`offline_access`, publisher verification and the generic AADSTS symptom table** — no Teams scope carries a refresh token of its own; `offline_access` is what gets you one. The base also holds the inputs to collect, the run order, tenant ownership, the credential hand-off and the generic stop-and-ask conditions. **A reader who loads only this file is missing all of it.** Everything below is Teams-specific. ## Extra inputs to collect On top of the base skill's batch: | Input | Notes | | --- | --- | | **Delegated (a user connects) or application (app-only background sync)?** | Decides the whole permission story — §2 | | **Is bulk message export in scope, or per-conversation reads?** | Export is application-only and has its own licensing — §1 | | **Meetings: recordings, transcripts, AI insights?** | Each is a separate permission; AI insights needs a Copilot license — §1, §4 | | **Will a Teams app (manifest) be shipped, or only an Entra app?** | RSC needs a Teams app installed in each team/chat — §3 | | **Who administers Teams on the customer side, and can they run PowerShell?** | Application access policies and RSC tenant settings are cmdlets — §3, §4 | | **Change notifications: with resource data, or bare?** | Resource data means an encryption certificate you must manage — §5 | ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Teams APIs are no longer metered.** Microsoft: *"Starting August 25, 2025, the Teams APIs are no longer metered, and no billing configuration is required to use these APIs. If your application is configured for billing, no action is required."* The `model` query parameter *"is no longer required and is ignored when supplied"*, and the only API still listed as metered in Graph is SharePoint/OneDrive `assignSensitivityLabel`. The old payment-model article (model=A / model=B / evaluation mode, $0.00075 per message, $0.003 per recording minute, $0.0022 per transcript minute) is **marked deprecated and slated for removal in June 2026** but was still live on this date — treat every number in it as history, not as a quote you give a customer. - **Two licensing exceptions survived the change.** Teams **meeting AI insights** APIs require a **Microsoft 365 Copilot** license for the user, and the **DLP `policyViolation` PATCH** APIs require a license carrying the Microsoft Communications DLP service plan. Everything else on that list is now unmetered. - **Export APIs need a Teams license, not a compliance license.** The current export article states that organizations *"must have an active Microsoft Teams license assigned to the users whose data is being exported"* and — explicitly — that use of the export APIs *"does not require a Microsoft Purview Data Loss Prevention (DLP) service plan or any additional DLP licensing."* That reverses years of guidance; older runbooks and vendor comparisons still demand E5. - **The protected-API request form appears to be gone.** The dedicated protected-APIs page (`/graph/teams-protected-apis`) now returns **404** with no redirect, the historical `aka.ms/teamsgraph/requestaccess` short link no longer resolves to a form, and the export article's prerequisites now read: *"Microsoft Teams APIs in Microsoft Graph that access sensitive data are considered protected APIs. You can call these APIs as long as the requirements for accessing without a user are met."* No approval queue, no Wednesday review cycle. **But** the metered-APIs page still carries the stranded sentence *"Some metered APIs and services in Microsoft Graph are protected and require additional validation beyond permissions and admin consent. Before you can use these protected APIs, you must submit a request"* — while listing only a SharePoint API. Microsoft's own pages disagree. Plan for the permissive reading, and treat a `403` naming protected-API validation as the signal that the gate is back; do not tell a customer an approval is required (or that none is) without testing it in their tenant. - **`ChannelMessage.Read.Group` and `ChatMessage.Read.Chat`** are the RSC forms of the broad message-read permissions and are real, documented alternatives — §3. - **Application access policies for meetings are still mandatory** for app-only access to online meetings and virtual events. Permission plus admin consent is not enough; §4. If Graph or the Teams admin surfaces do not behave like this, stop and report what you actually see. ## 1. Export and the protected APIs — the fact that decides the design The bulk-export surface is the reason most Teams integrations exist and the reason most of them stall. | API | Who can call it | | --- | --- | | `GET /teams/{team-id}/channels/getAllMessages` | **Application only.** Delegated is *"Not supported"* — for work/school *and* personal accounts | | `GET /users/{user-id}/chats/getAllMessages`, `GET /me/chats/getAllMessages` | Application only | | `GET /teams/{team-id}/channels/getAllRetainedMessages`, `GET /users/{user-id}/chats/getAllRetainedMessages` | Application only | | `GET /teamwork/deletedTeams/{id}/channels/getAllMessages` | Application only | | `GET /users/{id}/onlineMeetings/getAllRecordings` / `getAllTranscripts` | Application only, **plus** an application access policy (§4) | The documented application permissions for the export set are `Chat.Read.All` (all 1:1, group and meeting chat messages), `ChannelMessage.Read.All` (all channel messages), `User.Read.All`, `OnlineMeetingTranscript.Read.All` and `OnlineMeetingRecording.Read.All`. Consequences to say out loud before anyone designs around it: - **There is no delegated export.** A "the user connects their own Teams account" flow can never call `getAllMessages`. It reads messages one conversation at a time through `/teams/{id}/channels/{id}/messages` and `/chats/{id}/messages`, at delegated rate limits, bounded by what that user can see. If the requirement is tenant-wide history, the requirement is an app-only app with a Privileged Role Administrator's consent. - **Cost is no longer the objection; scope is.** Since 2025-08-25 there is no per-message charge and no Azure subscription to attach. What a customer's security review now weighs is `Chat.Read.All` + `ChannelMessage.Read.All` on an app-only app — every private chat in the tenant, forever, with no user in the loop. Expect that to be the hard conversation, and expect §3 to be the answer for anything short of compliance archiving. - **Retention and deletion windows bound what export can return**: user-deleted messages for **21 days**, deleted teams/channels and deleted or inactive users for **30 days**, and retained (hold/retention-policy) messages through the separate `getAllRetainedMessages` calls. - **`$top` is a hint, not a page size.** Microsoft: *"The $top value is a maximum hint and not a guaranteed page size… a response may return fewer items than requested and include an @odata.nextLink"* because messages come from several underlying mailboxes. The recommended ceiling for export is **250**. Pagination must follow `@odata.nextLink` until it is absent — never infer completion from a short page. - **Historic `402 Payment Required` errors** mean an old metered path: no Azure subscription, `model=A` without the DLP license, or evaluation capacity exhausted. On a current tenant a fresh `402` on a Teams call is a signal that something in your stack is still sending `model=` or that you are calling AI insights without a Copilot license. ## 2. The Teams permission families Teams permissions divide sharply by mode. This table is what an admin sees, and whether their consent is required (from the Graph permissions reference, 2026-09-20): | Permission | Delegated | Application | Admin consent | | --- | --- | --- | --- | | `Team.ReadBasic.All` | yes | yes | delegated **no**, application yes | | `Channel.ReadBasic.All` | yes | yes | delegated **no**, application yes | | `ChannelMessage.Read.All` | yes | yes | **yes, both** | | `ChannelMessage.Send` | yes | — | no | | `ChatMessage.Read` | yes | — | no | | `Chat.Read` / `Chat.ReadBasic` | yes | — | no | | `Chat.Read.All` | **— (application only)** | yes | yes | | `TeamMember.Read.All` | yes | yes | **yes, both** | | `OnlineMeetings.Read` | yes | — | no | | `OnlineMeetings.Read.All` | **— (application only)** | yes | yes | | `OnlineMeetingRecording.Read.All` / `OnlineMeetingTranscript.Read.All` | yes | yes | **yes, both** | | `OnlineMeetingAiInsight.Read.All` | yes | yes | yes, both — **plus a Copilot license** | | `CallRecordings.Read.All` / `CallTranscripts.Read.All` | yes | yes | **yes, both** | | `CallRecords.Read.All` | **— (application only)** | yes | yes | | `Chat.Read.WhereInstalled` | **— (application only)** | yes | yes | Four traps live in that table: 1. **`Chat.Read.All` has no delegated form.** A delegated design that wants "all the user's chats" uses `Chat.Read` plus `ChatMessage.Read`, not `Chat.Read.All`; requesting the latter in a delegated authorize URL fails as an invalid scope, not as a consent prompt. 2. **The `ReadBasic` pair is the cheap entry.** `Team.ReadBasic.All` and `Channel.ReadBasic.All` are the only interesting Teams scopes an ordinary user can consent to delegated — names and descriptions of teams and channels, nothing inside them. A connector that only lists conversations for a picker should stop there and request message scopes later, at the point the customer chooses to sync. 3. **Delegated `ChannelMessage.Read.All` still needs an admin.** It reads only what the signed-in user can already see, so the *access* is narrow, but the consent is not user-grantable. Discovering that at a customer's consent screen (`AADSTS90094`) costs a scheduling round trip; say it up front. 4. **Any scope in the token is honored.** Requesting `Chat.Read` "for least privilege" alongside `Chat.Read.All` yields a tenant-wide app with a least-privilege label. Narrow means the broad scopes come out of the request. Teams file attachments live in SharePoint and OneDrive, so `Files.Read.All` and `Sites.Read.All` ride along with any message-read design that resolves attachments. They are delegated-user-consentable but tenant-wide in application mode, and a security review will read them as SharePoint access — see the SharePoint skill for that argument. ### Product fact — Unified.to's Microsoft Teams connector, as of 2026-09-20 Recorded from the connector's own metadata. **Confirm the current set with the connector's owner before you register anything**; scope lists change and this is a snapshot. - **Delegated only.** It authorizes and exchanges tokens against the multitenant `/common` v2.0 endpoints, sends `prompt=select_account`, and ships Unified.to's own shared client credentials by default. There is **no app-only (client credentials) path**, which means no export APIs (§1), no application access policy (§4), and no application-mode change notifications (§5) as the connector stands today. - **`offline_access` is present in every per-object scope set**, alongside `openid`, `email` and `profile`; the login-only flow requests just `openid`, `email`, `profile` and `User.Read`. - **Messaging**: channels use `Team.ReadBasic.All` + `Chat.Read`; message read adds `Channel.ReadBasic.All`, `ChannelMessage.Read.All`, `ChatMessage.Read`, plus `Files.Read.All` and `Sites.Read.All` for attachments; message write uses `ChannelMessage.Send` and `ChatMessage.Send` (with `ChannelMessage.Read.All` retained). - **Calendar and meetings**: `Calendars.Read` / `Calendars.ReadWrite`; webinars use `VirtualEvent.Read` / `VirtualEvent.ReadWrite` (delegated `VirtualEvent.Read` **is** admin-consent-required); recordings request `OnlineMeetingRecording.Read.All`, `OnlineMeetingTranscript.Read.All`, `OnlineMeetingAiInsight.Read.All` and `OnlineMeetings.Read`, and the unified-communications recording object adds `CallRecordings.Read.All` and `CallTranscripts.Read.All`. - **Directory objects** reuse the shared Entra directory scopes — `Directory.Read.All`, `User.Read.All`, `Group.Read.All` and their `ReadWrite` forms — all admin-restricted in both modes. - **Protected or metered APIs implied: none today.** Nothing in the set reaches an export endpoint, and nothing is metered after 2025-08-25. The one live licensing dependency is `OnlineMeetingAiInsight.Read.All`, which returns nothing for a user without a **Microsoft 365 Copilot** license — a per-user condition your support team will see as "recordings work, insights are empty". - **There is a second, separate Teams connector** (`microsoftteamsbot`, added 2025-12) covering channels, messages and message events with a near-identical delegated scope set, intended for the bot-shaped Teams integration. Which connector a customer is on changes which scope set applies and which Entra app the credentials belong to. That connector's registration is a different task and deserves its own skill; do not assume this one's answers carry. Expect the message-read set — `ChannelMessage.Read.All` in particular, and the directory scopes — to be escalated to a tenant admin in any customer with a security review. ## 3. RSC — the narrow alternative, and what it cannot reach Resource-specific consent lets a **team owner, chat member or meeting organizer** grant your app access to *that one* team or chat, instead of a tenant admin granting access to all of them. It is the Teams analogue of SharePoint's `Sites.Selected`, and it is the strongest answer you have to a security review. **It requires shipping a Teams app**, not just an Entra app: - Register the Entra app as usual, then declare the RSC permissions in the **Teams app manifest**, under `webApplicationInfo` (`id` = the Entra app ID; `resource` is unused by RSC but must be a non-empty string) and `authorization.permissions.resourceSpecific`, each entry a `name` plus `type` of `Application` or `Delegated`. Manifest **v1.12 or later** is required for delegated RSC. (Manifest v1.11 and earlier used a flat `applicationPermissions` array; Microsoft recommends moving off it.) - **One Entra app per Teams app.** Microsoft: *"You mustn't share your Microsoft Entra app ID across multiple Teams apps… Installing multiple Teams apps associated with the same Microsoft Entra app ID will cause installation or runtime failures."* - **Consent happens at install**, in the Teams client, by whoever installs the app in that team or chat — not in the Entra admin center, and not visible there. Application RSC grants are readable through `GET /teams/{id}/permissionGrants`, `/chats/{id}/permissionGrants` and `/users/{id}/permissionGrants`, matching `clientAppId` to the manifest's `webApplicationInfo.id`. - **The tenant can switch it off.** Team and chat RSC are governed by tenant settings (`ManagedByMicrosoft` by default, or `EnabledForAllApps` / `DisabledForAllApps`, set through Graph PowerShell); user-scope RSC is governed by a separate Teams app-settings property. A customer with RSC disabled cannot install your app no matter what the manifest says. What RSC reaches, and what it does not: | Can | Cannot | | --- | --- | | `ChannelMessage.Read.Group` — this team's channel messages | Anything tenant-wide: `/teams/getAllMessages`, `/chats/getAllMessages` | | `ChatMessage.Read.Chat` — this chat's messages (beta for subscriptions) | A team or chat where the app is not installed | | `TeamMember.Read.Group`, `TeamSettings.*`, `Channel.*`, `TeamsTab.*` | Directory-wide reads (`User.Read.All`, `Group.Read.All`) | | `OnlineMeetingRecording.Read.Chat`, `OnlineMeetingTranscript.Read.Chat`, `ChannelMeetingRecording.Read.Group` | Meetings not associated with an installed chat or team | | `TeamsActivity.Send.*` — always tenant-enabled, always consented on install | A per-user identity: RSC calls are application calls | Two things to flag before anyone designs around RSC: - **RSC grants are not attributed to a user.** Microsoft's own warning: the app *"can be allowed to perform actions that the user can't, such as deleting a tab."* Permission checks you might expect from a delegated flow are not there. - **Onboarding is per team and per chat, by the resource owner.** Your platform cannot self-serve it, the same way it cannot self-serve a `Sites.Selected` grant. Design the connect flow to say so. ## 4. Application access policies — meetings only, and not the Exchange one For **application** permissions against online meetings and virtual events, permission plus admin consent is not enough. Microsoft requires the tenant admin to also grant an **application access policy** naming your app's client ID against the users whose meetings it may act for. ```powershell New-CsApplicationAccessPolicy -Identity Test-policy -AppIds "","" -Description "…" Grant-CsApplicationAccessPolicy -PolicyName Test-policy -Identity "" Grant-CsApplicationAccessPolicy -PolicyName Test-policy -Global # optional: tenant default ``` - **It covers exactly these application permissions**: `OnlineMeetings.Read.All`, `OnlineMeetings.ReadWrite.All`, `OnlineMeetingArtifact.Read.All`, `OnlineMeetingTranscript.Read.All`, `OnlineMeetingRecording.Read.All` and `VirtualEvent.Read.All`. Nothing else in Graph is gated this way. - **`-Identity` means two different things** — the policy name when creating, the *user's object ID* when granting. - **Granting applies to online meetings and virtual events together.** If they must be managed separately, Microsoft says to use two applications. - **Changes take up to 30 minutes** to show up in Graph calls. A "it still 403s" report inside that window is not a bug. - Without it, the call fails `403 Forbidden` with `"No application access policy found for this app"` — a message that names neither the permission nor the user, and looks exactly like a missing consent. - **This is not the Exchange `ApplicationAccessPolicy`** used to scope app-only *mail* access to a mail-enabled security group. Same words, different cmdlet, different product, different scoping model. Do not hand a customer instructions for one while debugging the other. ## 5. Change notifications, and what resource data costs you Polling Teams is a policy violation, not just bad practice: Microsoft allows a resource to be polled **once per day**, and says apps that ignore this *"will be considered in violation of the Microsoft APIs Terms of Use"*, which *"may result in additional throttling or the suspension or termination of your use of the Microsoft APIs."* Subscriptions are the supported design. - **Tenant-wide message subscriptions are application-only**: `/teams/getAllMessages` needs `ChannelMessage.Read.All`, `/chats/getAllMessages` needs `Chat.Read.All`. Per-channel and per-chat subscriptions support delegated (`ChannelMessage.Read.All`, `Chat.Read`) and the RSC forms. - **Lifetimes and renewal**: Teams `chatMessage`, `channel`, `chat`, `team`, `conversationMember`, `callRecording` and `callTranscript` subscriptions max out at **4,320 minutes (three days)**, so renewal is a permanent background job. And for chat/channel message subscriptions, an `expirationDateTime` more than **one hour** out **requires** a `lifecycleNotificationUrl` in the subscription request, or creation fails outright. - **Quotas are shared and they bite**: one subscription per app *per channel or chat*, ten per user for user-wide chat subscriptions, and **10,000 total Teams subscriptions per organization across every Teams resource type combined** — chats, messages, channels, teams, members, recordings, transcripts. Past that, new Teams subscriptions fail `403 Forbidden`. A per-conversation subscription model does not scale to a large tenant; design for the tenant-wide resource or accept the cap. - **Resource data means key management.** `includeResourceData: true` requires an `encryptionCertificate` (public key only, Base64 X.509, RSA, **2,048–4,096 bits**; self-signed is fine — Graph does not verify the issuer) and your own `encryptionCertificateId` (up to 128 characters) to match notifications to keys. Decryption is two-stage: unwrap the per-item `dataKey` with your private key using **RSA-OAEP**, verify `dataSignature` with **HMAC-SHA256**, then decrypt `data` with **AES-CBC/PKCS7** using the **first 16 bytes of the symmetric key as the IV**. Rotate by issuing new certificates at renewal and keeping the old private key until no notification references its ID. - **Validate every notification.** Rich notifications carry `validationTokens`; the publisher claim (`azp` on v2.0 tokens, `appid` on v1.0) must be `0bf30f3b-4a52-48df-9a82-234910c4a086`. Respond `202 Accepted` first, validate after. A `null` `validationTokens` means app misconfiguration: set the service principal's `appRoleAssignmentRequired` to `false`, or explicitly assign the *Microsoft Graph Change Tracking* service principal a role. - Teams `chatMessage` notifications are documented as arriving in **under 10 seconds** typically. If your product promises real time, this is the mechanism; nothing else gets close. ## 6. Throttling — the four dimensions Teams throttles on **per app** (summed across every tenant — the one multitenant connectors forget), **per app per tenant**, **per resource** (one team, channel or chat) and **per user**. A request is throttled when it exceeds *any* applicable limit, and a sustained rate of roughly **83 percent** of the listed value is evaluated over a longer window, so a workload that runs continuously at the published number still gets throttled. | Call | Per app | Per app per tenant | Per resource | Per user | | --- | --- | --- | --- | --- | | `GET /teams/{id}/channels/{id}/messages` | 200 rps | 20 rps | 1 rps per channel | — | | `GET /chats/{id}/messages` | 200 rps | 20 rps | 1 rps per chat | — | | `POST` channel messages or replies | 500 rps | 50 rps | 1 rps per channel | 1 rps | | `GET /me/joinedTeams`, `/users/{id}/joinedTeams` | 300 rps | 30 rps | — | — | | `GET /teams/{id}/channels` | 1200 rps | 60 rps | 4 rps per team | — | | Export `getAllMessages` / `getAllRetainedMessages` | 1000 rps | 200 rps | — | — | | Anything unlisted (GET) | 1500 rps | 30 rps | 1 rps per chat or channel | 1 rps | **The per-resource limit is the one that hurts.** One request per second against a single channel or chat means a backfill of a busy conversation is serialized no matter how much concurrency you have; parallelize across conversations, not within one. Export endpoints exist precisely because they are exempt from the per-resource cap. Honor `Retry-After`, back off exponentially, and do not register extra app IDs to dodge the per-app ceiling — the per-tenant limits still apply and Microsoft treats it as abuse. Teams' own product limits (members per team, channels per team, message size) apply identically through Graph; see the Teams limits and specifications doc before designing around a number you assumed. ## 7. Verify the Teams-specific parts Run the base skill's end-to-end check first (a second tenant, a `refresh_token` in the response, the token's `scp` or `roles` claim inspected). Then: 1. **List before you read.** `GET /me/joinedTeams`, then that team's channels, then one channel's messages. Each step uses a different permission; a failure tells you which one never made it into the token. 2. **Test with a non-admin user.** Delegated `ChannelMessage.Read.All` and `TeamMember.Read.All` both need admin consent, and the admin's own session hides that. 3. **If meetings are in scope**, fetch one recording and one transcript for a meeting the connecting user organized — these are organizer-scoped, so testing with a participant proves nothing. If AI insights are in scope, confirm the test user holds a Copilot license before filing an empty result as a bug. 4. **If a subscription is in scope**, create it, let it deliver one notification, decrypt it, and then let it renew at least once. Three-day expiry means the renewal path is the part that breaks in production. | Symptom | Cause | | --- | --- | | `403 Forbidden`, `"No application access policy found for this app"` | App-only meeting call with no application access policy, or granted under 30 minutes ago (§4) | | Delegated authorize rejects a scope as invalid | Asking for an application-only permission (`Chat.Read.All`, `OnlineMeetings.Read.All`, `CallRecords.Read.All`) in a delegated request (§2) | | `getAllMessages` returns 403 or is refused for a delegated token | Export is application-only; there is no delegated form (§1) | | Export returns fewer items than `$top` | `$top` is a hint; follow `@odata.nextLink` to completion (§1) | | Recordings work, AI insights come back empty | The user has no Microsoft 365 Copilot license (§1, §2) | | `402 Payment Required` on a Teams call | A leftover `model=` parameter, or an AI-insight/DLP licensing gap — metering itself ended 2025-08-25 (Platform state) | | Subscription creation fails naming `lifecycleNotificationUrl` | Expiry set beyond one hour on a chat/channel message subscription without it (§5) | | New subscriptions fail `403` in a large tenant | The 10,000-per-organization Teams subscription cap, shared across all Teams resource types (§5) | | `validationTokens` arrives `null` | `appRoleAssignmentRequired` is `true` without a role assigned to Microsoft Graph Change Tracking (§5) | | RSC app fails to install: `WebApplicationInfoIdOfSideloadedAppMustBeInTheSameTenantAsUser` | The Entra registration is in a different tenant than the installer, and the installer is not a tenant admin (§3) | | RSC permissions invisible in the Entra admin center | They live in the Teams app manifest and the install, not the Entra app; read them from `permissionGrants` (§3) | | Steady 429s on one busy channel despite low overall volume | The 1 rps per-resource limit; parallelize across conversations, not within one (§6) | ## Hand off — the Teams additions The base skill's hand-off list applies. Add to the closing summary: **delegated or application**, and for application which meeting permissions are covered by an application access policy, who created it and against which users; the exact Teams scope strings and which of them required admin consent; whether any export endpoint is in scope and what licensing the customer was told about it (Teams license for exported users; Copilot for AI insights); if RSC is used, the Teams app manifest's declared permissions and which teams or chats it is installed in; and for change notifications, which resources are subscribed, the certificate identifier in use and who owns the renewal job. ## Stop and ask On top of the base skill's conditions, hand back to a human when: - The design needs tenant-wide message export and no Privileged Role Administrator is available to consent Graph application permissions — there is no delegated fallback and no lower permission that works (§1, §2). - A customer's security review objects to `Chat.Read.All` or `ChannelMessage.Read.All` and RSC has not been evaluated. That is a product decision with an onboarding cost, not a config detail (§3). - Anyone plans around per-message billing, `model=A`/`model=B`, evaluation-mode quotas or an Azure billing subscription for Teams APIs. Those ended 2025-08-25; the plan needs rewriting, not a workaround. - A plan depends on a protected-API approval form, or on there definitely being no approval at all. Microsoft's pages disagree on this today — test in the customer's tenant rather than asserting either (Platform state). - Someone proposes polling a Teams resource more than once a day instead of subscribing (§5). - A shared Entra app ID is proposed across more than one Teams app, or an RSC design assumes a tenant-wide reach it does not have (§3). - Compliance, legal hold, eDiscovery or Purview retention enters the conversation. Export APIs touch it; the obligations are the customer's counsel's call, not yours. - The work is really about SharePoint sites, Outlook mail and calendar, OneDrive files or plain sign-in — those are different skills with different permission models. ## References The base skill carries the shared Entra references. These are the Teams-specific ones; verified 2026-09-20, every link returned HTTP 200. - Use the Microsoft Graph API to work with Microsoft Teams (limits, polling requirements) — https://learn.microsoft.com/en-us/graph/api/resources/teams-api-overview - Microsoft Teams API overview (concepts) — https://learn.microsoft.com/en-us/graph/teams-concept-overview - Export content with the Microsoft Teams Export APIs — https://learn.microsoft.com/en-us/microsoftteams/export-teams-content - Metered APIs and services in Microsoft Graph (Teams no longer metered) — https://learn.microsoft.com/en-us/graph/metered-api-list - Payment models and licensing for Teams APIs (deprecated; historical model=A/B) — https://learn.microsoft.com/en-us/graph/teams-licenses - channel: getAllMessages — https://learn.microsoft.com/en-us/graph/api/channel-getallmessages - chats: getAllMessages — https://learn.microsoft.com/en-us/graph/api/chats-getallmessages - chat: getAllRetainedMessages — https://learn.microsoft.com/en-us/graph/api/chat-getallretainedmessages - List channel messages — https://learn.microsoft.com/en-us/graph/api/channel-list-messages - List joined teams — https://learn.microsoft.com/en-us/graph/api/user-list-joinedteams - Resource-specific consent for apps — https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent - Grant RSC permissions to an app (manifest, tenant controls) — https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/grant-resource-specific-consent - Configure an application access policy (cloud communications) — https://learn.microsoft.com/en-us/graph/cloud-communication-online-meeting-application-access-policy - New-CsApplicationAccessPolicy — https://learn.microsoft.com/en-us/powershell/module/microsoftteams/new-csapplicationaccesspolicy - Grant-CsApplicationAccessPolicy — https://learn.microsoft.com/en-us/powershell/module/microsoftteams/grant-csapplicationaccesspolicy - Change notifications for messages in Teams channels and chats — https://learn.microsoft.com/en-us/graph/teams-changenotifications-chatmessage - Change notifications for Microsoft Teams resources (overview) — https://learn.microsoft.com/en-us/graph/teams-change-notification-in-microsoft-teams-overview - Change notifications with resource data (encryption, validation) — https://learn.microsoft.com/en-us/graph/change-notifications-with-resource-data - Change notifications overview (lifetimes, per-resource quotas) — https://learn.microsoft.com/en-us/graph/change-notifications-overview - Reduce missing subscriptions and change notifications (lifecycle events) — https://learn.microsoft.com/en-us/graph/change-notifications-lifecycle-events - Change notifications for call records — https://learn.microsoft.com/en-us/graph/changenotifications-for-callrecords - callRecording resource type — https://learn.microsoft.com/en-us/graph/api/resources/callrecording - callTranscript resource type — https://learn.microsoft.com/en-us/graph/api/resources/calltranscript - Use Graph APIs to fetch meeting transcripts — https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/meeting-transcripts/api-transcripts - Meeting AI insights (Copilot license) — https://learn.microsoft.com/en-us/microsoftteams/platform/graph-api/meeting-transcripts/meeting-insights - Microsoft Graph throttling limits (Microsoft Teams service limits) — https://learn.microsoft.com/en-us/graph/throttling-limits - Limits and specifications for Microsoft Teams — https://learn.microsoft.com/en-us/microsoftteams/limits-specifications-teams ## monday-oauth-app URL: https://docs.unified.to/skills/monday-oauth-app/SKILL.md # monday.com OAuth2 App Registration Get a working monday.com OAuth2 client — an app in the Developer Center, redirect URLs, a scope list, client ID and secret — for a platform that connects many customers' monday.com accounts. The registration form is unremarkable. Four things around it are not, and each of them costs a release: 1. **An admin has to install your app on the customer's account before OAuth can complete.** monday apps are installed objects, not just OAuth clients, and installation is an admin action. A member who clicks Connect on an account where nobody installed the app does not reach a consent screen (§6). 2. **Scopes live on the app version, not on the authorize call.** The authorize request may request a *subset* of what the app version declares and nothing more — and changing the declared scopes means a new app version, which existing users must manually approve before the new permissions work (§5, §3). 3. **There are two live OAuth flows.** The legacy flow issues tokens that never expire and has no refresh token at all. The opt-in **OAuth 2.1** flow has PKCE, expiring JWT access tokens, rotating refresh tokens, revocation, and a *different token endpoint*. The switch is a per-app-version toggle, so someone can flip it in the console and silently break a client that has no refresh path (§7). 4. **The API is GraphQL-only behind a dated `API-Version` header, and the default moves every quarter.** A client that sends no header is not frozen — it is pinned to whatever monday currently calls *Current*, and inherits each quarter's breaking changes on the day they land (§8). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, icon, colour, short description | Shown at consent and in the customer's installed-apps list | | **monday.com account** that will own the app, and who else holds admin there | The app is an object in one account (§2) | | **Redirect URLs** | Every callback host your platform serves (§4) | | **Which objects the connector reads and writes** | Decides the scope list you declare on the app version (§5) | | **Legacy flow or the new OAuth 2.1 flow?** | Must match what the client implements — do not guess (§7) | | **Which monday plans your customers are on** | Decides the daily call cap and a few Enterprise-only endpoints (§9) | | **Are webhooks in scope?** | Extra scopes, per-board registration, and a signing secret to verify (§10) | | **Marketplace listing wanted, or private/shared-link distribution?** | A listing is optional for serving customers (§6) | | **New app, or an edit to an existing one?** | A new client ID re-authorizes every customer (§1) | ## Quick Start 1. Confirm a **new** app is needed — existing customer connections are bound to the current client ID (§1). 2. Sign in, or create a free **developer account**, and open the Developer Center (§2). 3. **Create an app**; find client ID and secret under *General settings → App credentials* (§3). 4. Add **every** redirect URL, exactly (§4). 5. Declare the scope list on the app version under **OAuth & Permissions** (§5). 6. Promote the version to **live**, then **Install** it, then **Share** it so other accounts can install (§6). 7. Capture the credentials and decide which of the two OAuth flows you are on (§7). 8. Decide the `API-Version` you will pin, and put its quarterly rotation on someone's calendar (§8). 9. Check the daily call cap and complexity budget against what the connector actually does (§9). 10. Verify from a **second monday account you do not administer**, on a non-Enterprise plan (§10). 11. Hand the credentials over — never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Two OAuth flows are live.** The legacy flow is the documented default; the **OAuth 2.1** flow is an opt-in **New OAuth Flow** toggle in *OAuth & Permissions*, enabled per **app version**. monday's own OAuth page carries a banner pointing new integrations at the 2.1 migration guide. No end-of-life date for the legacy flow is published. - **Legacy tokens never expire.** monday's words: *"Tokens do not expire and are valid until the user uninstalls your app. Our OAuth flow does not support refresh tokens."* The legacy token response is `access_token`, `token_type` (always `Bearer`) and `scope` — no `expires_in`, no `refresh_token`, no revoke endpoint. - **The 2.1 flow changes four things at once:** PKCE with `S256` is **required**; access tokens are JWTs with an `exp` claim; every refresh returns a **new** refresh token that replaces the old one; and the token endpoint moves from `https://auth.monday.com/oauth2/token` to `https://auth.monday.com/oauth_ms/oauth/token`. The authorize URL, the scopes, the redirect URLs and the 10-minute authorization code are unchanged. - **There is a public discovery document** at `https://auth.monday.com/oauth_ms/.well-known/oauth-authorization-server` (unauthenticated). On this check it advertised `client_secret_post` **and** `client_secret_basic`, `S256` only, `code` only, grant types `authorization_code` / `refresh_token` / `client_credentials`, and a `scopes_supported` list of 26 scopes — five of which (`ai:consume`, `apps:read`, `apps:write`, `items:read`, `items:write`) do **not** appear in the scope table on the OAuth docs page. Treat discovery as the live truth and the table as the narrative. - **A temporary legacy-token migration endpoint exists** (`/oauth_ms/oauth/migrate`) that swaps an old app token for a 2.1 access/refresh pair without sending users back through consent. monday says it *"is **temporary** and will be removed once the OAuth 2.1 migration is complete"* and that personal tokens cannot be migrated. - **The API is GraphQL-only**, one endpoint, `POST https://api.monday.com/v2`, and version is chosen by the `API-Version: YYYY-MM` header. On this check: **`2026-04` Maintenance, `2026-07` Current, `2026-10` Release candidate**, with `2026-10` scheduled to become Current on **October 1, 2026**. - **Credentials live under *General settings → App credentials*** (client ID, client secret, signing secret, app ID). The OAuth docs page still says "Basic Information tab" — that naming is stale; trust the Developer Center. - **A free developer (sandbox) account is self-serve and instant**, with up to 10 seats, 1,000 items per product, 10M API complexity and selected Pro/Enterprise features. No request form, no waiting period. - **Marketplace listing is optional.** A public app can be shared by link — optionally restricted to named accounts — and installed without review. Unapproved apps show a banner saying monday has not vetted them. If the Developer Center does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer connection is bound to the old one** — every customer re-authorizes, and an admin re-installs. Reuse the existing app for: adding a redirect URL, changing the scope list, resetting a leaked secret, or diagnosing an authorization failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, or a deliberate environment split. Say which path you are taking before you touch anything. Two monday-specific wrinkles before you decide: - **Widening scopes is not free even on the same app.** Scope changes ship as a new app version, and monday says *"changes to scopes or permissions require manual approval from users"* — users get an in-app banner, keep using the app meanwhile, and *"some features may not work as expected until approval is granted."* So a scope widening is a slow, per-account rollout you cannot see from your side, not an instant switch. - **The app is an object inside one monday account.** Whoever holds admin on that account can reach its credentials. Prefer a company-controlled account over a personal one, make sure at least two people who will still be there can administer it, and record the app name, app ID and client ID somewhere outside monday. ## 2. Account: sign in, or create a developer account - Any monday.com **admin or member** can create apps; **guests** can only view apps they collaborate on, and **viewers cannot open the Developer Center at all**. - Open it from your profile picture → **Developers** (it opens in a new tab). - **A free developer account is the right testing surface** and is self-serve: sign up at `https://auth.monday.com/users/sign_up_new?developer=true`. It includes all monday products, up to 10 seats, 1,000 items per product, 25,000 automation and integration actions a month, 10M API complexity, and account-, column- and item-level permissions. monday states plainly it is **for development and testing only**, not for production workflows or serving customers. - **Who can use the API at all** is a seat question, and it is not the same as who can log in: admins, members and guests can use the API, but **viewers, deactivated or disabled users, users with unconfirmed email addresses and users on student accounts cannot**. Guests additionally cannot hold a personal API token — OAuth is their only route. A customer connecting as a view-only user gets a token that fails with `USER_ACCESS_DENIED`, which reads like your bug and is not (§10). Hand control back to the user for anything only they can do: signup, email verification, 2FA, accepting developer terms, promoting someone to admin, installing the app. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user the exact ordered click path with the literal values to paste (§4 redirect URLs, §5 scope list), then continue once they report back with the client ID. ## 3. Create the app, and understand app versions Developer Center → create an app. The sections that matter: | Section → tab | Holds | | --- | --- | | **General settings → Display information** | App name, short description, icon, colour | | **General settings → App credentials** | **Client ID, client secret, signing secret, app ID** | | **Build → OAuth & Permissions** | **The scope list**, redirect URLs, and the **New OAuth Flow** toggle (§7) | | **Build → Webhooks** | App lifecycle webhooks (install/uninstall), distinct from board webhooks (§10) | | **Manage → App versions** | Draft / live / deprecated versions, gradual release, "Active for me" | | **Distribute → Install app / Share app / Submit to marketplace** | §6 | **Versioning is the part people miss.** monday apps have no major/minor numbers: *"each version represents a complete snapshot of your app."* A draft inherits everything from the live version — scopes, OAuth settings, features, name — and changes nothing for users until promoted. Exactly **one** version is live at a time; promoting a new one deprecates the old one, which keeps working for users already on it. A collaborator can mark a draft **Active for me** to test it alone without affecting anyone. Practical consequence: **you cannot change scopes, or flip the OAuth flow, on the live version in place.** You make a draft, change it, test it as Active-for-me, and promote. Build that round trip into any change plan. ## 4. Redirect URLs Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` What monday documents: - **Multiple redirect URLs are supported.** monday: *"You can use multiple URLs if you want to route users to different pages based on their context."* If the console only ever accepts one value, stop and report that rather than registering four separate apps. - **`redirect_uri` is conditionally required on the authorize call.** With exactly one URL configured it may be omitted and monday uses it; **with more than one configured it is required**. Since a multi-region platform always has several, treat it as mandatory and always send it. - **It must match a registered value**, and it must be sent again at token exchange, where monday uses it *"for validation only — there is no redirection"* and requires it to equal the value from the authorize step. - The redirect carries back `code` (valid **10 minutes**) and your `state`. monday's 2.1 guide also notes a `status` parameter to check that the user approved. Errors come back on the redirect URI, not as a monday-hosted page. ## 5. Scopes Scopes are **declared on the app version** in *OAuth & Permissions*, then optionally narrowed per authorize request: space-separated in the `scope` parameter, and *"All requested scopes must match the list you configured in your app."* Omit `scope` entirely and monday uses the app's full configured list — which is usually not what you want on a connector where different customers enable different objects. The catalogue as monday's OAuth page spells it: | Scope | Grants | | --- | --- | | `me:read` | The authorizing user's own profile — the minimum for identifying a connection | | `boards:read` / `boards:write` | Board data: boards, groups, items, subitems, column values | | `workspaces:read` / `workspaces:write` | Workspaces | | `users:read` / `users:write` | Profiles of the account's users | | `teams:read` / `teams:write` | The account's teams | | `account:read` | General information about the account | | `webhooks:read` / `webhooks:write` | Reading and creating board webhook subscriptions | | `updates:read` / `updates:write` | Updates (monday's comment threads) and replies | | `docs:read` / `docs:write` | monday docs | | `assets:read` | Files on items the user can see | | `tags:read` | The account's tags | | `departments:read` / `departments:write` | Departments (added February 2026) | | `notifications:write` | Sending notifications as the user | The live discovery document additionally advertises `items:read`, `items:write`, `apps:read`, `apps:write` and `ai:consume`, which the docs table does not list. **Read the per-endpoint "Required scope" line in the API reference for the operations your connector calls** rather than reasoning from the table — every query and mutation in monday's reference names its scope (for example the webhook mutations name `webhooks:write`), and a scope name that does not exist is rejected as `invalid_scope`. **Three rules that bite:** 1. **Read and write do not imply each other.** `boards:write` does not grant `boards:read`. Declare both for anything you both read and modify. 2. **Scopes are a ceiling, not a grant.** The token can never exceed the authorizing user's own permissions — monday is explicit that API permissions mirror the UI, including board-, column-, item- and account-level access. A token with `boards:read` held by a user who cannot see a private board cannot see that board, full stop. 3. **Exceeding the granted scopes is an HTTP 200.** monday returns `missingRequiredPermissions` as an application-level error inside a 200 response — *"The operation has exceeded the OAuth permission scopes granted for the app."* A client that only checks HTTP status will read this as success with empty data. > **As of 2026-09-20, this platform's monday connector requests scopes per object, and the union of everything it can > ask for is:** `me:read`, `boards:read`, `boards:write`, `workspaces:read`, `users:read`, `users:write`, > `teams:read`, `teams:write`, `webhooks:read`, `webhooks:write`. A login-only connection requests `me:read` alone. > Every one of these must be declared on the app version, or the customers who enable those objects fail at consent. > It does not request `account:read`, `updates:*`, `docs:*`, `assets:read`, `tags:read`, `departments:*`, > `notifications:write` or `workspaces:write`. Confirm the current list with the connector's owner before declaring > anything. ## 6. Install, share, and the admin gate This is monday's distribution model, and it is the step that makes a correctly registered app fail for a customer. **Installation is a prerequisite for authorization, and it is an admin action.** monday's own description of the `force_install_if_needed` parameter says it *"automatically redirect[s] users to the app installation page if the app is not installed. Once an admin installs the app, they are redirected back to the OAuth page to complete the authentication process."* Add `force_install_if_needed=true` to the authorize URL so a non-installed account gets routed into installation instead of into a dead end — but understand that this is a redirect to a human step, not an automation of it. A member at a customer who cannot install apps still needs their admin. The three distribution states: | State | What it means | | --- | --- | | **Installed on your own account** | Promote a version to live, then *Distribute → Install app*. Visible to nobody outside your account | | **Public, shared by link** | *Share app* → accept developer terms → get a shareable `auth.monday.com` URL. Optionally restrict to named accounts. Customers install from that link; it appears under *Installed outside the marketplace* | | **Listed in the marketplace** | Reviewed and approved by monday, discoverable by all users | **A marketplace listing is optional for serving customers** — link sharing is enough, and needs no review. The cost of skipping it is a banner telling users monday has not vetted the app. If a listing is wanted, know what it involves: seven requirement categories (listing page, documentation and support, legal, partnership, privacy and security, product, UI/UX), a submission form, and a review conducted with you on a shared monday board. monday aims for an **initial response within 72 business hours**; total duration depends on the app. monday also states that apps built primarily with no-code platforms or AI-generated "vibe code" are **not eligible**, and that submissions duplicating existing marketplace functionality may be declined. Do not promise a customer a listing date. **Account selection.** A user can belong to several monday accounts, and the consent screen offers an account picker. To pin a specific one, either send them to `https://.monday.com/oauth2/authorize?...` (the picker still works, your slug is the default) or add `subdomain=` to the authorize URL (the picker is then locked to that account). For a multi-tenant connect flow you generally want neither — but if a customer reports connecting "the wrong company", this is the mechanism. ## 7. Capture the credentials, and pick your flow From **General settings → App credentials**: **client ID**, **client secret**, **signing secret** (used to verify board webhook deliveries, §10) and the app ID. Record also the redirect URLs, the declared scope list, and which app version is live. ### Legacy flow (the documented default) - Authorize: `GET https://auth.monday.com/oauth2/authorize` with `client_id`, `redirect_uri`, `scope`, `state`, and optionally `subdomain`, `app_version_id`, `force_install_if_needed`. - Exchange: `POST https://auth.monday.com/oauth2/token` with `client_id`, `client_secret`, `redirect_uri`, `code`. No `grant_type` is documented for this endpoint. - Response: `access_token`, `token_type: "Bearer"`, `scope`. **No expiry. No refresh token. No revoke endpoint.** The token dies when the user uninstalls the app. - Consequence to state in a security review: **a leaked legacy token is a long-lived bearer credential** carrying everything the authorizing user can reach within the granted scopes, and you cannot revoke it from your side — uninstalling the app on that account is the kill switch. ### OAuth 2.1 flow (opt-in, per app version) Enable it in *OAuth & Permissions* on a **draft** version, test it as Active-for-me, then promote. What changes: - Authorize gains **required** `code_challenge` and `code_challenge_method=S256` (plain is rejected); the verifier is 43–128 characters and must be sent as `code_verifier` at exchange. - Token endpoint moves to `POST https://auth.monday.com/oauth_ms/oauth/token`, JSON body, with an explicit `grant_type` (`authorization_code`, then `refresh_token` for renewals). - Access tokens are **JWTs that expire** — decode `exp`; monday's advice is to refresh when under five minutes remain, and to retry once on a `401`. - **Refresh tokens rotate on every refresh.** Store the newest and discard the old one, or the next refresh fails. - Revocation exists: `POST https://auth.monday.com/oauth_ms/oauth/revoke` with `token`, `client_id`, `client_secret` and an optional `token_type_hint`, answering `{ "success": true }`. **Do not flip this toggle because it looks more modern.** It is a client-side contract change: a connector with no refresh implementation will work for exactly as long as the first access token lives and then fail everywhere at once. Flip it only when the client already implements PKCE, refresh-token rotation and the new endpoint — and verify on a draft version first. Resetting the client secret: monday does not document the effect on already-issued tokens. Assume the worst, never reset without explicit go-ahead and a cutover plan, and verify the effect on one test connection before telling customers anything. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. **The personal API token alternative, and why it is not the answer here.** Every admin and member can mint a personal V2 token from the Developer Center (admins also from *Administration → Connections*). It carries **all permission scopes** and exactly that person's UI permissions, never expires, and is regenerable — regenerating invalidates the old one immediately. Fine for a script or a first read path; not a multi-tenant answer, because it is one person's credential, every action is attributed to them, guests cannot have one at all, and it dies when they leave. If someone proposes personal tokens to skip registration, say this. > **As of 2026-09-20, this platform's monday connector uses the legacy flow.** It sends `client_id`, `redirect_uri`, > `response_type`, `state` and a space-delimited `scope` to `https://auth.monday.com/oauth2/authorize`, **does not use > PKCE**, and exchanges the code by POSTing a **JSON body** of `client_id`, `client_secret`, `redirect_uri` and `code` > to `https://auth.monday.com/oauth2/token`. It stores no refresh token and has no refresh path — correct for the > legacy flow, and fatal under the 2.1 flow. **So the app it is pointed at must have the New OAuth Flow toggle OFF.** > It also accepts a monday API token as an alternative credential for single-account setups. Confirm with the > connector's owner before changing anything in the console. ## 8. API versions — the recurring maintenance cost monday runs **at least three versions in parallel** — Release candidate (unstable), Current (default, stable), Maintenance (stable, previous) — and ships a new RC at the start of each quarter at 12:00 AM UTC. Each version is stable for **at least six months**, and the lifecycle is fixed: RC → Current after 3 months → Maintenance after 3 more → Deprecated, announced *at least* six months in advance. The header is `API-Version: YYYY-MM`, for example `API-Version: 2026-07`. Behaviour worth knowing exactly: | What you send | What you get | | --- | --- | | No header at all | **The Current version** — which changes every quarter, under you | | A well-formed name that never existed (e.g. `2024-02`) | The **Current** version — silently, no error | | A version that has since been **deprecated** | The **Maintenance** version — silently | | A malformed name (e.g. `2023`) | `InvalidVersionException` | | A field that does not exist in the version you asked for | An error: `Field '' doesn't exist on type ''`, code `undefinedField` | monday's guidance is explicit: *"We strongly encourage production applications to pass a version name in each API call. If you don't, your app will always get the Current version."* Every response echoes an `API-Version` header, and the `version` / `versions` queries report the same thing — use them to prove what you are actually running on. **Why this is the recurring cost.** Quarterly Current promotions carry real breaking changes. The **`2026-10`** version, Current from **October 1, 2026**, removes the legacy `User` fields `photo_original`, `photo_thumb`, `photo_thumb_small`, `photo_tiny`, `photo_small`, `is_guest`, `is_admin`, `is_view_only`, `is_pending`, `enabled`, `is_verified`, `join_date`, `encrypt_api_token` and `sign_up_product_kind`, and removes the `kind`, `newest_first` and `non_active` arguments on `Query.users` in favour of `user_kind`, `sort` and `status`. `2026-07` before it capped `Query.users` `limit` at 1,000 and renamed a Connect Boards argument. An unversioned client picks all of that up on the promotion date with no deploy of its own. So: pin a version deliberately, subscribe to the release notes, and diary the next promotion. Pinning does not make the problem go away — it converts a surprise outage into a scheduled migration, which is the whole point. > **As of 2026-09-20, this platform's monday connector sends no `API-Version` header on any call**, so it runs on > whatever monday currently calls Current and will move to `2026-10` on October 1, 2026 without a deploy. Its user > queries select `photo_original`, `photo_small`, `photo_thumb`, `photo_thumb_small`, `photo_tiny`, `join_date`, > `enabled`, `is_admin`, `is_guest`, `is_pending`, `is_view_only` and `is_verified` — **twelve fields that `2026-10` > removes**. This is a dated, testable prediction: run one people/employee read against `API-Version: 2026-10` today > and it either already fails with `undefinedField` or it does not. Raise it with the connector's owner now rather > than on October 1. ## 9. Limits, plans and seats monday does **not** rate-limit primarily by request count. It bills a **complexity budget** per token per minute, plus four other ceilings on top: | Limit | Value | Error | | --- | --- | --- | | Complexity, single query | 5,000,000 points | `ComplexityException` | | Complexity, app (OAuth) tokens | **5M points/minute for reads and 5M for writes, separately** | `COMPLEXITY_BUDGET_EXHAUSTED` (429) | | Complexity, personal tokens | 10M/minute combined; **1M** on trial, NGO and free accounts | as above | | Daily calls | **Free/Standard/Basic 1,000 · Pro 10,000 · Enterprise 25,000**, resetting midnight UTC | `DAILY_LIMIT_EXCEEDED` | | Requests per minute | Other 1,000 · Pro 2,500 · Enterprise 5,000 | `Minute limit rate exceeded` / `Rate Limit Exceeded` | | Concurrency | Other 40 · Pro 100 · Enterprise 250 | `maxConcurrencyExceeded` | | Per IP | 5,000 requests per 10 seconds | `IP_RATE_LIMIT_EXCEEDED` | Notes that change how you design a sync: - **The per-minute complexity budget is a sliding window** that resets 60 seconds after the first call, and reads and writes have separate budgets for app tokens. You can be throttled at a low request count by a few deep nested queries — which is exactly what a naive "fetch the board with all items and all column values" query is. - **Ask the API what a query costs.** Add the `complexity` field (`before`, `query`, `after`, `reset_in_x_seconds`) to any query or mutation; on its own it counts as **0.1** of a daily call, so fold it into real calls rather than polling it separately. - **The daily cap is the one that decides whether your product works.** 1,000 calls/day covers Free, Standard *and* Basic — an initial sync of a moderately sized account can eat it before lunch, and it resets only at midnight UTC. Requests that hit a rate limit still cost **0.1 calls**, and high-complexity queries cost **more than one**. An increase can be requested through monday's API analytics dashboard if an account consistently exceeds it. - **Back off on the numbers monday hands you.** Rate-limit errors carry a `retry_in_seconds` field, responses carry `Retry-After`, and every response carries `RateLimit-Policy` and `RateLimit` headers. Each response also carries a `request_id` in `extensions` — quote it in support tickets. - **Application-level errors arrive as HTTP 200** with an `errors` array, and responses can be **partial**: some fields resolved, others errored. A client that branches only on HTTP status will record silent data loss. - **Plan gating is real but narrow.** The API itself is on every plan. Individual capabilities are not — the `2026-10` board-export query and its status poll, for example, are Enterprise-only. Check the reference page for each operation rather than assuming parity. - **Seat gating is the one customers hit.** Viewers, deactivated or disabled users, users with unconfirmed email addresses and users on student accounts cannot use the API at all: `USER_ACCESS_DENIED` (403), *"The user is unauthorized to use the API."* An admin can also restrict API access by IP, which surfaces as a 401 *"Your ip is restricted"* — from monday's side, not yours. ## 10. Verify end-to-end Authorizing on the account that owns the app, as its admin, on a developer account with 10M complexity, proves almost nothing. Test the path a customer takes: 1. Authorize from a **second monday account you do not administer**, on a **non-Enterprise plan**, through your platform's real connect flow, using the shared link and `force_install_if_needed=true`. 2. Confirm what happens when the app is **not yet installed** there, and that your connect flow shows the customer something better than a blank failure. 3. Read the consent screen: the permissions listed must match the scope list you declared, and the account picker must land on the account the customer means. 4. Confirm the token response shape matches the flow you are on — legacy: no `expires_in`, no `refresh_token`; 2.1: a JWT with `exp`, a refresh token, and a *new* refresh token after one forced refresh. 5. Call one operation per object the connector maps, and **check the `errors` array on every 200**, not the status code. `missingRequiredPermissions` here means a scope you never declared. 6. Repeat one read with `API-Version` set to the **next** version (`2026-10` at time of writing) and diff the result. This is the cheapest possible early warning for §8. 7. Authorize as a **plain member with limited board access**, and confirm the connector degrades sensibly — missing boards, not a crash. Then confirm a **view-only** user fails with a message a support agent can act on. 8. If webhooks are in play: board webhooks are created per board through the API (`webhooks:write`), monday verifies your endpoint by POSTing a `challenge` you must echo back verbatim, and deliveries may carry a **JWT in the `Authorization` header signed with your app's signing secret** — verify it. Then delete the subscription and confirm it is gone. 9. Run a realistic initial sync against an account on a 1,000-call/day plan and watch the daily counter, not just the minute counter. | Symptom | Cause | | --- | --- | | Consent never appears; user lands on an install page or a dead end | App not installed on that account; only an admin can install (§6) | | `403 unauthorized_client` / `403 access_denied` on the redirect | App version not live/shared for that account, or the user declined (§6) | | `403 invalid_scope` | A requested scope is not declared on the app version, or the name does not exist (§5) | | `400 invalid_request` at authorize | Missing or mismatched parameter — usually `redirect_uri` omitted while several are configured (§4) | | Token exchange fails with a valid-looking code | Code older than 10 minutes, `redirect_uri` not identical to the authorize value, or the client is on the wrong token endpoint for the flow the app version is set to (§7) | | Everything 401s some time after connecting, all at once | The app version was switched to the New OAuth Flow; tokens now expire and nothing refreshes them (§7) | | `missingRequiredPermissions` inside an HTTP 200 | The operation exceeds the granted scopes — declare and re-consent (§5) | | `USER_ACCESS_DENIED` (403) | Authorizing user is view-only, deactivated, unverified or on a student account (§9) | | `UserUnauthorizedException` (403) on one board only | The user does not have permission to that board; the token cannot exceed them (§5) | | `Unauthorized` (401) | Token missing or malformed in the `Authorization` header (§7) | | `Your ip is restricted` (401) | The customer's admin restricted API access by IP (§9) | | `Field '' doesn't exist on type ''` / `undefinedField` | You are on a different API version than you think — usually because no `API-Version` header is sent (§8) | | `InvalidVersionException` | Malformed version name, e.g. `2023` instead of `2023-04` (§8) | | `DAILY_LIMIT_EXCEEDED` | The plan's daily call cap; resets midnight UTC (§9) | | `COMPLEXITY_BUDGET_EXHAUSTED` / `ComplexityException` at low request counts | Deep nested queries, not request volume (§9) | | Webhook verified once, then silence | Board webhooks are per board — a new board is not covered by an old subscription (§10) | ## 11. Hand off — never commit the secret - **Do not** write the client secret or signing secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a redirect host, a scope list, an `API-Version` header), keep it secret-free and say what the human must set out of band. - Close with: app name, app ID, and the monday account that owns it; client ID; where the secret was delivered; whether a signing secret was issued and who holds it; the authorize and token URLs **and which of the two flows the app version is set to**; the exact declared scope strings; the redirect URLs; the distribution state (installed / shared by link / submitted to marketplace); the `API-Version` the client sends, or a flag that it sends none; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: someone proposes enabling the **New OAuth Flow** toggle (it is a client contract change, §7); a scope list needs widening (it ships as a new app version and every customer must approve it, §1); a customer's admin will not install the app; someone proposes personal API tokens in place of OAuth; a marketplace submission asks for legal, security, privacy, support-SLA or pricing commitments you were not given; an `API-Version` needs pinning or moving and nobody owns the migration (§8); a client secret reset is proposed without a cutover plan; or the Developer Center does not match the **Platform state** section above. ## References Official monday.com docs only; every URL below returned HTTP 200 on 2026-09-20. The Developer Center itself is reached from your monday profile menu → **Developers** and requires sign-in. - OAuth and permissions (flow, parameters, scope table, error codes) — https://developer.monday.com/apps/docs/oauth - Migrating to the new OAuth 2.1 flow (PKCE, refresh, revoke, discovery, legacy-token migration) — https://developer.monday.com/apps/docs/migrating-to-the-new-oauth-flow - OAuth discovery document (live list of scopes, grant types, auth methods) — https://auth.monday.com/oauth_ms/.well-known/oauth-authorization-server - The Developer Center (tabs, where credentials live) — https://developer.monday.com/apps/docs/the-developer-center - Create an app — https://developer.monday.com/apps/docs/create-an-app - App versioning (draft/live/deprecated, scope-change approval) — https://developer.monday.com/apps/docs/app-versioning - Install and share your apps — https://developer.monday.com/apps/docs/install-and-share-your-apps - App review checklist — https://developer.monday.com/apps/docs/app-review-checklist - App listing guidelines — https://developer.monday.com/apps/docs/app-listing-guidelines - Submit your app (review phases, 72-business-hour initial response) — https://developer.monday.com/apps/docs/submit-your-app - API basics, including **who can use the API** — https://developer.monday.com/api-reference/docs/basics - Authentication (header format, personal tokens, token permissions) — https://developer.monday.com/api-reference/docs/authentication - Developer / sandbox account — https://developer.monday.com/api-reference/docs/developer-sandbox-account - API versioning (header, lifecycle, release schedule, troubleshooting) — https://developer.monday.com/api-reference/docs/api-versioning - Release notes (per-version breaking changes) — https://developer.monday.com/api-reference/docs/release-notes - API changelog — https://developer.monday.com/api-reference/changelog - Apps changelog — https://developer.monday.com/apps/changelog - Rate limits (complexity, daily, minute, concurrency, IP) — https://developer.monday.com/api-reference/docs/rate-limits - Complexity query — https://developer.monday.com/api-reference/docs/complexity - Optimizing API usage — https://developer.monday.com/api-reference/docs/optimizing-api-usage - Errors (codes, 200-with-errors format, `request_id`) — https://developer.monday.com/api-reference/docs/errors - Board webhooks (challenge handshake, JWT signing, scopes) — https://developer.monday.com/api-reference/reference/webhooks - App lifecycle webhooks — https://developer.monday.com/apps/docs/webhooks-1 - Integration authorization (JWT verification, signing secret, short-lived tokens) — https://developer.monday.com/apps/docs/integration-authorization - API analytics (usage dashboard, daily-limit increase requests) — https://developer.monday.com/api-reference/docs/api-analytics - Plans and pricing — https://monday.com/pricing - Marketplace — https://monday.com/marketplace - Status page — https://status.monday.com/ ## netsuite-oauth-app URL: https://docs.unified.to/skills/netsuite-oauth-app/SKILL.md # NetSuite OAuth 2.0 App Registration Get a working NetSuite OAuth 2.0 client — an integration record, redirect URIs, scopes, and the client ID and client secret — for a platform that connects other organizations' NetSuite accounts on behalf of many customers. NetSuite is not like the other vendors in this collection, and four things account for almost all of the lost time. **There is no central developer portal.** The "app" is an *integration record*, and an integration record lives **inside a NetSuite account** — yours. You create it once, in an account you control, and NetSuite then installs a copy of it into each customer account the first time someone there authorizes. You do **not** ask every customer to create their own. That is §1 and §3, and it is the single answer that decides the whole onboarding shape, so read §1 before you promise anyone anything. **Every host is account-specific.** The authorize host, the token host and the API host all contain the customer's account ID, and sandbox account IDs mangle into the hostname by a documented rule. Nothing about a NetSuite host can be hardcoded. That is §7. **A valid token is not access.** NetSuite will hand you a perfectly good access token and then return 401s or permission errors, because a *feature* is off in the customer's account or because the *role* the user picked on the consent screen does not carry the REST Web Services permission. Neither is visible at registration time. That is §8. **The refresh token lasts seven days and Oracle does not document it rotating.** For a confidential client the token endpoint's refresh response is documented as returning an access token only — no new refresh token. Plan the reconnect story before you build, not after the first weekend. That is §9. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Which NetSuite account will own the integration record** | Production, a partner/developer account, or a sandbox — this decides who can ever reset the secret (§2) | | **Administrator credentials for that account** | Creating an integration record and enabling features are Administrator tasks (§2, §8) | | **Integration record name** and description | Customers see this on the consent screen; it becomes read-only in installed copies (§3) | | **Redirect URIs** | Every callback host your platform serves — HTTPS only (§4) | | **Scope set** | `rest_webservices`, `restlets`, `suite_analytics` — pick the narrowest that works (§5) | | **Confidential or public client?** | Confidential gives a 7-day refresh token; public gives a configurable, rotating, one-time-use one and no client secret (§9) | | **A test customer account you can actually authorize against** | Ideally one you do not own, to prove the cross-account install (§11) | | **Does the client re-authorize before the refresh token dies?** | Blocking — a connector that only refreshes will lose every connection in a week (§9) | | **New integration record or an edit to an existing one?** | A new client ID orphans every existing customer connection (§1) | ## Quick Start 1. Establish that you are creating **one** integration record, in your own account, not one per customer (§1). 2. Sign in as Administrator to the NetSuite account that will own it (§2). 3. Enable the OAuth 2.0 feature in that account, and confirm the customer-side feature list (§8). 4. Create the integration record: Authorization Code Grant, scopes, redirect URIs (§3). 5. Add **every** redirect URI exactly (§4). 6. Check the scope boxes that match the scope strings your authorize URL will send (§5). 7. Save once and capture the client ID and client secret — they are shown **only once** (§6). 8. Work out how the customer's account ID reaches every URL before writing client code (§7). 9. Confirm the role and permission story with a real customer role, not Administrator (§8). 10. Decide the reconnect strategy for a seven-day refresh token (§9). 11. Verify end-to-end against a second account (§11), then hand the credentials over (§12). ## Platform state (verified 2026-09-20 — re-verify before trusting) NetSuite's OAuth 2.0 surface has been stable in shape for years, but three things on this page are recent or dated and will move. - **There is no netsuite.com developer console for OAuth apps.** Registration happens at *Setup > Integration > Manage Integrations > New* inside a NetSuite account. Oracle's docs live on `docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/`, are deep-linked by opaque section IDs, and those IDs occasionally move — every link in **References** was checked on 2026-09-20. - **PKCE becomes mandatory for all new integrations as of 2027.1.** Today `code_challenge` is optional for confidential clients, and required for public clients and for the `mcp` scope. Build the PKCE path now; it will stop being optional (§5). - **There is a fourth scope, `mcp`, and it cannot be combined with any other.** Oracle lists the allowed scope values as `restlets`, `rest_webservices`, `suite_analytics` and `mcp`, and documents that `mcp` must be used alone and requires `code_challenge` for both public and confidential clients. `prompt=none` cannot be used with it. - **`plain` PKCE has not been supported since 2020.2.** `code_challenge_method` must be `S256`. - **Token lifetimes**: access token 60 minutes; refresh token **seven days** for confidential clients; **two days, one-time use, configurable 1–720 hours** for public clients. The id_token is only relevant to the separate "NetSuite as OIDC Provider" flow. - **OAuth 2.0 authorizations do not cross environments.** An authorization made in a production account is not copied to Release Preview or to a sandbox, and each sandbox refresh clears what was there. Every environment is its own authorization (§7). If the UI does not look like this — in particular if *Manage Integrations* is missing, which usually means the OAuth 2.0 feature is off — stop and report what you actually see rather than clicking on. ## 1. One integration record, or one per customer? — the crux **One.** You create a single integration record in a NetSuite account you control, and its OAuth 2.0 client ID is the identifier every customer authorizes against. You do not ask customers to create integration records, and you do not collect a consumer key and secret from each of them. What makes that work is Oracle's **auto-installation** behaviour. The integration-record documentation says a record can be distributed to other accounts by bundling or by auto-installation, and the auto-installation page states plainly: > "Auto-installation works with integration records that use user credentials, token-based auth, or OAuth 2.0 for > authentication." > "Whether using The Three-Step TBA Authorization Flow, calling The IssueToken Endpoint, or using OAuth 2.0, an > Integration record is automatically installed in your account." So the first time a user in a customer account completes the authorization code flow against your client ID, a copy of your integration record appears in *their* Manage Integrations list, and their account is connected. Nothing is typed in by the customer. Five consequences that decide real onboarding decisions: - **The owning account owns the secret.** Only an authorized user in the account where the record was created can reset the client ID and secret. Records installed elsewhere are considered owned by a different account and are not fully editable — the installing account can change only **Note** and **State**; the owner's changes sync outward. Pick the owning account deliberately in §2, because you cannot move it later. - **Name and description become read-only in the customer's copy**, and **State, Note and OAuth 2.0 Consent Policy are per-account values** that may differ from yours once installed. - **A customer can block you before you ever see a request.** The *Require Approval during Auto-Installation of Integration* preference (*Setup > Integration > Integration Management > SOAP Web Services Preferences*) defaults to **False**, which enables newly installed records immediately. Set to **True**, your record installs as **Waiting for Approval** and requests are blocked until an administrator enables it. The symptom is `IntegrationBlocked` (§11). - **Sandboxes and Release Preview are separate accounts for this purpose.** Authorizations are not copied from production, and a sandbox refresh clears them. A customer testing in a sandbox authorizes there separately. - **The client credentials (machine-to-machine) flow does not get this.** Oracle states the client credentials setup in one account "isn't copied to any other production account, Release Preview account, or sandbox account" and must be set up explicitly in each. That flow is per-customer work — certificate upload plus an entity/role/application mapping — which is exactly why the authorization code grant is the right shape for a multi-customer connector. **Verify this on a real customer account before you build onboarding around it.** Oracle's auto-installation page is written mostly around SOAP and token-based authentication and names OAuth 2.0 in one sentence; the behaviour is documented but it is worth one round trip against an account you do not own (§11). ### Reuse or register new? A new integration record means a **new client ID, and every existing customer connection is bound to the old one** — every customer would have to reconnect, and your record would auto-install a second time into their accounts. Reuse the existing record for: adding a redirect URI, adding a scope, rotating a compromised secret, or diagnosing a failure. Register a new one only when the user explicitly wants one — a replacement for a compromised record, a separate product, or a deliberate test record in a sandbox. Say which path you are taking first. ## 2. Account: which NetSuite account owns the record There is no free self-serve signup for a NetSuite developer account of the kind other vendors offer. In practice the owning account is one of: - **Your company's own NetSuite production account** — the usual answer if you run NetSuite. The secret can be reset by your administrators, forever. - **A partner or developer account** obtained through Oracle's NetSuite partner programs. Note that Oracle documents development and partner accounts as having a fixed concurrency base limit of **5** that does *not* scale with SuiteCloud Plus licenses (§10) — fine for building, not for load testing. - **A sandbox** — acceptable for a throwaway test record, wrong for the record customers will authorize against, because sandbox refreshes are destructive. You need the **Administrator** role (or a role with Core Administration Permissions) in that account to enable the OAuth 2.0 feature and to create the integration record. Hand control back to the user for anything only they can do: obtaining the account, two-factor enrolment, accepting the SuiteCloud Terms of Service, or deciding which account will own the record permanently. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (§4 redirect URIs, §5 scope boxes), warn them in advance that the client ID and secret appear **only once** on save (§6), and continue once they report back. ## 3. Create the integration record *Setup > Integration > Manage Integrations > New*. | Field | What to put, and why it matters | | --- | --- | | **Name** | The product name customers will see on the consent screen. Becomes **read-only** in every installed copy | | **Description** | Optional; also read-only once installed | | **State** | **Enabled** | | **Authorization Code Grant** | **Check it.** This is the browser flow a multi-customer connector needs | | **Client Credentials (Machine to Machine) Grant** | Leave unchecked unless you specifically need M2M — it is per-customer setup work (§1) | | **Redirect URI** | One or more; see §4 | | **Scope: REST Web Services / RESTlets / SuiteAnalytics Connect / NetSuite AI Connector Service** | Check exactly the ones your authorize URL will send (§5) | | **Public Client** | Leave **unchecked** for a server-side connector — see the trade-off below and in §9 | | **OAuth 2.0 Consent Policy** | `Always Ask` (default), `Never Ask`, or `Ask First Time`. `Never Ask` is auto-approval by an Administrator and is a per-account value that may change once installed | | **Application Logo / Terms of Use / Privacy Policy** | Optional. Logo is JPEG/PNG/GIF; the two policy documents are PDFs taken from the File Cabinet, so upload them there first | | **Dynamic Client Registration** | Only for clients that register without knowing the client ID in advance; requires **Public Client**. Not what a connector needs | Things that cost a cycle if you get them wrong: - **Authorization Code Grant and Client Credentials can both be checked**, but checking Client Credentials does not save you any per-customer work — that flow still needs certificate and mapping setup in every account (§1). - **Public Client is not a small choice.** It removes the client secret, makes PKCE mandatory, and switches the refresh token to a **two-day, one-time-use** token that you can configure between 1 and 720 hours, with a separate *Maximum Time For Token Rotation* setting (default 168 hours). For a confidential client the refresh token is fixed at seven days and Oracle does not document a way to extend it. That makes "public client with a 30-day rotating refresh token" a genuinely tempting option for a long-lived connector — but it means no client secret and a strict one-time-use rotation discipline. Raise it as a decision; do not silently pick one (§9). - **If you check NetSuite AI Connector Service**, Oracle says to clear all other OAuth 2.0 scope boxes and Token-based Authentication. It is not additive. - **Save once.** The credentials appear on that save and only that save (§6). ## 4. Redirect URIs Add **every** callback host your platform serves. The value sent as `redirect_uri` must match one of them **exactly**. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Rules: - **HTTPS only.** Oracle states `http://` is not supported; a custom URL scheme such as `myapp://callback` is accepted for native clients. Transport security must be guaranteed. - **The field takes more than one URI**, and the values are validated when you save the record. Oracle does not publish a maximum count — if a paste is rejected, that is the thing to report, not to guess around. - **A mismatch does not redirect.** This is the NetSuite-specific trap: if `redirect_uri` does not match the integration record, **no redirect happens at all**. The user sees a NetSuite error page, your callback is never called, and your platform sees nothing — not an `error=` parameter, not a failed connection, nothing. The audit-trail name for it is `InvalidRedirectURI`. Any "the user clicks connect and just lands on a NetSuite error screen" report starts here. - **The `redirect_uri` sent at the token exchange must match the one used to get the code**, or the exchange returns `invalid_grant`. ## 5. Scopes NetSuite's scopes are few and coarse. There is no per-record or per-object scope; one scope buys the whole REST surface that the user's role permits. | Scope string | Grants | Integration record checkbox | | --- | --- | --- | | `rest_webservices` | SuiteTalk REST — the record service **and** SuiteQL over REST | **REST Web Services** | | `restlets` | RESTlets (custom SuiteScript endpoints) | **RESTlets** | | `suite_analytics` | SuiteAnalytics Connect (ODBC/JDBC/NetSuite2.com data source) | **SuiteAnalytics Connect** | | `mcp` | The NetSuite AI Connector service. **Must be sent alone**, requires `code_challenge`, and cannot be used with `prompt=none` | **NetSuite AI Connector Service** | Mechanics of the authorize request (§11 has the failure table): - Scopes are **space-delimited** in the `scope` query parameter, and URL-encoded like everything else. - The scope you send must be **enabled on the integration record**. A mismatch between the token's scope and the record's checked boxes surfaces as `ScopeMismatched`, both at authorization and later in the Login Audit Trail. - `response_type` must always be `code`; `client_id` and `redirect_uri` are required. - **`state` is required and must be 22–1024 characters**, printable ASCII, unique per flow. This is unusual — most vendors treat `state` as optional and unconstrained. A short state fails with `InvalidState`, and a state longer than 1024 characters is out of spec too, which matters if you pack a signed token into it. - `code_challenge` / `code_challenge_method` are optional today for confidential clients, **required for public clients and for `mcp`, and required for all new integrations as of 2027.1**. `code_challenge_method` must be `S256`. The `code_verifier` is 43–128 characters from `A-Z a-z 0-9 - . _ ~`. - `prompt` accepts `none`, `login`, `consent`, `login consent` or `consent login`. `login` only works against the account-specific domain, not the generic one (§7). ### Which scope for which read - **REST record service** (`/services/rest/record/v1/...`) — `rest_webservices`. - **SuiteQL over REST** (`POST /services/rest/query/v1/suiteql`, with the required `Prefer: transient` header) — also `rest_webservices`. SuiteQL through REST is part of REST web services; it does **not** need `suite_analytics`. It does enforce the same role-based access restrictions as SuiteAnalytics Workbook, and returns at most 100,000 results across all pages unless the SuiteAnalytics Connect feature is enabled in the account. - **SuiteAnalytics Connect** (ODBC/JDBC against NetSuite2.com) — `suite_analytics`, and a role permission of its own (§8). A REST-only connector does not need this scope and should not ask for it. **Product fact.** As of 2026-09-20, the connector requests exactly one scope for NetSuite — **`rest_webservices`** — for every unified object it supports, read and write alike, with a space delimiter. It never requests `restlets`, `suite_analytics` or `mcp`. So the integration record needs the **REST Web Services** box checked and nothing else, and a customer whose data the connector reaches through SuiteQL is still covered by that one scope. Confirm with the connector's owner before checking extra boxes "to be safe" — an unnecessary scope on the consent screen is a support conversation you do not need. ## 6. Capture the credentials On **Save**, NetSuite shows the client ID and client secret **once**. - **Shown only once.** Oracle is explicit: the values cannot be retrieved later, and if lost you must reset them on the integration record to get new ones. Warn the human *before* they click Save, and have somewhere to paste ready. - **Reset is not free.** Resetting produces a new client ID and secret and breaks every existing customer connection. Only an authorized user in the **owning** account can reset them (§1). - **The vocabulary is doubled.** NetSuite labels these *Consumer Key / Client ID* and *Consumer Secret / Client Secret*. A request for "the NetSuite consumer key and consumer secret" is a request for the OAuth 2.0 client ID and secret. Do not confuse them with the *Application ID* — a separate 32-character value on the same record, used by the older user-credentials SOAP path. - **Endpoints** (all account-specific — see §7): - authorize `https://.app.netsuite.com/app/login/oauth2/authorize.nl` - generic authorize, when the account ID is not yet known: `https://system.netsuite.com/app/login/oauth2/authorize.nl` - token `https://.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token` - revoke `https://.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/revoke` - REST base `https://.suitetalk.api.netsuite.com/services/rest` - RESTlets `https://.restlets.api.netsuite.com` - **Client authentication** at both the token exchange and the refresh is HTTP Basic over `application/x-www-form-urlencoded`: `Authorization: Basic `. Public clients either omit the header and put `client_id` in the body, or send `Basic ` — the trailing colon still matters. - **Revocation** takes the **refresh token** in a `token` form field, and Oracle documents that revoking a refresh token also invalidates the access tokens issued from it. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be reset, then move on. **Product fact.** As of 2026-09-20, the connector exchanges the code with a form POST carrying `redirect_uri`, `code` and `grant_type`, authenticating with HTTP Basic — it does **not** put the client ID or secret in the body — and refreshes with a form POST carrying `refresh_token` and `grant_type`, again over Basic. It **does** send PKCE (`code_challenge_method=S256`). It has the NetSuite revoke endpoint available but the authorize request it builds sends only `client_id`, `redirect_uri`, `response_type`, `state` and `scope` plus the PKCE pair. Confirm with the connector's owner before assuming any of it. ## 7. Account-specific hosts — nothing can be hardcoded Every NetSuite URL that matters contains the customer's account ID, so the connector must build hosts per connection. ``` https://.app.netsuite.com # authorize / UI https://.suitetalk.api.netsuite.com # token, revoke, REST https://.restlets.api.netsuite.com # RESTlets ``` The account ID is not always a plain number, and the mapping into the hostname is a documented transformation. Oracle's rule, verbatim: **"the underscore becomes a hyphen and the capital letters of the acronym become lowercase letters."** | Account ID (as the customer sees it) | Hostname prefix | | --- | --- | | `1234567` (production) | `1234567` | | `1234567_SB1` (sandbox) | `1234567-sb1` | | `1234567_SB` (sandbox) | `1234567-sb` | | `1234567_RP` (Release Preview) | `1234567-rp` | Where the account ID comes from, and why you may not need to ask: - A customer can read it at *Setup > Company > Company Information*, and it is the first label in every NetSuite URL they use. - **You can start the flow without it.** Oracle documents a generic authorize host, `https://system.netsuite.com/app/login/oauth2/authorize.nl`, "if you don't know the specific account ID". The successful callback then carries `code`, `state`, **`role`**, **`entity`** and **`company`** — and `company` **is** the NetSuite account ID. You have it in hand before the token exchange, which is the only leg that needs an account-specific host. Asking the customer to type their account ID is avoidable onboarding friction. - The callback's `role` and `entity` tell you which role and which user authorized — which is precisely what §8 says determines whether your token can read anything. Also true and easy to miss: - **Sandbox and Release Preview are separate accounts.** An authorization made in production is not copied to them, and every sandbox refresh clears the authorizations that were there. A customer who tests in a sandbox and then goes live authorizes twice. - **The realm value in error headers uses the underscore form**, not the hyphenated hostname form. A 401 from REST comes back as `WWW-Authenticate: Bearer realm="123456", error="invalid_token", ...`, where the realm is the account ID the data was requested for — useful for confirming you are talking to the account you think you are. **Product fact.** As of 2026-09-20, the connector asks the customer for their NetSuite account ID up front (it labels it "NetSuite account ID" and points at *Setup > Company > Company Information*, with `1234567_SB1` given as the sandbox example), then substitutes the normalized form into the authorize host, the token host and the REST base URL per connection — underscores to hyphens, trailing acronym lowercased. It accepts the hyphenated form too and converts it back to underscores where NetSuite wants a realm. It also supports the older token-based path (account ID/realm plus a consumer key, consumer secret, token ID and token secret) alongside OAuth 2.0. It does **not** appear to read the account ID from the callback's `company` parameter or use the generic `system.netsuite.com` authorize host. Confirm the current behaviour with the connector's owner. ## 8. Features and the role/permission model — why a valid token still fails This is the part that generates support tickets, and none of it is visible from the integration record. ### Features that must be enabled, in the customer's account - **OAuth 2.0** — *Setup > Company > Enable Features > SuiteCloud* subtab, **Manage Authentication** section, check **OAuth 2.0**, accept the SuiteCloud Terms of Service, Save. If this is off, the authorization fails with `FeatureDisabled` — and the *Manage Integrations* menu may not appear at all, which reads as "NetSuite changed the UI" rather than "a feature is off". - **REST Web Services** — *Setup > Company > Setup Tasks > Enable Features > SuiteCloud*, in the **SuiteTalk (Web Services)** section, with the SuiteCloud Terms of Service accepted. Without it, OAuth succeeds and the REST calls do not. - **SuiteAnalytics Workbook** — *Enable Features > Analytics*. Oracle lists it among the prerequisites for REST web services. - **Client SuiteScript and Server SuiteScript** — both required if you will use OAuth 2.0 for **RESTlets**. Not needed for REST web services alone. - **Token-based Authentication** is a *different* feature, for the older TBA/OAuth 1.0 path. Oracle's "Enable the OAuth 2.0 Feature" steps do not list it as a prerequisite for OAuth 2.0, so do not tell a customer to turn it on for an OAuth 2.0 connector unless the connector actually uses TBA. A missing feature does not say "enable this feature" at the point of failure — you get `FeatureDisabled` in the audit trail, or an ordinary-looking 401/403 at the first API call. When a customer's brand-new connection reads nothing, check features before you check your code. ### Roles and permissions The token is granted **for the role the user chose on the consent screen** (the consent screen even offers a *Choose Another Role* link, and the callback returns the chosen `role`). Everything the token can do is what that role can do. | Permission | Why it matters | | --- | --- | | **Log in using OAuth 2.0 Access Tokens** | Required to use OAuth 2.0 at all for REST web services, RESTlets and SuiteAnalytics Connect | | **REST Web Services** (level Full) | The API permission. **Separate from any record permission** — a role can have full access to customers and still be unable to call REST | | **Log in using Access Tokens** | Listed by Oracle among the required permissions for REST web services | | **SuiteAnalytics Workbook** (level Edit) | Listed among the required permissions for REST web services | | **SuiteAnalytics Connect** or **SuiteAnalytics Connect – Read All** | Only for the `suite_analytics` scope. "Read All" is read access to *all* NetSuite data including employee and customer records — Oracle flags the exposure explicitly | | **OAuth 2.0 Authorized Applications Management** | Administrative only. Oracle states a role with this permission **cannot** access REST web services, RESTlets or SuiteAnalytics Connect using OAuth 2.0 — a genuine trap if someone hands you the "OAuth admin" role to authorize with. It also requires 2FA | Two more facts that shape the ask you make of customers: - **API permission and record permission are separate layers.** Oracle is explicit that record-level permissions are *additionally* required based on the operation — the example given is needing **Find Transaction** to retrieve a record with GET, and subsidiary restrictions when working with employees. A token that lists customers fine and 403s on transactions is a record-permission problem, not a scope problem, and no amount of re-authorizing fixes it. - **Standard roles mostly already work.** Oracle lists roughly three dozen standard roles that ship with the REST Web Services, Log in using Access Tokens and SuiteAnalytics Workbook permissions — Accountant, Bookkeeper, A/P Clerk, A/R Clerk, CFO, System Administrator, Developer and so on. Custom roles are where this breaks, and Oracle explicitly recommends **not** using the Administrator role for REST web services. - **2FA is not a blocker for the API, but it shapes who can authorize.** Administrator and other highly privileged roles are 2FA-required by default and the requirement cannot be removed; Oracle's guidance is that 2FA is not compatible with web services and that you should use OAuth 2.0 or TBA for those roles. The consent flow is an interactive login, so the authorizing user will be taken through their normal 2FA. Tell customers to authorize with a purpose-built integration role that carries the permissions above rather than with Administrator. Ask the customer for a **named role** to authorize with, and have them confirm it carries *Log in using OAuth 2.0 Access Tokens*, *REST Web Services* (Full) and the record permissions for the objects you will sync. That one sentence in an onboarding doc removes most NetSuite support tickets. ## 9. Token lifetimes — the seven-day problem | Token | Lifetime | Rule | | --- | --- | --- | | authorization `code` | short-lived, single use | Reused or stale codes give `invalid_grant` | | `access_token` | **60 minutes** (`expires_in` is always `3600`) | JWT, `PS256`-signed; the payload carries `sub` (role;entity), `aud` (application ID and client ID) and `scope` | | `refresh_token`, confidential client | **seven days** | Oracle's documented refresh **response sample contains only `access_token`, `expires_in` and `token_type`** | | `refresh_token`, public client | **two days by default, one-time use**, configurable 1–720 hours on the integration record, with a separate *Maximum Time For Token Rotation* (default 168 hours) | The refresh response *does* return a new refresh token | Read that table again, because it is the opposite of what most vendors do. 1. **For a confidential client, Oracle documents no rotation and no extension.** The sentence that describes a refresh token coming back on refresh is scoped to public clients: *"If you use public clients with OAuth 2.0, the refresh token request returns an access and refresh token."* The sample confidential response has no `refresh_token` field, and the *Refresh Token Validity* field only appears on the record when **Public Client** is checked. The plain reading is that a confidential-client connection dies **seven days after the user authorized**, no matter how diligently you refresh, and the customer must go through consent again. Build the reconnect prompt; do not discover this on day eight. **Confirm it empirically on your test account** — it is the one claim here whose consequences are large enough to be worth measuring rather than trusting. 2. **A client that does persist a returned refresh token is still correct.** Store whatever comes back and fall back to the existing value when the response omits it — that is right for both client types and costs nothing. 3. **When the refresh token expires the token endpoint returns `invalid_grant`**, and Oracle's instruction is unambiguous: go back to Step One of the flow. There is no silent recovery. 4. **Public client is the documented way to get a longer window** — up to 720 hours (30 days) of refresh-token validity with rotation — at the cost of having no client secret and needing strict one-time-use handling. It is a real architectural choice, not a checkbox. Surface it; do not decide it alone. 5. **Revoking the refresh token invalidates its access tokens**, so a disconnect in your UI should call the revoke endpoint rather than just forgetting the row. **Product fact.** As of 2026-09-20, the connector refreshes with `grant_type=refresh_token` over HTTP Basic and, when NetSuite's response omits a refresh token, **keeps the existing one** — which is the correct handling for a confidential client. It does not appear to compute or store a refresh-token expiry for NetSuite, so nothing in it anticipates the seven-day wall. Raise this with the connector's owner: the reconnect prompt, not the refresh loop, is what keeps NetSuite connections alive. ## 10. Governance, concurrency and quotas NetSuite meters **the customer's account**, not your app — which is the opposite of the connection-ceiling model most vendors use, and it means your customers' other integrations are competing with yours. - **Concurrency is governed at the account level** and covers web services and RESTlet requests combined. - **Base limits by service tier** — legacy tiers: Shared 5, Tier 3 = 2, Tier 2 = 10, Tier 1 / 1+ = 15, Tier 0 = 20. Tiers sold from June 2020: Standard 5, Premium 15, Enterprise 20, Ultimate 20. - **Each SuiteCloud Plus license adds 10** concurrent requests. Ultimate plus five licenses is 20 + 50 = 70. - **Development and partner accounts are fixed at 5** and do not scale with SuiteCloud Plus licenses. - **A customer can cap you specifically.** *Setup > Integration > Integration Management > Integration Governance* lets an administrator allocate a **Concurrency Limit** to an individual integration record — yours included. Oracle's own best practice is a single account limit without per-integration limits, but the field exists and customers use it on "unmanaged external applications". - **Exceeding it is a 429** — the REST error codes `CONCURRENCY_LIMIT_EXCEEDED` (429) and `USER_ERROR` (429, often paired with concurrency limits) are the ones to catch and back off on. - **SuiteQL over REST caps at 100,000 results** across all pages unless SuiteAnalytics Connect is enabled in the account. There is no per-app install cap, no certification gate, and no app-store review for an integration record — the commercial gates NetSuite has are on the customer's licence, not yours. ## 11. Verify end-to-end Testing in the account that owns the record hides the entire cross-account story. Test both. 1. Authorize **your own** account through the platform's real connect flow. Confirm the token response carries a refresh token and that one read call works. 2. Authorize an account **you do not own** — this is the test that proves §1. Afterwards, check *Setup > Integration > Manage Integrations* in that account and confirm your record appears there without anyone having created it. 3. Authorize with a **non-Administrator role** that a real customer would use, and confirm the reads you need still work (§8). If they do not, you have a permission list to give customers, which is the deliverable. 4. Confirm the **account ID handling** end to end with a sandbox account ID containing `_SB1` — authorize host, token host and REST host must all carry the `-sb1` form (§7). 5. Force a **refresh**, then force a **second** refresh. Then check what your client stored: if NetSuite returned no new refresh token, confirm the old one was kept, and note the original issue time so you know when the seven days run out (§9). 6. Call **revoke** and confirm subsequent API calls 401. | Symptom | Cause | | --- | --- | | User clicks connect and lands on a NetSuite error page — no callback at all | `redirect_uri` does not match the integration record; NetSuite does **not** redirect in this case (§4) | | `InvalidRedirectURI` in the audit trail | Same (§4) | | `UnknownIntegration` / `unauthorized_client` at authorize | Wrong or unknown client ID (§6) | | `InvalidState` | `state` missing, reused, or outside 22–1024 characters (§5) | | `InvalidRequest` at authorize | Missing required parameter, or missing/malformed PKCE parameters (§5) | | `UnsupportedResponseType` | `response_type` is not `code` (§5) | | `invalid_scope` / `ScopeMismatched` | Scope string malformed, or not checked on the integration record (§5) | | `FeatureDisabled` | The OAuth 2.0 feature is off in that account (§8) | | `IntegrationBlocked` | The auto-installed record is **Waiting for Approval** or its State is not Enabled — the customer's *Require Approval during Auto-Installation* preference (§1) | | `AuthorizationCodeGrantRequired` | The Authorization Code Grant box is not checked on the record (§3) | | `AuthorizationExplicitlyDenied` / `error=access_denied` | User clicked Deny or Back on the consent screen | | `invalid_client` (401) at the token exchange | Wrong client ID or secret, or the Basic header is malformed (§6) | | `invalid_grant` (400) right after consent | Code expired, already used, `redirect_uri` differs from Step One, or `code_verifier` does not match `code_challenge` (§5) | | `InvalidGrant` naming `code_verifier` | PKCE verifier mismatch between the two legs — check that the verifier is stored per flow and is stable across it (§5) | | `ClientIdMismatch` | Different `client_id` used at Step One and Step Two (§6) | | `invalid_grant` on a connection about a week old | Refresh token passed its seven days — the customer must re-authorize (§9) | | `unsupported_grant_type` (400) | `grant_type` is neither `authorization_code` nor `refresh_token` | | `EntityOrRoleDisabled` | The authorizing user or the role they chose is inactive (§8) | | 401 `invalid_token` / "Invalid login attempt." | Access token expired, revoked, malformed or invalid — refresh, then re-authorize (§9) | | `AccessTokenExpired` in the Login Audit Trail | Refresh, or restart the flow (§9) | | `TokenRejected` / `InvalidTokenType` | A refresh token was sent as a bearer token, or vice versa (§9) | | `InvalidIntegration` | The integration record does not exist in that account — auto-installation did not happen or was removed (§1) | | 403 or a permission violation on a token that authorized cleanly | The authorizing role lacks **REST Web Services**, or lacks the record permission for that object — separate layers (§8) | | Reads work for one object and not another | Record-level permission or subsidiary restriction on that object (§8) | | 429 `CONCURRENCY_LIMIT_EXCEEDED` or `USER_ERROR` | Account-level concurrency governance, possibly a per-integration cap set by the customer (§10) | | Everything worked, then the customer refreshed their sandbox | Sandbox refresh clears OAuth 2.0 authorizations and client-credentials setup (§7) | NetSuite also records all of this: the **Login Audit Trail** shows failed OAuth 2.0 logins with the error name in the **Detail** column. Ask a stuck customer for that row before theorising. ## 12. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a redirect host, a scope box, account-ID normalization, a reconnect prompt), keep it secret-free and say what the human must set out of band. - Close with: the integration record name; **which NetSuite account owns it** and who there can reset the secret; the client ID; where the secret was delivered; the grant type and whether it is a confidential or public client; the exact scope strings and the matching checked boxes; the redirect URIs added; the authorize, token and revoke URL patterns with `` left as a placeholder; the refresh-token lifetime you are planning around; the feature and permission list customers must satisfy (§8); and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the owning NetSuite account has not been decided, or the only account available is a sandbox; someone proposes asking every customer to create their own integration record (that is §1, and it is the wrong shape — say so and show the auto-installation behaviour); the confidential-client seven-day refresh window is incompatible with the product's reconnect story and someone must choose between re-consent prompts and a public client; a customer's account has *Require Approval during Auto-Installation* set to True and needs an administrator to enable your record; the roadmap needs RESTlets, SuiteAnalytics Connect or the `mcp` scope, each of which changes the record's checkboxes and the customer's permission list; a customer wants the `SuiteAnalytics Connect – Read All` permission scoped down, which is a data-exposure decision; resetting the client secret is on the table (it breaks every existing connection); or the portal does not match the **Platform state** section above. ## References - OAuth 2.0 (chapter overview) — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/chapter_157769826287.html - OAuth 2.0 Authorization Code Grant Flow — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_158074210415.html - Step One: GET Request to the Authorization Endpoint (parameters, scopes, state length, PKCE) — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_158081944642.html - Step Two: POST Request to the Token Endpoint (Basic auth, token lifetimes) — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_158081952044.html - Refresh Token POST Request to the Token Endpoint — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_158082518856.html - POST Request to the Revoke Token Endpoint — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_161071224088.html - OAuth 2.0 Access and Refresh Token Structure — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_158255317571.html - Create Integration Records for Applications to Use OAuth 2.0 — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771733782.html - Enabling an Application to Use OAuth 2.0 — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771863841.html - Enable the OAuth 2.0 Feature — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771482304.html - OAuth 2.0 Tasks for Administrators — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771171166.html - Set Up OAuth 2.0 Roles — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157771510070.html - Managing OAuth 2.0 Authorized Applications — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_160855309644.html - Viewing and Revoking OAuth 2.0 Authorized Applications — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157960925955.html - OAuth 2.0 Client Credentials Setup (per-account, not auto-installed) — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_162686838198.html - OAuth 2.0 for REST Web Services — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157780312610.html - Integration Record Overview — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_4389727047.html - Distributing Integration Records — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_4389835426.html - Auto-Installation of Integration Records — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_4429293934.html - Troubleshooting OAuth 2.0 — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157780265265.html - Authorization Code Grant Flow Errors — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_158521803235.html - Authorization Errors in Step One — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/subsect_158521823069.html - Response Errors in Step Two and in the Refresh Token Response — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/subsect_158521832417.html - Authorization Code Grant Flow Error Messages (audit-trail names) — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/subsect_158711892221.html - RESTlets and REST Web Services Authentication Errors — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/subsect_158521885412.html - RESTlets and REST Web Services Error Messages in the Login Audit Trail — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/subsect_158324637123.html - OAuth 2.0 and the Login Audit Trail — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_158323851171.html - Prerequisites and Setup for REST Web Services (features, permissions, standard roles) — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_5085602973.html - Roles and Permission Considerations for APIs — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0320025211.html - Error Handling in REST Web Services (error body, error codes) — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_156570709583.html - REST Web Services URL Schema and Account-Specific URLs — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1546938065.html - Executing SuiteQL Queries Through REST Web Services — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157909186990.html - Understanding Account IDs in URLs (underscore/hyphen rule) — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1498754928.html - URLs for Account-Specific Domains — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1498251763.html - Web Services and RESTlet Concurrency Governance — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1500275531.html - Concurrency Governance Limits Based on Service Tiers and SuiteCloud Plus Licenses — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/bridgehead_1500275603.html - Concurrency Limit per Integration — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/bridgehead_156224824287.html - Mandatory Two-Factor Authentication (2FA) for NetSuite Access — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1532968056.html - Permissions Requiring Two-Factor Authentication (2FA) — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1515446005.html - Sandbox accounts — https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_4609939018.html ## notion-oauth-app URL: https://docs.unified.to/skills/notion-oauth-app/SKILL.md # Notion OAuth2 App Registration Get a working Notion OAuth2 client — an account, a **public connection**, redirect URIs, capabilities, client ID and client secret — for a platform that connects many customers' Notion workspaces. Three things are expensive to get wrong here, and none of them is the signup form: 1. **Internal vs public.** Notion calls its OAuth apps *public connections*; the other kind, an *internal connection*, is one workspace with a static token and **no OAuth flow at all**. The type is chosen at creation and is not a toggle you flip later. A multi-tenant connector needs a public connection (§3). 2. **Installation scope.** A public connection is created as **Any workspace** or **Selected workspaces only**, and that choice is permanent. The restricted one cannot ever be listed on the Marketplace (§3). 3. **The page-level permission model.** A Notion connection sees *nothing* until a workspace member explicitly shares pages or databases with it. A token that authenticates perfectly and returns an empty workspace is almost always this — not a scope problem, because Notion has no per-request scopes at all. §6 is the section that decides whether customers think your connector works. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Connection name**, logo, description | Shown on the consent screen and in the customer's workspace | | **Notion account** to own it, and the **development workspace** | Must be a Workspace Owner (§2) | | **Internal or public** | Decides everything else (§3) | | **Installation scope** — Any workspace, or Selected workspaces only | Permanent, set at creation (§3) | | **Redirect URIs** | Every callback host your platform serves (§4) | | **Capability set** | Content read/update/insert, comments, user information incl. email (§5) | | **Optional Notion template URL** | Offers users a page to duplicate during auth instead of picking pages (§6) | | **Marketplace listing wanted?** | Optional, separate review (§3) | ## Quick Start 1. Confirm a **new** connection is needed — a new client ID orphans every existing customer connection (§1). 2. Sign in to Notion as a **Workspace Owner** of the workspace that will own it (§2). 3. Create a **public connection** in the Developer portal; pick the installation scope carefully — it is permanent (§3). 4. Add **every** redirect URI under **OAuth Domain & URIs** (§4). 5. Set capabilities, including whether user email addresses are exposed (§5). 6. Read the page-sharing model before you promise anyone this works (§6). 7. Capture client ID and client secret from the **Configuration** tab (§7). 8. Confirm token and refresh-token behaviour against the live token response, not from memory (§8). 9. Verify from a **second, unrelated workspace** (§9), then hand the credentials over (§10). ## Platform state (verified 2026-09-20 — re-verify before trusting) Notion reorganised this whole surface in 2026. Anything written before mid-2026 uses the old vocabulary and the old portal URL, and will send you to pages that no longer exist. - **"Integrations" are now "connections."** The docs, the portal and the Notion UI say *connection*; *integration* is called out as the legacy synonym. Expect both words in the wild. - **The Developer portal is new (announced 2026-05-12)** and lives at `https://app.notion.com/developers/connections`. It is one place for **Internal connections**, **Public connections**, **Workers**, and **Personal access tokens**, under a **Build** section in the sidebar. - **Personal access tokens (PATs) exist now**, are user-scoped, and — unlike everything else here — **do expire**, on a date chosen at creation (7 / 30 / 90 / 180 days or 1 year; 1 year if unchosen; changelog 2026-07-02). A PAT is not an OAuth credential and cannot serve a multi-tenant connector. Do not let a "just make a token" shortcut end the run. - **Installation scope** (Any workspace vs Selected workspaces only) is a newer creation-time field and **cannot be changed afterwards**. - **OAuth authorizations now mint a fresh token pair each time** (changelog 2026-06-08) for *new* public connections, where they used to return the existing active token. Existing connections keep the old behaviour. Store the token pair from every successful response, including re-authorizations of the same connection. - **The current API version is `2026-03-11`**, and `Notion-Version` is a required header on every REST request. There have been two breaking versions since 2022: `2025-09-03` (databases split into database containers plus `/v1/data_sources`) and `2026-03-11` (`archived` → `in_trash`, `after` → `position`, `transcription` → `meeting_notes`). - **Rate limits changed 2026-09-09**: a per-connection budget of **600 req/min** on Business and Enterprise and **180 req/min** elsewhere, in a fixed 60-second window, *plus* a separate **per-workspace** limit shared across all of that workspace's connections. So a well-behaved connector can still be throttled by someone else's. Handle 429 **and 529**, and obey `Retry-After`. If the portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing connection, or register a new one A new public connection means a **new client ID, and every existing customer authorization is bound to the old one** — every customer would have to re-authorize and re-pick their pages. Reuse the existing connection for: adding a redirect URI, changing capabilities, rotating a compromised secret, or diagnosing an authorization failure. Register a **new** connection only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, or — the Notion-specific case — because the existing one was created as **Selected workspaces only** and now needs to reach any workspace, or needs a Marketplace listing. That scope cannot be widened; a new connection is the only path, and it is a customer-visible migration, not a config change. Say which path you are taking before you touch the portal. ## 2. Account and workspace - Sign in at `https://www.notion.so/login`, or sign up at `https://www.notion.so/signup`. - **You must be a Workspace Owner** of the workspace you pick to create a connection there. A plain Member cannot. - Every connection is created against a **development workspace** — the workspace that owns it administratively. For a public connection this is *not* a limit on who can install it; installation scope (§3) governs that. - **All internal connections in a workspace are visible to every Workspace Owner**, including ones others created. If the user expects privacy from colleagues, say so now. - A free personal workspace is enough to create and test a public connection. Note that some customer-side behaviour — the higher rate limit, Enterprise connection allow-lists (§6) — only appears on paid plans, so you cannot fully rehearse an Enterprise customer's experience from a free workspace. Hand control back for anything only a human can do: email verification, 2FA, accepting terms, being promoted to Workspace Owner. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the redirect URIs from §4 and the capability selections from §5 — and continue once they report back with the client ID. ## 3. Create the connection — internal vs public This is the fork in the road. Pick before you open the form; the type is chosen at creation. | | Internal connection | Public connection | | --- | --- | --- | | Workspaces | Exactly one | Many, subject to installation scope | | Auth | **Static installation access token. No OAuth.** | **OAuth 2.0**, one token per authorizing user | | Identity | Its own **bot user**, independent of any person | Acts **on behalf of the user who authorized it** | | Page access | A member shares pages via **Add connections**, or the owner via the **Content access** tab | The user picks pages in the **OAuth page picker** | | Marketplace | Never eligible | Eligible only if scope is *Any workspace* | **A multi-tenant connector — a platform that connects other people's Notion workspaces — needs a public connection.** An internal connection has no authorize URL, no client ID and no client secret to obtain; if someone asks for "Notion OAuth credentials" and you end up on the internal page, you are on the wrong page. Internal connections are right for a single team's own automation, and are the fastest way to get a token for testing. ### Creating a public connection **Developer portal → Build → Public connections → Create new connection.** The form asks for: - Connection name and **development workspace** - **Redirect URI(s)** for the OAuth flow (§4) - **Installation scope** — **Any workspace** (Marketplace-eligible) or **Selected workspaces only** (pick the workspaces from the list; never Marketplace-eligible). **Set once at creation, cannot be changed.** If there is any chance of a Marketplace listing, or of an unknown customer installing, choose *Any workspace*. - **Capabilities** (§5) After creation, the **Configuration** tab carries the client ID, the client secret and the connection's authorization URL; **Content access** manages page access; Marketplace listing details live in a separate **Listings** section. ### Creating an internal connection (only if that is genuinely what is wanted) **Developer portal → Build → Internal connections → Create a new connection** → name and workspace → **Configuration** tab for the **Installation access token**. Then grant page access (§6) — a new connection has none, and every request fails until it does. The token can be refreshed from the same tab if it leaks; that invalidates the old one. ### Listing and review Listing on the **Notion Marketplace** is **optional and separate** — a public connection works through its OAuth flow without ever being listed. If the user does want a listing: Marketplace listing dashboard → **Listings → Connections → Start a new connection listing** (name, description, category and tags, images, and the public connection to associate with it), save as a draft, then submit. Notion reviews **every** submission and public connections must pass a **security review** before listing; expect a reply by email in **5–10 business days**, with feedback and a resubmit path if rejected. Only *Any workspace* connections are eligible. Note one inconsistency to watch for: a screenshot in Notion's authorization guide is captioned "The Authorization URL field populates after a public connection is submitted for review," while the Marketplace guide states plainly that listing is optional and a public connection works without it. If the **Authorization URL** field is blank on a freshly created connection, that caption is the thing to check before assuming the portal is broken — and report what you see. ## 4. Redirect URIs Register **every** callback host your platform serves. Notion accepts multiple, under **OAuth Domain & URIs** on the Configuration tab. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` The rule that catches people out: > **`redirect_uri` becomes mandatory in the token-exchange body as soon as more than one redirect URI is configured.** Notion spells it out: `redirect_uri` is required in the `POST /v1/oauth/token` body if the `redirect_uri` query parameter was set on the authorize URL, **or** if the connection has more than one redirect URI configured — and it is *not allowed* in the single-URI-and-no-query-param case. Since a multi-region platform has four, it is always required, and it must be the same value that was sent on the authorize call. A mismatch comes back as `invalid_grant` at the exchange, not at save time. Notion does not publish scheme or host restrictions for redirect URIs (no documented localhost or HTTP rule) — treat matching as exact, register the literal strings above, and do not rely on trailing-slash or subpath forgiveness. ## 5. Capabilities — Notion's answer to scopes **Notion has no OAuth scopes.** The authorize URL takes `client_id`, `redirect_uri`, `response_type=code`, `owner=user` and optional `state` — and nothing else. There is no `scope` parameter to send, and nothing to line up between the app registration and the authorize call. Instead, **capabilities are configured on the connection itself**, in the portal, and the consent screen describes them to the user. | Group | Options | | --- | --- | | Content | **Read content**, **Update content**, **Insert content** — any combination | | Comments | **Read comments**, **Insert comments** | | User information | **No user information** / **User information without email addresses** / **User information with email addresses** | What this means in practice: - Capabilities gate **endpoints and response contents**. Insert-only cannot read full objects; read-only cannot call update endpoints; "no user information" strips names, avatars and emails out of user objects entirely. - **Changing capabilities forces re-authorization.** Notion is explicit: for public connections, users must re-authenticate if the capabilities changed since they last authorized. So capability changes are customer-visible events — plan them like a migration, not a tweak. - Request the minimum. Notion's own guidance is that fewer capabilities make a workspace admin more likely to install the connection, and **User information with email addresses** is the one a security reviewer will ask about. > **As of 2026-09-20, Unified.to's Notion connector** sends no scopes at all on the authorize call (correct — Notion > has none), and passes `owner=user` as Notion requires. Its object coverage implies the capability set it needs: > knowledge-base spaces, pages and **comments** (read and insert), database / table / record / query objects (read, > insert and update content), and a people object built from Notion users that maps **email addresses** — which means > **User information with email addresses**, the most scrutinised setting on the list. Confirm the exact set with the > connector's owner before ticking boxes, and remember that ticking a new one re-authorizes every existing customer. ## 6. The page-sharing permission model — read this before promising anything This is Notion's headline trap, and it has no equivalent at most vendors. **A connection has access to nothing by default.** Authorization does not grant workspace-wide access; it grants access to the specific pages and databases a human explicitly hands over, one at a time. How access is actually granted: - **Public connections** — during the OAuth flow, after the capabilities prompt, Notion shows a **page picker**. The user searches for and selects the pages and databases to share. Only resources the user has *full access* to are offered. **Selecting a parent page grants access to all of its children**, which is the single most useful thing to tell a customer: pick the top-level page or teamspace page, not thirty leaves. - **Internal connections** — the owner uses the **Content access** tab in the portal (**Edit access** → select pages), or any workspace member opens a page → **•••** → **Connections** → **+ Add connection** → pick the connection, and confirms it covers the page and its children. - **After the fact** — a user can share more pages later through the same **Add connections** menu; they do not have to re-run OAuth to widen access. Conversely, hovering a connection's name and pressing **Disconnect** removes access to that page, silently, with no notification to you. Two consequences that cause most "the integration is broken" tickets: 1. **Per-user, not per-workspace.** After a user authorizes a public connection, **only that user** can interact with it or share pages with it. If five people in one workspace want it, all five run the OAuth flow individually and each gets their own token and their own page selection. Do not build on the assumption that one authorization covers a customer's whole company. 2. **An empty workspace is a permission symptom, not a scope bug.** `POST /v1/search` searches only pages and data sources **that have been shared with the connection**, so a token with no shared pages returns an empty list, a 200, and no error. Direct reads return `object_not_found` (404) with a message like *"Make sure the relevant pages and databases are shared with your connection."* Notion states outright that the 404 "can also indicate that the resource has not been shared with owner of the bearer token." There is no capability, no scope and no re- registration that fixes it — a human has to share pages. Two more wrinkles worth knowing: - **Enterprise admins can gate connections.** On Enterprise plans, workspace owners can build an approved-connections list, restrict which connections members may install, and decide whether all members or only the connection owner may connect or disconnect a connection from pages. A customer whose admin has not approved your connection cannot install it, no matter what you register. - **The optional template.** A public connection can offer a Notion page to duplicate during auth (Configuration tab → Basic information → **Notion URL for optional template**). If the user takes it, Notion adds the connection, duplicates the template into their workspace and shares that new page with the connection automatically, returning its id as `duplicated_template_id`. It is the one path that guarantees a non-empty workspace on first connect. ## 7. Capture the credentials From the connection's **Configuration** tab in the Developer portal: - **OAuth client ID** and **OAuth client secret** (public connections), or the **Installation access token** (internal) - The connection's **Authorization URL** - Endpoints, which are fixed and the same for everyone: - authorize — `https://api.notion.com/v1/oauth/authorize` - token / refresh — `https://api.notion.com/v1/oauth/token` - introspect — `https://api.notion.com/v1/oauth/introspect`; revoke — `https://api.notion.com/v1/oauth/revoke` - API base `https://api.notion.com`, and the `Notion-Version` value the client will send (see **Platform state**) The token exchange is **HTTP Basic authentication** — `client_id:client_secret`, base64-encoded, in the `Authorization` header — with a **JSON** body (`grant_type`, `code`, and `redirect_uri` per §4). Not form-encoded, and the credentials do not go in the body. Refresh works the same way: Basic auth, JSON body, `grant_type=refresh_token`. Rotating the client secret breaks every token exchange and refresh until the new value is deployed. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. > **As of 2026-09-20, Unified.to's Notion connector** exchanges and refreshes tokens exactly this way — HTTP Basic auth > from the client credentials with a JSON-encoded POST body, sending `redirect_uri` on the exchange (which is what the > multi-URI rule in §4 requires) — and treats the resulting token as a bearer token. It also accepts a **static > internal-connection token** as an alternative to OAuth for single-workspace customers. It **pins > `Notion-Version: 2022-06-28`**, four breaking versions behind the current `2026-03-11`. That pin is deliberate and > keeps old request/response shapes, but note Notion's rule that *additive* changes ship to every version at once, so > pinning does not freeze responses — new fields will appear. Confirm all of this with the connector's owner before > registering anything or reporting it as settled. ## 8. Token behaviour — verify this, do not recite it Notion's token model changed, and the widely-repeated "Notion tokens never expire, there are no refresh tokens" answer is now wrong on the second half. What the docs say as of 2026-09-20: - **Refresh tokens exist.** The token response carries `refresh_token` alongside `access_token`, and `POST /v1/oauth/token` with `grant_type=refresh_token` "generates a new access token **and a new refresh token**." Treat refresh as **rotating**: store the new pair from every response, or the second refresh fails. - **No expiry is documented for OAuth access tokens.** There is no `expires_in` in the documented token response schema, and token introspection returns `active`, `scope` and `iat` — an issued-at, with no `exp`. Notion does not publish a lifetime for REST-API OAuth access tokens. Build as if they can expire anyway: implement refresh, and treat a 401 `unauthorized` as "refresh and retry once," not "the customer must re-authorize." - **Do not confuse these with the other two token types.** *Notion MCP* access tokens last about eight hours (raised from one hour, changelog 2026-07-14) and do carry `expires_in`. *Personal access tokens* expire on a date chosen at creation, up to a year. Neither figure applies to a public connection's OAuth access token. - **Re-authorization now mints a new pair** for connections created after 2026-06-08 rather than returning the existing active token, so a customer who reconnects gives you a token you must store; the record keyed by `bot_id` is what ties a token pair to an authorization. - **Revocation exists** (`/v1/oauth/revoke`), and a customer can also disconnect the connection from their side, at which point the token stops working with no notice to you. Because Notion has changed this twice in a year, **re-read the authorization guide and the changelog at the start of any run** rather than trusting this section. If the live token response now includes `expires_in`, that is the answer, and this section is stale. ## 9. Verify end-to-end Testing in the workspace that owns the connection proves almost nothing — that workspace's owner sees everything. Test the path a customer takes: 1. Authorize from a **second, unrelated workspace** (a fresh free workspace is fine) through your platform's real connect flow, as a **non-owner Member** if customers will be. 2. At the page picker, deliberately share **one** page. Confirm your connector reads that page *and its children*, and does **not** see the rest of the workspace. That is correct behaviour, and worth showing the user. 3. Confirm the token response carries a **`refresh_token`**, and store `bot_id`, `workspace_id`, `workspace_name`, `owner` and `duplicated_template_id` alongside it. 4. Force a **refresh**, then force a **second** refresh using the token returned by the first — that is what catches a client that ignores rotation. 5. Call `GET /v1/users/me` with the right `Notion-Version` header to confirm the bot user, then `POST /v1/search` to confirm the shared page is visible. 6. If email addresses matter, confirm a user object actually carries `person.email` — if it does not, the capability is set to the no-email variant (§5), and fixing it re-authorizes everyone. | Symptom | Cause | | --- | --- | | Auth succeeds, every list is empty, no error | Nothing shared with the connection (§6) — the default state | | `object_not_found` (404) on a known page | Same: that page was not shared, or was disconnected later (§6) | | `invalid_grant` at token exchange | `redirect_uri` missing from the JSON body, or not identical to the one sent on authorize (§4) | | `invalid_client` / 401 at token exchange | Credentials not sent as base64 HTTP Basic, or sent in the body instead (§7) | | Second refresh fails | Client is not storing the rotated `refresh_token` (§8) | | `unauthorized` (401) on every call | Wrong token type (PAT expired, internal token refreshed) or a revoked/disconnected connection (§8) | | User objects have no email | Capability is "without email addresses" (§5) | | Missing `Notion-Version` → 400 | The header is required on every REST request (see **Platform state**) | | One customer sees data, their colleague sees none | Per-user authorization — each member must run the flow (§6) | | Customer's admin blocks the install | Enterprise approved-connections list (§6) | | 429 despite low traffic | The shared per-workspace rate limit, not yours; obey `Retry-After`, handle 529 too | | Response shapes changed without a version bump | Additive changes ship to every API version at once (§7 note) | ## 10. Hand off — never commit the secret - **Do not** write the client secret, an installation access token or a PAT into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. Notion says the same in its own docs; if a token is exposed, refresh or revoke it from the portal. - If a code change is needed (a redirect host, the `Notion-Version` pin, refresh-token rotation), keep it secret-free and say what the human must set out of band. - Close with: connection name and type (public connection), the owning workspace, **installation scope and the fact that it is permanent**, the client ID, where the secret was delivered, the authorize and token endpoints, the exact capability set, whether email addresses are exposed, the `Notion-Version` in use, the refresh-token behaviour you observed, whether a Marketplace listing was submitted, and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the user wants a **single-workspace** setup and an internal connection or PAT is the right answer (that run does not produce OAuth credentials at all); the installation scope is ambiguous, because it is permanent; a capability change is on the table, because it re-authorizes every existing customer; the existing connection is *Selected workspaces only* and a Marketplace listing is wanted (a new connection and a customer migration); the Marketplace or security review asks for compliance, legal, data-retention or volume claims; a customer's Enterprise admin must approve the connection or share pages (customer-side, not registerable); or the portal does not match the **Platform state** section above. ## References - Overview and connection-type comparison — https://developers.notion.com/guides/get-started/overview - Public connections — https://developers.notion.com/guides/get-started/public-connections - Internal connections — https://developers.notion.com/guides/get-started/internal-connections - Authorization (full OAuth flow) — https://developers.notion.com/guides/get-started/authorization - Personal access tokens — https://developers.notion.com/guides/get-started/personal-access-tokens - Secure API tokens — https://developers.notion.com/guides/get-started/handling-api-keys - Preparing your connection for users — https://developers.notion.com/guides/get-started/preparing-for-users - List on the Marketplace — https://developers.notion.com/guides/get-started/marketplace-listing - Connection capabilities — https://developers.notion.com/reference/capabilities - Create a token — https://developers.notion.com/reference/create-a-token - Refresh a token — https://developers.notion.com/reference/refresh-a-token - Introspect a token — https://developers.notion.com/reference/introspect-token - Revoke a token — https://developers.notion.com/reference/revoke-token - Authentication — https://developers.notion.com/reference/authentication - Status and error codes — https://developers.notion.com/reference/status-codes - Versioning — https://developers.notion.com/reference/versioning - Changes by version — https://developers.notion.com/reference/changes-by-version - Upgrading to 2025-09-03 (databases → data sources) — https://developers.notion.com/guides/get-started/upgrade-guide-2025-09-03 - Request limits and rate limits — https://developers.notion.com/reference/request-limits - Changelog — https://developers.notion.com/page/changelog - Developer portal — https://app.notion.com/developers/connections - Marketplace listing dashboard — https://www.notion.so/profile/connections - Notion Marketplace — https://www.notion.com/integrations/all - Help: add and manage connections with the API — https://www.notion.com/help/add-and-manage-connections-with-the-api - Help: members, admins, guests and groups — https://www.notion.com/help/add-members-admins-guests-and-groups ## pennylane-oauth-app URL: https://docs.unified.to/skills/pennylane-oauth-app/SKILL.md # Pennylane API Credentials **Read this first: Pennylane has no self-serve developer portal where you register an OAuth app and walk away with a client ID and secret.** There is no "create app" button. Pennylane's own OAuth guide says it in one line — "Before using OAuth, register your app with Pennylane by contacting our Partnerships team" — and the API contract terms put teeth on it: "You will have to request an OAuth Client ID to Pennylane ... **Pennylane may or not grant you this Client ID at its discretion.**" Credentials are issued by hand, to accepted partners. If the goal is "get Pennylane OAuth credentials this week," the honest answer is that it depends on a partnership conversation you have to start first, and the run should stop at §3 with that request drafted. What *is* self-serve is different, and it is what a lot of Pennylane integrations actually run on: **each customer generates their own API token inside their own account** — a **Company API token** from Settings → Connectivity → Developers, or a **Firm API token** from Firm settings → Firm Tokens. Both are bearer tokens, both carry scopes the customer picks, both carry an expiration the customer picks, and both are shown exactly once. Decide which of the two models you are building against before anything else (§1). Three more things cost real time here. **One OAuth grant can be a company grant or a firm grant, and they are not the same API** — a firm grant reaches a portfolio of client companies through a different base path, and a connector that assumes "one connection, one company" quietly does the wrong thing (§8). **The generation you build against matters more than usual**: v1 is deprecated, v2 is the stable version, and a separate 2026 behaviour migration finished on **1 July 2026**, taking the old `ledger` scope with it (§9). And **this is a French accounting product** — the ledger, the exports and the invoicing rules are French statutory shapes, and the 2026 roadmap is dominated by French e-invoicing (§11). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Credential model** | Partner OAuth, per-customer Company token, or Firm token? (§1) | | **Partner status** | Is there an open conversation with Pennylane Partnerships, or none yet? (§3) | | **Company or firm customers** | A firm grant is a different base path and a company-selection step (§8) | | **Integration name**, company URL, description | Asked for in the partnership request; also what the consent screen shows (§3) | | **Redirect URIs** | Every callback host, final, up front (§4) | | **Scope set** | The exact `resource:readonly` / `resource:all` strings the connector calls (§5) | | **Does the client store the rotated refresh token?** | Blocking — rotation is immediate and unforgiving (§7) | | **A Pennylane tenant to test against** | Companies self-serve a sandbox; partners request one by email (§3) | | **New app or an edit to an existing one?** | New credentials orphan every existing customer authorization (§2) | ## Quick Start 1. Establish which **credential model** this actually needs (§1). Most wrong turns start here. 2. Check whether existing credentials are worth reusing; re-issuing orphans every existing connection (§2). 3. If partner OAuth: contact Pennylane Partnerships. This gates everything else (§3). 4. Give Pennylane **every** callback host in that same request (§4). 5. Pin the scope strings against the current v2 catalogue — not an older runbook (§5). 6. Capture the client ID and secret the moment they arrive; they cannot be re-read (§6). 7. Confirm the client handles 24-hour access tokens and **rotating** 90-day refresh tokens (§7). 8. Decide what happens when a user grants at **firm** level rather than company level (§8). 9. Confirm you are on **v2** and on the post-migration behaviour (§9). 10. Check the rate limit before promising a sync cadence (§10). 11. Verify with a real authorize → callback → refresh → refresh-again round trip (§12), then hand off (§13). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **OAuth 2.0 is partner-gated.** Registration is "contact the Partnerships team"; the API agreement reserves Pennylane's discretion to refuse. There is no console, no form on the docs site, no way to self-register a client. - **API v1 is deprecated; v2 is the official stable version.** Pennylane's migration page states "The V1 will be completely depreciated by end of 2025. The V2 is now the official stable version," and the use-case page adds that "New capabilities are now added in API v2, while v1 is deprecated and will not receive new features." Building against v1 today is building against a dead generation. - **The separate "2026 API changes" migration has already finished.** Preview from **14 January 2026**, sunset default flip on **8 April 2026**, cleanup — no rollback, opt-out mechanism removed — on **1 July 2026**. As of this verification date all of it is live and permanent. What landed: cursor pagination on the ledger-family list endpoints, ledger-entry ids no longer suffixed with three digits, `created_at`/`updated_at` filtering and sorting removed from ledger entries, default sort flipped to descending, draft and closed-fiscal-year entries no longer filtered out by default, journal/ledger-account/attachment ids replaced by nested objects, and the ledger-attachment upload endpoint replaced by the file-attachments one. - **The `ledger` scope is retired.** It was replaced by `journals:readonly`/`:all`, `ledger_accounts:readonly`/`:all`, `ledger_entries:readonly`/`:all` and `file_attachments:all`. Pennylane auto-added the new scopes on **14 January 2026** to OAuth apps, grants and tokens that already used `ledger`, and told everyone to have users re-authenticate. A new authorize URL should carry the granular scopes; an old runbook that still prints `ledger` is stale (§5). - **Rate limit is 25 requests per 5 seconds on the Company API v2**, applied **per token** — so per generated OAuth token, not per app. The Firm API documents a different figure (5 calls per second). Both return 429 (§10). - **Base host is `https://app.pennylane.com`**, Company API v2 under `/api/external/v2/`, Firm API under `/api/external/firm/v1/`. Pennylane's own 2026 migration guide prints examples against `api.pennylane.com` and the getting-started page prints one against `api.pennylane.com/v1/me` while its verification step uses `app.pennylane.com/api/external/v2/me`. **The docs contradict each other on the host** — treat `app.pennylane.com` as the one to build on, because it is the host used in the pages that describe v2 authentication, and confirm with a live `/me` call before shipping. - **Company API tokens are plan-gated and role-gated.** "Have an Essential plan or higher" and admin access to the company workspace — Executive, Internal Accountant or External Accountant. A customer on a Starter plan does not see the Developers tab at all. Whether the same plan gate applies to an OAuth authorization is **not stated anywhere in the documentation**; do not assume either way. - **`https://pennylane.readme.io/reference/scopes`** — the "official Scopes Reference" that the scopes guide links to as the authoritative list — **404s**. The guide page itself is the working list, and it carries its own caveat that it is "current as of October 2025." If the docs or the settings screens do not look like this, stop and report what you actually see rather than clicking on. ## 1. Which credential, and who issues it A reader arriving here is usually holding the wrong one. Pennylane runs three API surfaces, and the credential decides which one you get: | Credential | Who issues it | Reaches | Notes | | --- | --- | --- | --- | | **Company API token** | The customer, self-serve, in their own company workspace | That **one** company, Company API v2 | Essential plan or higher; admin role; scopes and expiry chosen at creation; shown once | | **Firm API token** | The customer, self-serve, in their firm account | The firm's **portfolio** of client companies, Firm API | One token per firm; a firm can hold several tokens with different scopes | | **OAuth 2.0 client** | **Pennylane**, to accepted partners, at its discretion | Whatever the granting user picks — a company **or** a firm | The only model that scales to many customers without asking each for a secret | Pennylane's own guidance is blunt about the split: use OAuth if you are "an integration partner building a third-party app" or "an accounting firm accessing the Firm API with firm-level tokens," and "Company customers should use API tokens instead." Two traps follow directly: - **A Firm API token is not a Company API token with more reach.** It authenticates against a different base path and a different reference; endpoints, scopes and even the rate-limit figure differ. Treat firm support as a separate piece of work, not a flag. - **Every token is single-company or single-firm.** A customer with three Pennylane companies needs three tokens and three connections. There is no account-discovery endpoint on a company token. > **Product fact — dated.** As of **2026-09-20**, Unified.to's Pennylane connector is **OAuth 2.0 only** — it offers no > customer-supplied-token mode, so a customer who only has a Company API token cannot connect. It targets the Company > API v2 base path on the `app.pennylane.com` host, points its authorize and token calls at that same host, and is > configured to use **its own registered credentials** rather than any shared platform credentials — meaning a > deployment must hold a Pennylane-issued client ID and secret of its own before a single customer can connect. It > records Pennylane's rate limit as **5 requests per second** (the figure the Firm API publishes, not the 25-per-5- > seconds the Company API v2 page publishes), records the sandbox route as an email to Pennylane's partnerships > address, and stores a Typeform page titled "OAuth registration form" as the place to obtain credentials — which is > **not** the partnership contact form Pennylane's own OAuth guide links to. Confirm both with Pennylane and with the > connector's owner before sending anyone to either. ## 2. Reuse the existing credentials, or request new ones New credentials mean a **new client ID, and every existing customer authorization is bound to the old one** — every customer re-authorizes. Reuse what exists for: adding a redirect URI, adding or migrating a scope, rotating a compromised secret, or diagnosing a failure. Request a **new** client only when the user explicitly wants one: a separate product, a replacement for a compromised client, or a deliberate test client. Note Pennylane's warning about losing a secret — "If they are lost, a new OAuth app must be created, which may take time" — which means a lost secret is not a five-minute rotation here, it is a new app and a full customer re-authorization. Guard the secret accordingly (§13). For **per-customer tokens** the calculus is much gentler: each customer's token is independent, so deleting or regenerating one affects exactly one company or firm. Say which path you are taking before you start. ## 3. Getting the credentials: the partnership route ### Path A — Partner OAuth (what a multi-customer connector needs) 1. Contact Pennylane's **Partnerships team**. The OAuth guide links a partnership contact form on `pennylane.com`; the getting-started page gives `partnerships@pennylane.com` as the integrator channel. Use both if unsure which is current. 2. Say what the integration does, which scopes it needs and why, and hand over **every** redirect URI (§4). Pennylane does not document a self-service place to add one later, so treat the list as final at request time. 3. "Once validated, you will receive a Client ID and Client Secret." 4. **Store both immediately.** Pennylane: "Store both Client ID and Client Secret immediately, as they cannot be retrieved later." Pennylane publishes **no review timeline, no cost, no SLA and no eligibility bar** for this. Do not invent one. The technology-partner page describes a three-stage path — development, **certification** ("Faites certifier votre intégration"), then publication to the marketplace — but does not state what certification tests, how long it takes, or whether it gates API access as opposed to listing. Say that it is unknown rather than guessing. ### Path B — Company API token (self-serve, available today) What a customer does in their own account, needing nothing from Pennylane: 1. They must be on an **Essential plan or higher** and hold admin access to the company workspace (Executive, Internal Accountant or External Accountant). 2. **Settings → Connectivity → Developers** → **Generate an API Token**. 3. Name it, choose **Read only** or **Read and write** under **API V2**, choose an expiration — **1 month, 6 months, 12 months, or Unlimited** — and generate. 4. Copy it immediately. "Tokens are not stored in Pennylane and cannot be retrieved later." 5. A company can hold several tokens with different scopes; each request is judged on the scopes of the token used. The right ask is one narrow token per integration, not one shared token for four vendors. 6. Deleting a token is irreversible and immediate. ### Path C — Firm API token **Firm settings → Firm Tokens → Generate an API Token**, then name, scopes, expiration, copy once. One token per firm; several tokens per firm are allowed for different scope combinations. ### A tenant to test against - **Companies** self-serve a sandbox: profile menu → **Test environment** → **Create my sandbox**. They end up with a live account and a separate sandbox account. - **Firms** use their existing firm account; no sandbox is created. - **Integrators** email `partnerships@pennylane.com` with a full name and the email address to use for sandbox creation. There is no self-serve partner sandbox. Rate limiting is enabled on sandbox as well as production, so a sandbox does not buy you head-room for a load test. Anything only a human can do — signing anything, email verification, being made an admin, upgrading a plan, asking for a sandbox — hand back rather than looping. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the redirect URIs from §4 and the scope strings from §5 — or a ready-to-send partnership request for Path A, then continue once they report back. ## 4. Redirect URIs Add **every** callback host your platform serves, at request time. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` What Pennylane documents: `redirect_uri` is required on the authorize call, and the one sent at the token exchange "Must match the one used in Step 2." What Pennylane does **not** document, anywhere on its docs site: how many redirect URIs an app may hold, whether wildcards or subpaths are accepted, whether `http://localhost` is allowed for development, the matching rule (prefix or exact), or how to add one after the app is issued. **Do not state any of these as fact.** Assume exact string matching, no trailing-slash forgiveness, and that adding a region later is an email and a wait — then ask Pennylane to confirm when you request the client. ## 5. Scopes Scopes are **space-separated** in the `scope` query parameter, and every one follows the same pattern: `resource:readonly` for GET-only, `resource:all` for read + write + delete. A token missing the required scope gets **403 Forbidden**, not a 401. The v2 catalogue, as Pennylane's scopes guide spells it: | Domain | Scopes | | --- | --- | | Sales | `customers:readonly` / `:all`, `products:readonly` / `:all`, `customer_invoices:readonly` / `:all`, `quotes:readonly` / `:all`, `customer_mandates:readonly` / `:all`, `billing_subscriptions:readonly` / `:all`, `commercial_documents:readonly` / `:all`, `customer_invoice_templates:readonly` | | Purchases | `suppliers:readonly` / `:all`, `supplier_invoices:readonly` / `:all`, `purchase_requests:readonly` / `:all` | | Accounting | `journals:readonly` / `:all`, `ledger_accounts:readonly` / `:all`, `ledger_entries:readonly` / `:all`, `trial_balance:readonly`, `fiscal_years:readonly`, `exports:fec`, `exports:agl`, `exports:gl`, and the retired `ledger` | | Analytics | `categories:readonly` / `:all` | | Banking | `transactions:readonly` / `:all`, `bank_accounts:readonly` / `:all`, `bank_establishments:readonly` | | Core / shared | `file_attachments:readonly` / `:all` | Four things that look like the portal being broken and are not: 1. **`ledger` is gone.** It only ever worked under the pre-2026 behaviour, and that behaviour was removed on 1 July 2026. Replace it with the granular accounting scopes plus `file_attachments:all`. Apps that held `ledger` before 14 January 2026 had the new scopes added automatically — but their **already-issued access tokens** do not carry them until the user re-authorizes. 2. **Scope changes need re-consent.** An existing token keeps the scopes it was minted with. Adding a scope to the authorize URL changes nothing for connected customers until each one goes through the flow again. 3. **`resource:all` is not "read plus a bit."** It is read + write + **delete**. Asking for `:all` where the connector only reads is an over-scope that a certification review can reasonably object to, and Pennylane's guide leads with least privilege. 4. **`/me` is the cheap way to settle an argument.** It needs no dedicated scope, works for every valid token, and returns the user, the company context and the token's active scopes. > **Product fact — dated.** As of **2026-09-20**, Unified.to's Pennylane connector requests scopes **per unified object > and per direction** rather than one blanket list, sends them space-delimited, and does **not** use PKCE. Its > accounting-ledger objects still request the **retired `ledger` scope** for both read and write — the single most > likely thing in this configuration to be broken, given that scope stopped working on 1 July 2026. Its invoice objects > request `draft_invoices:all` alongside `supplier_invoices:all`, `customer_invoices:all` and `categories:all`; > **`draft_invoices:all` does not appear anywhere in Pennylane's v2 scope catalogue or documentation index**, so it is > either a v1 leftover or undocumented — verify before trusting it. Several read-only objects request write-level > `:all` scopes (customers, suppliers, products, customer and supplier invoices, categories), and the bank-transaction > object maps its *write* direction to the read-only `transactions:readonly`. Organization data is requested with > `fiscal_years:readonly`, bank accounts with `bank_accounts:readonly`, file storage with `file_attachments:readonly` > for read and `file_attachments:all` for write, and analytical categories with `categories:readonly` / `:all`. Two > objects request no scopes at all. Confirm all of this with the connector's owner before you quote a scope list to > Pennylane — the `ledger` and `draft_invoices:all` entries in particular. ## 6. Capture the credentials - **Client ID and client secret** — delivered by Pennylane when the app is validated. **Neither can be retrieved later**; losing them means requesting a new app. - **Authorize** — `https://app.pennylane.com/oauth/authorize`, GET, carrying `client_id`, `redirect_uri`, `response_type=code`, `scope` (space-separated) and `state`. Pennylane marks `state` optional; treat it as mandatory, because it is your only CSRF defence. - **Token and refresh** — `https://app.pennylane.com/oauth/token`, POST. Pennylane's documented example is a form-encoded body carrying `client_id`, `client_secret`, `code`, `redirect_uri` and `grant_type=authorization_code`. A client posting JSON instead is not what the docs show; if the exchange fails with a credentials-shaped error, check the encoding before blaming the secret. - **Revoke** — `https://app.pennylane.com/oauth/revoke`, POST with `client_id`, `client_secret` and `token` (access or refresh). Returns 200 with an empty body. - **Environment** — there is no separate sandbox host. A sandbox is a separate *account* on the same endpoints (§3). Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and what it would cost to replace, then move on. ## 7. Token behaviour: 24 hours, 90 days, and rotation on every use | Token | Lifetime | Rule | | --- | --- | --- | | `access_token` | **24 hours** (`expires_in: 86400`) | Bearer, sent in the `Authorization` header | | `refresh_token` | **90 days** | **Rotates on every use.** Using it immediately invalidates it *and* the old access token | | authorization `code` | not documented | Single use; exchange it immediately | Pennylane calls the rotation behaviour RTR and is explicit about the consequences: "Every time you use a refresh token, it is immediately invalidated, and a brand new refresh token is returned alongside your new access token. Once a refresh token is used, both that token and the old access token will no longer work." Three ways this bites: 1. **A client that reuses the original refresh token succeeds once and fails on the second refresh.** Persist the new refresh token in the same transaction as the new access token. 2. **Concurrent refreshes destroy each other.** Pennylane names this directly — "does not attempt concurrent refresh requests using the same token." Two workers refreshing the same connection at once will leave one of them holding a dead token. Serialise refreshes per connection. 3. **There is no documented grace window.** Unlike some vendors, Pennylane does not say the previous refresh token keeps working for a few minutes. Assume it does not. If a refresh response is lost in flight, the connection may already be unrecoverable and the customer must re-authorize. A refresh token that passes 90 days unused is dead and the customer must go through the authorization-code flow again. Compute and store an expiry at every refresh; nothing in the response tells you when the *refresh* token dies. ## 8. Company or firm — what the user actually granted The consent screen is not a yes/no. Pennylane: "The user is prompted to select one of their available companies or firms and grant access." What comes back is one of two very different things: - **A company token** — scoped to that single company. `GET /api/external/v2/products` returns that company's products and nothing else. This is the shape most connectors assume. - **A firm token** — scoped to the firm. It reaches the firm's portfolio of client companies through the **Firm API** at `/api/external/firm/v1/`, starting with a companies listing (`companies:readonly`), and "subsequent calls must be made in the context of a selected company." Which companies are visible, including confidential ones, depends on the granting user's internal permissions — so two users at the same firm can produce different portfolios. Consequences worth stating out loud before a customer is promised anything: - A connector that only knows the Company API base path will **not** work against a firm grant. It will not fail loudly and helpfully either; the endpoints simply are not there. - "One connection, one company" is a defensible model, but then the connect flow has to tell the customer to grant at company level, and a firm user has to make one connection per client company. - The Firm API is a separate reference with its own scopes (including firm-only ones such as `companies:readonly` and the document-management `dms_files` scopes) and its own published rate limit. Treat firm support as a project. **Product fact — dated.** As of 2026-09-20, Unified.to's Pennylane connector targets the **Company API v2 path only** and has no company-selection step after authorization — it neither lists companies nor stores a company identifier against the connection, relying entirely on the token being a company-scoped token. A customer who grants at firm level should be expected to produce a connection that authorizes cleanly and then reads nothing useful. Confirm with the connector's owner before telling an accounting firm it is supported. ## 9. Which generation you are building against This is the expensive mistake in this vendor, so check it explicitly rather than inheriting it from an old runbook. - **v1 is deprecated** and was slated for complete deprecation by end of 2025; it receives no new features. Its paths carry `/v1`. If anything you are reading, testing against or copying uses a `/v1` path on the Company API, it is the wrong generation. - **v2 is the official stable version**, at `/api/external/v2/`. Its hallmarks: granular scopes, **cursor-based pagination**, one endpoint per resource type on create, file attachments as a first-class resource uploaded before use, and floats passed as **strings** rather than numbers. - **The 2026 behaviour migration is finished** (§ Platform state). The `X-Use-2026-API-Changes` header and its `use_2026_api_changes` query-parameter twin were the opt-in/opt-out lever during preview and sunset; after the cleanup phase on **1 July 2026** the mechanism is disabled. Code still sending either is at best a no-op — and sending the header and the query parameter with conflicting values returned **400 Bad Request** while it was live. - The migration's sharpest edge for a sync engine: **`created_at` / `updated_at` filtering and sorting were removed from the ledger-entries listing.** An incremental sync built on "give me everything updated since X" has to move to id-based paging, the business `date` field, or the changelog endpoints. The changelogs retain **four weeks** of changes — a connector that falls further behind than that cannot catch up from them. - Ledger-entry ids lost their three-digit suffix, so ids captured before the flip do not match ids fetched after it. Anything that stored them needs a re-key, not a re-sync. **Product fact — dated.** As of 2026-09-20, Unified.to's Pennylane connector calls the **v2** Company API throughout (`/api/external/v2/…`) and uses cursor pagination on the object where it reads bank transactions, so it is on the right generation. It does not send the 2026 migration header, which is correct now that the mechanism is retired. ## 10. Rate limits - **Company API v2: 25 requests per 5 seconds**, enforced **per token** — "If you have an OAuth app, it will be applied on each generated token from your OAuth app." So each customer connection gets its own budget; your app as a whole is not throttled as one. - **The Firm API publishes a different figure: 5 API calls per second.** Do not quote one at the other. - Rate limiting is on in **sandbox as well as production**, and all endpoints are affected. - Exceeding it returns **429** with a plain-text body naming the wait. **Honour the headers rather than guessing**: `retry-after` (seconds, 429 only), `ratelimit-limit`, `ratelimit-remaining`, `ratelimit-reset` (a Unix timestamp). The three `ratelimit-*` headers are returned on successful responses too, so a sync can steer before it hits the wall. - Pennylane's own retry guidance: retry 429, 500 and 503; do not retry 400, 401, 403, 404 or 422. ## 11. This is a French accounting product Not a style note — it changes what the integration is allowed to do and what customers will ask for. - **The ledger is a French statutory ledger.** The exports are French statutory exports: **FEC** (`exports:fec`) is the *fichier des écritures comptables* the tax administration requires, alongside the general ledger and the analytical general ledger (`exports:gl`, `exports:agl`). These are scope-gated separately from ordinary ledger reads. - **Ledger entries must balance** — debit total equals credit total — and Pennylane will reject ones that do not. - **Invoice numbering is sequential by law.** Pennylane's own testing guide tells you to configure invoice numbering before testing invoicing, and points at a French-language help article to do it. A connector that creates invoices is inside a legally constrained numbering sequence, not a free-form document store. - **Fiscal periods close and freeze.** Post-migration, the ledger-entry listing no longer hides entries outside the open fiscal year by default, and `date` can now be null — both of which change what a naive sync pulls. - **French e-invoicing dominates the 2026 roadmap.** The changelog through 2026 is Factur-X conversion on invoice import, e-invoicing flow fields and filters, sending customer e-invoices to an approved platform, and validation status fields. If the product touches French invoicing, this is where the requirements will come from. - **Much of the surrounding material is in French** — the partner programme pages, the help centre articles the docs link to, the token-generation screens. The API reference itself is in English. Read the French pages rather than skipping them; that is where the partnership and certification wording lives. - **Two clauses in the API agreement are worth a lawyer's eye, not yours**: Pennylane takes an exclusive, perpetual, irrevocable, sublicensable licence to your API Integration and may request its source code; and the restrictions forbid using the API to offer services similar to, or competing with, Pennylane's. Flag them; do not interpret them. ## 12. Verify end-to-end Do not stop at "the token came back." 1. Call `/me` first. It needs no scope and tells you the user, the **company context** and the token's **active scopes** — which settles most scope arguments in one request. 2. Confirm the token response carries a **refresh token** and note the 24-hour `expires_in`. 3. Make one real read against a v2 path and check the response carries `ratelimit-*` headers (proof you are on the metered v2 surface). 4. **Refresh, then refresh again using the token the first refresh returned.** Two minutes of work; it is the only thing that catches a client ignoring rotation. 5. Read a **ledger-family** endpoint — journals or ledger entries — because that is where the retired `ledger` scope and the new cursor pagination both bite. 6. Authorize a **second** account, and if firms are in scope, deliberately grant at **firm** level and see what your connector does (§8). 7. Watch the rate-limit headers during a real sync, not a single call. | Symptom | Cause | | --- | --- | | 403 on every call, token looks valid | Missing scope for that endpoint — check `/me` for the token's active scopes (§5) | | 403 on ledger, journals or ledger accounts specifically | Still requesting the retired `ledger` scope instead of the granular accounting scopes (§5) | | 401 on every call | Token missing, malformed or revoked; check the `Authorization` header (§6) | | 404 on every call, credential is valid | Wrong base URL or wrong generation — a `/v1` path, or the firm path against a company token (§8, §9) | | Authorization succeeds, then no data anywhere | The user granted at **firm** level and the connector only speaks the Company API path (§8) | | Second refresh fails, the first one worked | Client is not storing the rotated refresh token (§7) | | Refresh fails intermittently under load | Concurrent refreshes on one connection — the loser gets a dead token (§7) | | Worked for months, dead today, nothing changed | A customer's self-serve token hit its chosen expiration, or a 90-day refresh token went unused (§3, §7) | | Customer says there is no Developers tab | They are on a Starter plan, or lack the admin role (§3) | | Incremental sync silently stops finding changes on ledger entries | `created_at`/`updated_at` filtering was removed in the 2026 migration (§9) | | Stored ledger-entry ids no longer resolve | The three-digit id suffix was dropped in the 2026 migration — re-key, do not re-sync (§9) | | `400 Bad Request` mentioning the 2026 changes | Header and query parameter sent with conflicting values, back when the lever existed (§9) | | 429 under light load | 25 requests / 5 seconds per token — honour `retry-after` and `ratelimit-reset` (§10) | | 422 on a ledger entry create | Entry does not balance, or a float was sent as a number instead of a string (§9, §11) | ## 13. Hand off — never commit the secret - **Do not** write the client secret, an API token or a refresh token into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the human running this, for their secret store or console. - Pennylane's secret is **not re-readable and not rotatable in place** — losing it means a new app and a full customer re-authorization. Say so when you hand it over; it raises the care people take. - If a code change is needed (a callback host, a scope migration, storing rotated refresh tokens), keep it secret-free and say plainly what the human must set out of band. - Close with: which credential model you ended up on; the client ID if partner OAuth; where the secret was delivered; the authorize, token and revoke endpoints; the exact scope strings, with any retired or undocumented ones called out; whether the client stores rotated refresh tokens and serialises refreshes; whether company-level or firm-level grants are supported; the rate limit the connector will actually get; and whatever is still waiting on Pennylane. ## Stop and ask Hand back to a human rather than guessing when: **there is no partnership conversation with Pennylane** and the ask is OAuth credentials (say so in the first reply — do not start a registration that cannot complete); the client does not persist rotated refresh tokens or refreshes concurrently (report it, do not register and hope); a customer needs **firm-level** access the connector does not implement (§8); anything still targets **v1** or assumes the pre-July-2026 ledger behaviour (§9); a scope in the current configuration is not in Pennylane's published catalogue (§5); the certification, marketplace-listing or partnership terms ask for commercial, security or compliance claims; the API agreement's exclusive-licence and non-compete clauses are relevant to the product (§11); French e-invoicing or statutory-export obligations are in scope and nobody on the team owns that question; or the documentation and settings screens do not match the **Platform state** section. ## References Verified to resolve on 2026-09-20. - Implement OAuth 2.0 (registration, authorize, token, refresh, revoke, lifetimes) — https://pennylane.readme.io/docs/oauth-20-walkthrough - Understand Scopes (the v2 scope catalogue) — https://pennylane.readme.io/docs/v2-scopes - Introduction to the Pennylane API (auth paths, sandbox creation, `/me`) — https://pennylane.readme.io/docs/getting-started - Choose the Right API (Company vs Firm vs Firm Group) — https://pennylane.readme.io/docs/what-apis-are-available - Create a Company API Token (plan gate, roles, expirations) — https://pennylane.readme.io/docs/generating-my-api-token - Create a Firm API Token — https://pennylane.readme.io/docs/firm-api - Migrate from API v1 to v2 (v1 deprecation) — https://pennylane.readme.io/docs/api-v2-vs-v1 - 2026 API changes Migration Guide (phases, `ledger` scope retirement, pagination, id changes) — https://pennylane.readme.io/docs/2026-api-changes-guide - Rate Limiting in API v2 — https://pennylane.readme.io/docs/rate-limiting-1 - Error Handling & Status Codes — https://pennylane.readme.io/docs/error-handling-status-codes - Use Cursor-Based Pagination — https://pennylane.readme.io/docs/using-cursor-based-pagination - Supported Use Cases (and what is not supported) — https://pennylane.readme.io/docs/supported-use-cases - Webhooks — https://pennylane.readme.io/docs/webhooks - API Changelog — https://pennylane.readme.io/changelog - API contract terms (client-ID discretion, licence and restriction clauses) — https://pennylane.readme.io/page/api-contract-terms - How to reach out — https://pennylane.readme.io/docs/how-to-reach-out-to-us - Full documentation index — https://pennylane.readme.io/llms.txt - Firm API reference — https://firm-pennylane.readme.io/ - Firm API scopes — https://firm-pennylane.readme.io/reference/scopes - Firm API rate limiting — https://firm-pennylane.readme.io/reference/rate-limiting - Partnership contact form (linked from the OAuth guide) — https://www.pennylane.com/fr/contact-demande-de-partenariat/ - Technology partner programme (development → certification → publication) — https://www.pennylane.com/fr/partenaires/partenaire-technologique ## pipedrive-oauth-app URL: https://docs.unified.to/skills/pipedrive-oauth-app/SKILL.md # Pipedrive OAuth2 App Registration Get a working Pipedrive OAuth2 client — a developer sandbox account, an app in Developer Hub, the callback URL, the scope set, client ID and secret — for a platform that connects many customers' Pipedrive accounts. The form itself is two required fields. Four things around it are expensive to get wrong. **One callback URL per app** — Pipedrive allows exactly one, so a platform with several regional callback hosts must decide up front between one app per region and one shared callback (§4). **That same URL also receives uninstall notifications**, as a `DELETE` with HTTP Basic auth, so a redirect handler that only speaks `GET` silently loses uninstall events (§4). **Scopes live on the app, not in the authorize URL** — a `scope` parameter on the authorize request does nothing, the user accepts or denies the whole set, and on an approved public app changing the set sends you back through review (§5). And **there is no fixed API host**: every company answers on its own domain, handed to you in the token response (§6). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name** | Shown on the consent dialog and in the customer's installed-apps list | | **Account / registration email** | For the developer sandbox that will own the app (§2) | | **Public or private app?** | Irreversible once chosen (§3) | | **Callback URL** | Exactly one per app — so this decision comes with §4's regional question | | **Which objects the connector reads and writes** | Decides the scope set (§5) | | **App icon, short summary, full description, 3–5 listing images** | Public app only, checked at review (§7) | | **Website, pricing page, Terms, Privacy Policy, support URL and email** | Public app only, all required at review (§7) | | **Priority of a Marketplace listing** | A private app serves customers today; a public listing takes weeks (§3, §7) | ## Quick Start 1. Confirm a **new app** is actually needed — the client ID of an existing app can never be changed (§1). 2. Request a **developer sandbox account**; it is the only way to see Developer Hub (§2). 3. **Create an app** and pick public or private — the type cannot be changed later (§3). 4. Fill **App name** and the single **Callback URL**; settle the regional question first (§4). 5. Save; you land on **OAuth & access scopes**, where the scopes and the credentials live (§5, §6). 6. Select only the scopes the connector's endpoints actually need, including `activities:*` if it touches activities and `admin` if it manages pipelines, stages or app webhooks (§5). 7. Set the **Installation URL** so your own side starts the flow and can send a `state` value (§4). 8. Capture client ID and secret; wire the exchange as a form-encoded POST with **HTTP Basic** credentials, and store the returned `api_domain` per connection (§6). 9. **Install & test** from the sandbox, verify a real round trip and a refresh (§8), then hand the credentials over — never commit them (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Developer Hub (formerly Marketplace Manager) is only reachable from a developer sandbox account.** It sits at `https://app.pipedrive.com/developer-hub`, or *Settings → (sandbox company name) → Developer Hub*. A normal Pipedrive company account does not show it. The sandbox is requested through a form at `https://developers.pipedrive.com/`. - **One callback URL per app**, stated twice in Pipedrive's own registration docs: *"we allow only one callback URL per app"*. There is no multi-redirect-URI field, and this has been a standing developer request for years. - **App type is public or private, and it cannot be changed after creation.** Private apps skip review entirely and install into **any** Pipedrive company through a direct unlisted link; public apps must pass Marketplace review. Pipedrive's docs carry no install cap for either. - **Public app review currently runs up to 21 business days** — a banner on the approval and update pages, last edited 2025-11-12. Editing a *critical* field on an approved app (callback URL, scopes, installation URL, app extensions) sends the change through review again. - **Token exchange authenticates with HTTP Basic**, not body credentials: *"your request has to be authenticated via HTTP Basic Auth"*, with body credentials documented only as a discouraged fallback. Content type must be `application/x-www-form-urlencoded`, and the authorization code expires in **5 minutes**. - **Access token 60 minutes; refresh token expires after 60 days of non-use.** A refresh returns *the same* refresh token with its window reset to 60 days — Pipedrive does not rotate it. Let it lapse and the customer reinstalls. - **There is no single API host.** The token response carries `api_domain`, the company's own base URL (`https://{companydomain}.pipedrive.com`), and customers can change their company domain from Pipedrive's settings. - **PKCE is not part of the documented flow.** The authorize endpoint takes `client_id`, `redirect_uri` and an optional `state` — nothing else. `state` is self-serve, documented, and strongly recommended. - **Rate limiting is a token/cost budget, not a request count.** A daily budget of 30,000 × plan multiplier × seats, plus a 2-second burst window; v2 endpoints cost roughly half their v1 equivalents. - **API v1 is partly sunset.** A set of v1 endpoints (activities, deals, persons, organizations, products, pipelines, stages, search) was deprecated on 2025-04-14 with availability guaranteed only until **2025-12-31** — a date now in the past. Other v1 endpoints (files, webhooks, filters, the field endpoints) were not in that list and still exist. Webhooks v2 has been the default for new webhooks since **2025-03-17**, with v1 webhooks slated for deprecation during 2026. If Developer Hub does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one **A `client_id` can never be changed or refreshed** — Pipedrive's own wording is that the only way to get a new one is to create a new app, and every existing customer install is bound to the old one. A new app means every customer reinstalls. Reuse the existing app for: adding or removing a scope, correcting the callback URL, refreshing a leaked secret, adding an installation URL, or diagnosing a failing install. On an **approved public app** each of those except the secret is a *critical field*: saving it creates a private copy with pending changes, that copy goes through review, and the change merges into the live app only on approval. Plan for the wait; do not start one casually. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app per callback host (§4), a deliberate public listing alongside an existing private app, or a first app. Say which path you are taking before you touch anything. ## 2. Account: the developer sandbox is the door - **Request a developer sandbox** at `https://developers.pipedrive.com/` — a short form asking for an email address and first name, and submitting it accepts Pipedrive's Terms of Service, Developer Agreement and Privacy Notice. The sandbox behaves as a regular Pipedrive company account, its data is completely separate from any production account, and it is what unlocks Developer Hub. - **Limits worth knowing:** 5 user seats by default; the sandbox is **deleted** if no app is created within 45 days, or if no public app is published / no private app made live within six months of signup. Signing back in and creating or publishing an app prevents that. - **Developer Hub** then lives at `https://app.pipedrive.com/developer-hub` (sign-in required) or under *Settings → (sandbox company name) → Developer Hub*. - Apps are owned by that sandbox company. Use a shared, team-controlled account — not a person who might leave — and record who owns it. Hand control back to the user for anything only they can do: the sandbox request form, signup email verification, 2FA, and accepting the **Pipedrive Developer Partner Agreement** (required before submitting a public app). Do not retry a blocked step in a loop. Before that agreement is accepted, skim it for anything a founder would want flagged — exclusivity, revenue share, data-use commitments, restrictions on resale. Boilerplate needs no commentary; anything unusual gets raised first. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (§4 callback URL, §5 scope list), then continue once they report back with the client ID. ## 3. Create the app — public or private, and the choice is final In Developer Hub: **Create an app** → *Create public app* or *Create private app*. Pipedrive's own warning: *"Do pick your app type carefully, as it cannot be changed later."* | | **Private app** | **Public app** | | --- | --- | --- | | Who can install | Any Pipedrive company, via a direct unlisted installation link | Anyone, from the Marketplace listing | | Review | None | Mandatory approval before anyone outside your own company can install | | Statuses | Draft (your company only) → Live | Draft → In review → Unpublished → Published | | Listing assets | Not required | Icon, 3–5 images, descriptions, pricing page, legal and support URLs | | Reversibility | Live cannot go back to draft | — | For a multi-tenant connector that needs to work **now**, a private app is the honest answer: no cap is documented, and the unlisted link installs into any company. A public listing is a distribution decision, not a technical prerequisite — raise it as such rather than blocking the integration on review. Two required fields save a draft: **App name** and **Callback URL**. A non-functioning callback URL is accepted at draft time and must be replaced before review — and for a private app, before **Change to live**, which validates it. Live private apps cannot be reverted to draft, and every later save goes live immediately. Pipedrive also publishes a `create-pipedrive-app` CLI that scaffolds the OAuth flow, including an HMAC-signed `state`, token exchange and refresh. Useful as a reference implementation; it does not register the app for you. ## 4. The callback URL — one per app, and it has two jobs This platform serves one callback path per data center: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` **Pipedrive accepts exactly one of these per app.** That is the decision this section exists for, and it is not yours to make silently. The options: 1. **One app per callback host** — four apps, four client ID/secret pairs, each region's connections bound to its own app. Every later scope change has to be repeated four times, and a public listing would have to be argued four times over. 2. **One app, one callback host** — the region that host serves is the only one that can complete an install. 3. **One app whose single callback fans out** — only if the platform already has, or will add, a dispatcher that forwards to the right region. That is a platform change, not a portal setting. Confirm the current list and the intended option with the platform owner before you type anything into the form. **The same URL receives uninstall callbacks.** When a user uninstalls, Pipedrive's OAuth server sends a `DELETE` request in JSON to that URL with `client_id`, `company_id`, `user_id` and `timestamp`, authenticated with HTTP Basic using `client_id:client_secret` so you can verify it. Notes that follow from that: the endpoint must accept `DELETE` as well as the `GET` redirect; it is a background request, so `localhost` and `127.0.0.1` will never work; and Pipedrive does not read your response body or status. Other rules: - `redirect_uri` on the authorize request must match the registered value, or the install fails before the user gets anywhere useful (`Invalid request: redirect_uri is invalid`). - **Installation URL** (on the *OAuth & access scopes* tab) is a separate, optional field: it is where Pipedrive sends a user who clicks "Proceed to install" on a Marketplace listing, or who follows a private app's install link. Set it to your own connect entry point when you need to start authorization from your side — which is also the documented way to get a `state` value into the flow. Without it, Pipedrive starts the flow itself and your platform never gets to set `state`. - Changing the callback URL on an **approved public app** is a critical-field edit: pending copy, review, merge. ## 5. Scopes — chosen on the app, accepted all-or-nothing **Scopes are not negotiated in the authorize URL.** The documented authorize parameters are `client_id`, `redirect_uri` and `state`; a `scope` (or `scopes`) query parameter is not part of the flow, and the `scope` field in the token response is described as *"List of scopes selected in the app's settings"*. Whatever a client sends is irrelevant — the app's configuration is the contract. At the consent dialog the user can *"either accept or deny all scopes"*, so every extra scope is an extra reason for someone's admin to say no. Shape of the catalogue: - **`base` is always on** for every app: `GET /users/me`, `/userConnections`, `/userSettings`. That is how a connector identifies the account and reads the company domain without asking for anything else. - **`x:full` includes `x:read`.** `deals:full` grants read access to deals; requesting both is noise. - **Field scopes only come in `full`**: `deal-fields:full`, `contact-fields:full` (persons *and* organizations), `product-fields:full`, `project-fields:full`. There is no read-only variant, so a connector that merely lists custom-field definitions still has to ask for write-capable field access. Expect to justify that at review. - **`admin` is its own thing, not a superset.** It covers creating and editing pipelines and stages, activity types, users and permissions, and app webhooks. It also *"requires the user who is installing an app to have admin rights within the company"* — and the Marketplace does not stop a non-admin from installing anyway, so the connector has to survive a non-admin install. `GET /users/{id}/permissions` is how you find out which one you have. - **`deals:read` explicitly excludes activities.** Pipedrive's own wording: *"Does not include access to activities (except the last and next activity related to a deal)."* Anything that lists, creates or updates activities needs `activities:read` / `activities:full`, and no amount of deal or contact scope substitutes for it. This is the single most common mis-mapping on a Pipedrive connector. - **Search is separate**: `search:read` covers `GET /deals/search`, `/persons/search`, `/organizations/search`, `/leads/search`, `/products/search`, `/projects/search`. - **Webhooks**: `webhooks:read` and `webhooks:full` exist as their own scopes; `admin` also carries `GET/POST /webhooks` and `DELETE /webhooks/{id}`. Pipedrive's app-webhooks guide still says the app "needs to have the admin scope", which predates the dedicated webhook scopes — if webhook creation 403s under `webhooks:full` alone, that inconsistency is the first thing to test, not a bug in your code. A CRM connector's usual set: | Scope | Why | | --- | --- | | `base` | Always granted; identifies the user and yields the company domain | | `deals:read` / `deals:full` | Deals, their fields, files, notes, filters, pipelines and stages (read) | | `contacts:read` / `contacts:full` | Persons and organizations and their related fields, notes, files | | `activities:read` / `activities:full` | Activities/events — **not** covered by deal or contact scopes | | `leads:read` / `leads:full` | Leads and lead labels | | `search:read` | Any `/search` endpoint used to serve a query filter | | `deal-fields:full`, `contact-fields:full` | Custom-field definitions and options | | `users:read` | Users, their permissions and roles — for owner/assignee mapping | | `admin` | Creating or editing pipelines and stages; app webhooks | | `webhooks:full` | App-specific webhooks, if sync uses them | Others exist (`mail:*`, `products:*`, `projects:*`, `goals:*`, `recents:read`, `phone-integration`, `video-calls`, `messengers-integration`). Read the scopes reference rather than guessing a name. > **As of 2026-09-20, this platform's Pipedrive connector** covers companies, contacts, deals, leads, pipelines and > stages, events/activities, files, custom-field metadata and a taxonomy object, plus webhooks. It sends the > authorize request with `client_id`, `redirect_uri` and `state` only — no PKCE — and exchanges the code with a > form-encoded POST authenticated by **HTTP Basic**, taking the per-company host from the token response's > `api_domain`. Its per-object scope map asks for `deals:read`/`deals:full`, `contacts:read`/`contacts:full`, > `leads:read`/`leads:full`, `search:read`, `users:read`, `deal-fields:full`, `contact-fields:full`, > `webhooks:full` and `admin` (for pipeline and stage writes), with `base` on the login flow. **It does not request > `activities:read` or `activities:full`, although its events object reads and writes `/activities`** — so an app > registered from that map alone will 403 on every event call. Add the activities scopes, and confirm the current > map with the connector's owner before submitting. Its object calls are on API **v2** with a few endpoints still on > v1 (files, webhooks, filters, some field reads); a dormant second-generation build of the same connector, derived > from the v1 API description, is not wired into the live product. None of that changes registration: v1 and v2 > share one scope catalogue and one OAuth flow — v2 endpoints simply cost about half as many rate-limit tokens. **Changing scopes later** on an approved public app: a private copy with pending changes is created, you edit scopes there, the copy goes to review, and on approval you merge it into the live app. Pipedrive's docs do not state what happens to already-installed users' tokens, and a token carries the scopes granted when it was issued — assume existing customers keep the old grant until they reinstall, and prove it with a test install before promising either behaviour. ## 6. Capture the credentials From Developer Hub → your app → **OAuth & access scopes** → the *Client ID* section: **client ID** and **client secret**, both re-readable there. Record: - **Client ID** and **client secret**, and whether they belong to a public or private app - Authorize: `GET https://oauth.pipedrive.com/oauth/authorize` - Token exchange and refresh: `POST https://oauth.pipedrive.com/oauth/token` - Revoke: `POST https://oauth.pipedrive.com/oauth/revoke` (RFC 7009; also HTTP Basic) - API base: **per company**, from `api_domain` in the token response — e.g. `https://acme.pipedrive.com`, with endpoints under `/api/v1/…` and `/api/v2/…` - Identity endpoint: `GET /users/me`, which also returns `company_domain` - The exact scope strings selected on the app **Exchange mechanics, precisely as documented:** - `POST https://oauth.pipedrive.com/oauth/token`, content type `application/x-www-form-urlencoded`. - Credentials go in an **`Authorization: Basic base64(client_id:client_secret)`** header. Body credentials are described as a fallback "only when you cannot use HTTP Basic Auth" — if a client sends both, prefer Basic. - Body: `grant_type=authorization_code`, `code`, `redirect_uri`. The code dies after **5 minutes**. - Response: `access_token`, `token_type` (always `bearer`), `refresh_token`, `scope`, `expires_in`, **`api_domain`**. - Pipedrive warns that access-token length grows over time and recommends storing at least `varchar(768)`. **Token behaviour:** - **Access token: 60 minutes.** Refresh, do not re-authorize. - **Refresh: `grant_type=refresh_token` + `refresh_token`, again with Basic auth.** The response returns **the same refresh token**, with its expiry window extended by 60 days, plus a fresh `api_domain`. There is no rotation to handle — but there *is* an idle clock: a refresh token unused for **60 days** expires, and the customer must reinstall from scratch. - **Revocation is asymmetric.** Revoking the `refresh_token` removes all OAuth data and marks the app uninstalled; revoking only the `access_token` leaves the install intact. - **`api_domain` is per connection and can change.** Customers rename their company domain from Pipedrive's own settings. Persist `api_domain` from every token *and refresh* response and use it as the base for every call — never a hard-coded host, and never `api.pipedrive.com` for an OAuth token. - **Migrating an existing API-token integration** is possible with the custom grant `exchange_api_token` (+ `api_token`, Basic auth). Each API token can be exchanged **once**; the users are emailed that the integration is now an OAuth app and must confirm permissions. Delete the API token afterwards. Do not attempt this without an explicit plan from the user — there is no second chance per token. **Refreshing the client secret** ("Refresh" under the Client ID section) does *not* invalidate existing user tokens, but every exchange and refresh with the old value fails from that moment. Never refresh without explicit go-ahead and a deployment plan. The client ID cannot be refreshed at all. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be refreshed, then move on. ## 7. Distribution, review, and limits **Authorization is per user, not per company.** Pipedrive states it directly: *"authorization is user-based, not company-based"* — each user installs and consents for themselves, and the resulting token sees only what that user's permission sets and visibility groups allow. A connection that mysteriously misses records is usually this, not a scope. Admins can push an app to selected or all users, but each of those users still authorizes individually, and **app-sharing modes have to be enabled by Pipedrive on request** (by writing to their marketplace developer contact). **Public app review** is currently quoted at up to 21 business days. The checklist that decides it includes, among much else: the app *"implements and primarily uses OAuth 2.0"* and *"does not collect, store, or unnecessarily use the API token of a user"*; it refreshes the access token when the 60-minute lifetime expires; it requests only necessary scopes; installation **and uninstallation** flows are polished and tested; it handles different user types and permission sets; it respects rate limits; it uses **v2 webhooks** where possible; and the listing carries a unique name, a distinct icon, 3–5 annotated images, a separate pricing page, and support and legal URLs. **Stop and ask** before answering anything you would have to invent: security or compliance questionnaires, retention periods, subprocessors, customer counts, volume projections, pricing, demo videos, or a named responsible contact. A plausible guess about compliance is worse than an unanswered question and expensive to retract. **Rate limits** (worth telling the platform owner about before launch, not after): | Limit | Value | | --- | --- | | Daily budget per company | 30,000 tokens × plan multiplier (Lite 1×, Growth 2×, Premium 5×, Ultimate 7×) × seats | | Indicative costs | ~2 tokens get-one, ~10 update-one, ~20 list, ~40 search — v2 costs about half of v1 | | Burst, OAuth apps | 80 / 160 / 400 / 480 requests per 2-second rolling window by plan | | Burst, API-token access | 20 / 40 / 100 / 120 requests per 2 seconds by plan | | Search API | 10 requests per 2 seconds, all plans | | Headers | `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset` | | Over budget | `429`; sustained abusive `api_token` traffic can be cut off with a `403` at the edge | The budget is **per customer company and shared with everything else they run**, so a full resync can starve the customer's other integrations. Customers are notified at 75% and 100% of their daily budget — and they will ask. **App-specific webhooks**, if the connector uses them: created via the API (not visible in Pipedrive's UI), a maximum of **40 per app user**, deleted automatically when the user uninstalls or the app leaves the Marketplace. Ten consecutive failed first deliveries ban a webhook for 30 minutes; **three consecutive days with no successful delivery deletes it**. Webhooks v2 is the default for new subscriptions. ## 8. Verify end-to-end A registration that half-worked surfaces later as a customer who cannot connect. Prove it: 1. Use **Install & test** in Developer Hub to install the draft app into the sandbox company. 2. Run a real connect through your platform's own flow — not a hand-built URL — and complete the callback. 3. Confirm the connection stored **`api_domain`** and that subsequent calls go to that host, not a default one. 4. Force a **refresh** and confirm the same refresh token still works and the access token changes. 5. Call one endpoint per object the connector maps, including activities, search and a custom-field read — that is where a missing scope shows up as a flat `403`. 6. If webhooks are in play, create one, deliver one, and delete it. 7. Install once as a **non-admin** user if the app requests `admin`, and check the failure is handled rather than fatal. 8. Trigger an **uninstall** and confirm your callback URL received the `DELETE` and verified its Basic auth header. | Symptom | Cause | | --- | --- | | `Invalid request: redirect_uri is invalid` | The callback sent does not match the one registered — and only one can be registered (§4) | | `Invalid client: client is invalid` | Wrong client ID/secret, or the secret was refreshed and not redeployed (§6) | | `Invalid client: cannot retrieve client credentials` | Credentials sent neither as Basic auth nor in the body (§6) | | `Invalid grant: authorization_code has expired` | More than 5 minutes between callback and exchange (§6) | | `Invalid grant: authorization_code is invalid` | The code was already exchanged once | | `Invalid grant: refresh_token is invalid` | Token revoked, user uninstalled, or the 60-day idle window lapsed (§6) | | `content must be application/x-www-form-urlencoded` | Exchange posted as JSON (§6) | | 403 on activities only | `activities:read` / `activities:full` not selected on the app (§5) | | 403 on pipelines, stages, users or webhook creation | `admin` not selected, or the installing user is not an admin (§5) | | 404s or connection errors against a valid-looking host | Calling a fixed host instead of the connection's `api_domain`, or the customer renamed their company domain (§6) | | Works for one user, empty for another in the same company | Per-user authorization plus permission sets / visibility groups (§7) | | `429`, or `403` from the edge, at modest request rates | Daily token budget exhausted, or the 2-second burst window (§7) | | Uninstall never observed | The callback URL does not handle `DELETE`, or points at a local/unreachable host (§4) | ## 9. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a callback host, a scope list), keep it secret-free and say plainly what the human must set out of band. - Close with: app name, type (public/private) and the sandbox account that owns it; client ID; where the secret was delivered; the authorize, token and revoke URLs plus the fact that the API host is per connection; the exact scope strings selected and why; the callback URL registered and which regions it therefore serves; review or live status with dates; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the single-callback-URL constraint forces a choice between one app per region, one region only, or a platform-side dispatcher (§4); an approved public app would have to change a critical field and go back through review; someone proposes refreshing the client secret, or exchanging API tokens with the one-shot `exchange_api_token` grant; the app needs `admin` and nobody has confirmed customers will install as admins; a review or agreement asks for compliance, legal, pricing or volume claims; the sandbox form asks for company details you were not given; or Developer Hub does not match the **Platform state** section above. When the portal behaves in a way the docs did not describe, say what you found rather than picking the option that lets you keep going. ## References Official Pipedrive docs only; every URL below returned HTTP 200 on 2026-09-20. Developer Hub itself is at `https://app.pipedrive.com/developer-hub` and requires sign-in to a developer sandbox account. - Developer sandbox account — https://pipedrive.readme.io/docs/developer-sandbox-account - Sandbox request form / developer portal — https://developers.pipedrive.com/ - Developer Hub — https://pipedrive.readme.io/docs/developer-hub - Registering a public app — https://pipedrive.readme.io/docs/marketplace-registering-the-app - Registering a private app — https://pipedrive.readme.io/docs/marketplace-registering-a-private-app - Creating an app (public vs private) — https://pipedrive.readme.io/docs/marketplace-creating-a-proper-app - Client ID and client secret — https://pipedrive.readme.io/docs/client-id-and-client-secret - OAuth 2.0 overview — https://pipedrive.readme.io/docs/marketplace-oauth-api - OAuth authorization (flow, Basic auth, token fields, refresh) — https://pipedrive.readme.io/docs/marketplace-oauth-authorization - State parameter — https://pipedrive.readme.io/docs/marketplace-oauth-authorization-state-parameter - Scopes and permission explanations — https://pipedrive.readme.io/docs/marketplace-scopes-and-permissions-explanations - OAuth status codes — https://pipedrive.readme.io/docs/marketplace-oauth-and-api-proxy-status-codes - App installation flows — https://pipedrive.readme.io/docs/app-installation-flows - Handling user app uninstallation (and token revocation) — https://pipedrive.readme.io/docs/app-uninstallation - App sharing: adding apps to multiple users — https://pipedrive.readme.io/docs/app-sharing-adding-apps-to-multiple-users - App approval process and checklist — https://pipedrive.readme.io/docs/marketplace-app-approval-process - Updating approved apps (critical vs non-critical fields) — https://pipedrive.readme.io/docs/marketplace-updating-the-existing-app - Pipedrive Developer Partner Agreement — https://pipedrive.readme.io/docs/marketplace-vendor-agreement - Migrating existing integration users (`exchange_api_token`) — https://pipedrive.readme.io/docs/marketplace-migrating-existing-integration-users - Authentication overview — https://pipedrive.readme.io/docs/core-api-concepts-authentication - Rate limiting (token budget) — https://pipedrive.readme.io/docs/core-api-concepts-rate-limiting - How to get the company domain — https://pipedrive.readme.io/docs/how-to-get-the-company-domain - Getting user data (`/users/me`) — https://pipedrive.readme.io/docs/marketplace-getting-user-data - Webhooks for apps — https://pipedrive.readme.io/docs/webhooks-for-apps - Guide for Webhooks v2 — https://pipedrive.readme.io/docs/guide-for-webhooks-v2 - API v2 overview — https://pipedrive.readme.io/docs/pipedrive-api-v2 - API v2 migration guide — https://pipedrive.readme.io/docs/pipedrive-api-v2-migration-guide - Changelog: deprecation of selected API v1 endpoints — https://developers.pipedrive.com/changelog/post/deprecation-of-selected-api-v1-endpoints - Changelog: Webhooks v2 becomes the default — https://developers.pipedrive.com/changelog/post/breaking-change-webhooks-v2-will-become-the-new-default-version - create-pipedrive-app CLI — https://pipedrive.readme.io/docs/create-pipedrive-app-cli - FAQ — https://pipedrive.readme.io/docs/faq - Changing a company domain (support) — https://support.pipedrive.com/en/article/changing-a-company-domain-in-pipedrive ## quickbooks-oauth-app URL: https://docs.unified.to/skills/quickbooks-oauth-app/SKILL.md # QuickBooks Online (Intuit) OAuth2 App Registration Get a working Intuit OAuth2 client — a developer account, an app, redirect URIs, scopes, and the client ID and secret — for a platform that connects customer QuickBooks Online companies on behalf of many customers. Three things here cost real time. First, **one Intuit app carries two credential pairs** — Development and Production — and they are not interchangeable: sandbox keys never reach a production company, and the failure surfaces as a 403 at the first API call, not at registration. Second, **production keys are a review gate**, not a button: Intuit requires an app assessment questionnaire from every app that touches production data, listed on the app store or not, and a "Not approved" result can leave you unable to resubmit. Third, **Intuit returns a `realmId` on the callback and rotates refresh tokens**; a client that drops either one authorizes fine and then dies quietly — the first at the first API call, the second a day or two later. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, logo, description | Shown on the Intuit consent screen | | **Intuit developer account / workspace** | Existing, or a new signup (§2) | | **New app or an edit to an existing one?** | New keys orphan every existing connection (§1) | | **Redirect URIs** | Every callback host, per environment (§5) | | **Which surface**: Accounting, Payments, or both | Decides scopes and connector (§6, §11) | | **EULA URL and Privacy Policy URL** | Required before production keys are issued (§7) | | **Host domain, Launch / Disconnect / Connect URLs** | Portal app-settings fields (§4, §7) | | **Target industries / regulated-industry answer** | Asked in the questionnaire; a wrong answer can fail it (§7) | | **Does the client store the rotated refresh token and the realmId?** | Blocking — see §9 | ## Quick Start 1. Confirm a **new app** is actually needed — existing connections are bound to the current client ID (§1). 2. Sign in to, or create, the Intuit Developer account and workspace (§2). 3. Read the dated platform notes before trusting any older runbook (§3). 4. Create the app and pick its product line and scopes (§4). 5. Understand the **two key pairs** before touching anything else (§4 → §5). 6. Add **every** redirect URI, under **both** Development and Production (§5). 7. Request the narrow scope set the connector actually calls (§6). 8. Work the **going-live review** — questionnaire, URLs, industries — to get production keys (§7). 9. Capture client ID and secret per environment (§8). 10. Verify a real authorize → callback → token → refresh → read round trip (§10). 11. Hand the credentials over — never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Authorize and token endpoints are the same for sandbox and production.** Confirmed live from Intuit's own OIDC discovery documents on 2026-09-20: both `openid_configuration` and `openid_sandbox_configuration` return `authorization_endpoint` `https://appcenter.intuit.com/connect/oauth2`, `token_endpoint` `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer`, and `revocation_endpoint` `https://developer.api.intuit.com/v2/oauth2/tokens/revoke`. Only `userinfo_endpoint` differs (`accounts.platform.intuit.com` vs `sandbox-accounts.platform.intuit.com`). **The environment is selected by which key pair you send, not by which URL you call** — which is exactly why mixing them is so easy and so silent. - **Production keys are gated behind the app assessment questionnaire.** Every app with connections to production QuickBooks Online companies must submit it, private/unlisted included. It is a self-assessment plus attestations, not a code review — but it is a gate, and it is separate from app-store review. - **App-store listing is a different, deeper process**: technical, legal, security and marketing review, repeated annually. Intuit also states that apps **not** on the app store must meet the store's security requirements once they exceed **500 connections**, and that it checks all apps annually. A multi-tenant connector crosses that line. - **Minor version 75 is both the default and the floor.** Support for minor versions 1–74 of the Accounting API was discontinued on 2025-08-01; a lower `minorversion` value is ignored and 75 is served. - **Scopes are additive-only on an app**: you can add a scope, you cannot remove one. - **Up to 25 redirect URIs per app.** Production URIs must be `https`, and cannot be `localhost` or an IP address. - **Sandbox**: up to 10 sandbox companies per developer account, valid for two years, not renewable — create a new one when it expires, and ignore any prompt to enter payment details. - **Webhooks are not registerable by API.** The webhook endpoint URL and entity subscriptions are configured by hand in the developer portal, per environment. If the portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer connection is bound to the old one** — every customer would have to re-authorize, and each re-authorization is a company-admin action you cannot perform for them. Reuse the existing app for: adding a redirect URI, adding a scope, rotating a compromised secret, filling in app settings for the going-live review, or diagnosing an authorization failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product or surface (Accounting vs Payments vs Desktop), or a deliberate migration. Say which path you are taking before you touch the portal. ## 2. Account: sign in or sign up - **Developer portal**: `https://developer.intuit.com/` — sign in, then **My Hub → App dashboard**. Workspaces at `https://developer.intuit.com/workspaces`. - **Sandbox companies**: **My Hub → Sandboxes**, or `https://developer.intuit.com/sandbox-companies`. A developer account is provisioned with a sandbox company; you can add more up to the cap. - **Team access**: apps belong to a workspace and everyone on the app **Team** can see the app, its keys and the assessment questionnaire. Add the people who will maintain it before you need them at 2am. Hand control back to the user for anything only a human can do: account signup and email verification, CAPTCHA, MFA enrollment, accepting Intuit's Developer Terms of Service, and the assessment questionnaire's attestations. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste — the redirect URIs from §5 and the scope strings from §6 — then continue once they report back with the client ID. ## 3. Create the app From the app dashboard, create an app and choose its **product line**. For a QuickBooks Online connector that is **"QuickBooks Online and Payments"**; the portal also offers ProConnect Tax and QuickBooks Desktop lines, which are different products with different scopes. The scope picker at creation offers **Accounting** and **Payments (US only)**. Pick what the connector actually calls — you can add later, you cannot remove (§0 platform state). Beta-gated fine-grained scopes (retirement / payroll, data-integration) appear only for allowlisted apps; if the user asks for one that is not offered, that is a request to Intuit, not a checkbox. Then work through **Settings** on the app, because several of these fields are what the going-live review checks: | Setting | What it is | | --- | --- | | **Keys & credentials** | Client ID and secret, with a Development / Production toggle (§4) | | **Redirect URIs** | Per environment; Development and Production are separate lists (§5) | | **App URLs** | Host domain, Launch URL, Disconnect URL, Connect/Reconnect URL — also per environment | | **App terms of service** | EULA URL and Privacy Policy URL — required for production keys | | **App categories** | e.g. Accounting | | **Accepted connections** | Which countries may connect | | **Regulated industries** | Answer this honestly and *before* the questionnaire — see §7 | Basic app info (name and logo) is what renders on the consent screen. Developers have reported the App Center showing "Sorry, but *undefined* didn't connect" when app profile data has not resolved — a blank app name on a consent screen is a portal-side symptom, not a bug in your authorize URL. ## 4. Sandbox vs production keys **This is the section that catches people.** One app, two independent credential pairs: | | Development keys | Production keys | | --- | --- | --- | | Connect to | Sandbox companies only | Live QuickBooks Online companies only | | API host | `https://sandbox-quickbooks.api.intuit.com` | `https://quickbooks.api.intuit.com` | | Userinfo host | `sandbox-accounts.platform.intuit.com` | `accounts.platform.intuit.com` | | Authorize / token / revoke | **Identical to production** | **Identical to development** | | Redirect URIs | Its own list; may include `localhost` (http allowed) | Its own list; `https` only, no `localhost`, no IPs | | Available | Immediately | Only after the going-live review (§7) | Rules that follow from that table: - **Use a pair as a pair.** A Development client ID with a Production secret, or vice versa, fails in ways that read like an Intuit outage. - **Sandbox keys against a production company (or the reverse) is the classic 403.** Intuit's own guidance: a 403 usually means the key type does not match the company type. Expect `ApplicationAuthorizationFailed` / error code `003100` on every data call while OAuth itself looks perfectly healthy. - **Because the authorize URL is identical**, a misconfigured environment gets all the way through consent before anything goes wrong. Do not treat "the user saw the consent screen" as evidence the environment is right. - **The API host is the tell.** Whatever picks the base URL — config, environment flag, connection record — must be driven by the same switch that picks the key pair, or the two drift apart. - Development credentials remain useful after go-live; keep both pairs, clearly labelled, and never let a sandbox value reach a production config. ## 5. Redirect URIs Register **every** callback host your platform serves, in **both** the Development and Production lists. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes: - **Exact match, and Intuit means it**: the `https` scheme, the same case, and the same trailing `/`. No subdirectory matching, no trailing-slash forgiveness. A mismatch is rejected at connect time, with a message telling you to list it on the app's keys tab — which it already is, just not byte-identically. - IP addresses are never allowed. `localhost` is allowed for **sandbox only**, and without HTTPS there. - Up to **25** URIs per app. Adding a data center later is an edit to this list, not a new app. - The portal pre-populates the OAuth Playground's redirect URL. Leave it — it is what makes §10's playground check possible — but do not mistake it for your callback. - **Confirm the save persisted.** Reload the page and re-read the list. Silent save failures on production settings have been reported; a URI that did not stick looks exactly like a client sending the wrong value. ## 6. Scopes Space-delimited in the authorize URL. The strings, exactly as Intuit spells them: | Scope | Why | | --- | --- | | `com.intuit.quickbooks.accounting` | The QuickBooks Online Accounting API — the connector's data scope | | `com.intuit.quickbooks.payment` | The QuickBooks Payments API (US only) — a **separate** surface, see §11 | | `openid` | OpenID Connect processing; required to receive an ID token | | `profile` `email` `phone` `address` | Identity claims only — no accounting data | | `com.intuit.quickbooks.payroll*` | Payroll APIs, allowlisted beta apps only — do not request speculatively | Points worth holding on to: - **Identity scopes are not data scopes.** `openid email profile` gets you a signed-in user and a userinfo call; it gets you nothing from the Accounting API. A token minted with only identity scopes produces 403s on every entity call — one of the two shapes of "auth worked, API didn't". - The authorize request must also carry `response_type=code`, `client_id`, `redirect_uri` and a `state` you verify on the way back (Intuit documents `state` as required, for CSRF). - A scope the app does not have enabled comes back as `invalid_scope` **on the authorize URL**, before consent. GraphQL and beta scopes that are not on the app's permissions page cannot be requested at all. - Scopes are additive on the app but **each authorization is bound to the scopes it was granted with**. Adding a scope to the app does not upgrade existing tokens; customers re-authorize to pick it up. ## 7. Going live: the review that issues production keys Production keys are not available on demand. Two distinct processes exist and they are frequently confused: **A. The app assessment questionnaire — required for everyone.** - **Where**: app dashboard → **Production Settings** tab → **App assessment questionnaire** in the left nav. - **Who**: every developer whose app connects to one or more production QuickBooks Online companies. Explicitly including private, unlisted apps — Intuit's FAQ answers that question directly, with "Yes". - **What it asks**: attestations that you meet Intuit's platform requirements; information about how the app uses the QuickBooks platform; and extra questions if the app operates in certain industries. Intuit's help content puts it at roughly **40 minutes** once you have the answers collected. Status typically appears on the app dashboard within minutes of submission. - **Mechanics that bite**: answers must be **Saved** explicitly or they are lost on navigation; you can edit freely until you **Submit**, and **not at all afterwards**; resubmission is only offered when Intuit asks you to make changes. Developers have reported a "Completed / Not approved" result with no stated reason and no resubmit option, requiring a support ticket to reopen. **Get the answers right the first time.** - **Regulated industries is part of this.** An app that only reads accounting data should say so. A stray regulated-industry selection has been reported as the thing that failed an otherwise fine assessment. - **Before you can submit**, fill in the app's **EULA URL** and **Privacy Policy URL** under Production, plus host domain and the Launch / Disconnect / Connect URLs, and select target industries. Intuit's go-live page routes you to **Production → Keys & OAuth** for the keys themselves. **B. App-store listing review — only if you want to be listed.** Technical, legal, security and marketing review, run annually thereafter, with a third-party security vendor contacting you twelve months after you pass. A private connector does **not** need this. **But note the 500-connection rule.** Intuit's security requirements state that apps *not* published on the app store must still meet those requirements once they exceed **500 connections**, and that Intuit checks all apps annually. A connector that succeeds will cross that threshold. Raise it with the user early; it is a security and compliance commitment, not a portal setting. **Stop and ask, do not guess.** The questionnaire asks about data handling, retention, encryption, breach process, sub-processors and industry exposure. Those are the platform owner's answers, not yours. Collect them; do not compose them. ## 8. Capture the credentials App dashboard → your app → **Keys & credentials**, then toggle **Development** or **Production**. Capture, per environment: - **Client ID** and **Client secret** — as a matched pair, labelled with their environment - **Authorize URL**: `https://appcenter.intuit.com/connect/oauth2` - **Token URL**: `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer` (HTTP Basic with `client_id:client_secret`, `application/x-www-form-urlencoded` body) - **Revoke URL**: `https://developer.api.intuit.com/v2/oauth2/tokens/revoke` - **API base URL** for that environment (see the §4 table) - The app's **App ID**, for support tickets Unlike portals that show a secret once, Intuit's Keys & credentials page displays the pair for re-reading, and the secret can be rotated there. Rotation invalidates the old secret: existing access tokens live out their hour, but every token exchange and refresh fails until the new secret is deployed. Never rotate without a go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 9. Token lifetimes, rotation, and the realmId Four facts, and a connector that misses any one of them breaks later rather than now. | Fact | Consequence | | --- | --- | | **Access token: 60 minutes** (`expires_in` 3600) | Refresh proactively; a stale token is a 401 on every call | | **Refresh token: documented as up to 100 days** — the sample token response carries `x_refresh_token_expires_in` `8726400` (101 days) | This is an **absolute** clock, not idle time. Past it, the customer must re-authorize | | **Refresh tokens rotate** — the value can change roughly every 24–26 hours | Store the **new** refresh token from every token response. Reusing an old one returns `invalid_grant` | | **`realmId` arrives on the callback**, not in the token response | Store it with the credentials. Every Accounting API path is `/v3/company/{realmId}/...` | More on each: - **Rotation is the silent killer.** A client that persists only the first refresh token works for a day, then every refresh fails with `invalid_grant` and the connection is dead with no user-visible event. This is the single most common way a QuickBooks integration dies. Treat "we store the token we got at connect" as a defect, not a design. - **The 100-day clock keeps running even if you refresh.** Refreshing mints a new refresh token with a fresh window, so an actively-used connection stays alive indefinitely — but a connection nobody syncs for three months does not. Idle connections are the ones that expire. - **`realmId` identifies the QuickBooks company**, and is returned on the redirect alongside `code` and `state`. Intuit's own note: if the user is not already signed in to QBO with realm context, the realmId may not come back in the redirect at all — handle its absence as a failed connect, not as a nullable field. - **One token spans exactly one company. Never more.** A customer with five QuickBooks companies authorizes five times and you store five (token, realmId) pairs. There is no "list my companies" call on an accounting token, and reusing one company's token against another realm is an authorization error. Intuit shows the company picker before handing control back, so which company you got is *their* choice — read it from `realmId`, do not infer it. - **Disconnect invalidates both tokens.** A user disconnecting your app inside QuickBooks (or you calling the revoke endpoint) kills the access and refresh tokens immediately; the next call is an auth failure and the only recovery is a fresh authorization. Reconnecting is a full new consent, and produces a new refresh token — and, if they picked a different company, a different realmId. - **Also worth pinning: the API minor version.** `?minorversion=75` is the current default and floor; versions 1–74 were retired on 2025-08-01. Pinning it explicitly makes the response shape a decision rather than a default that moves under you. ## 10. Verify end-to-end Authorizing against a sandbox proves the sandbox pair works, and nothing about production. Test both, in order. 1. **Sandbox**: run the full authorize → callback → token exchange with the Development pair against a sandbox company. Intuit's OAuth 2.0 Playground is the fastest way to prove the keys and redirect URI in isolation — if the playground works and your app does not, the fault is in your client, not the registration. 2. Confirm the callback carried **`code`, `state` and `realmId`**, and that `state` matches what you sent. 3. Confirm the token response carried an `access_token`, a `refresh_token`, `expires_in` 3600 and `x_refresh_token_expires_in`. 4. Make one read call against `https://sandbox-quickbooks.api.intuit.com/v3/company/{realmId}/companyinfo/{realmId}` with `minorversion=75`. 5. **Force a refresh, then force a second refresh using the token the first one returned.** This is the step that catches a client ignoring rotation. Nothing else catches it before production does. 6. **Production**: repeat 1–5 with the Production pair against a real QuickBooks Online company, authorized by a company admin who is not you where possible. | Symptom | Cause | | --- | --- | | `redirect_uri` rejected as invalid | Not byte-identical to a registered value, or registered in the other environment's list (§5) | | `invalid_scope` on the authorize URL | Scope not enabled on the app, or a beta/allowlisted scope (§6) | | Consent screen shows the app name as `undefined` | App profile data not resolving in the App Center — portal-side; open a support ticket with the App ID | | Consent succeeds, every API call 403 / `ApplicationAuthorizationFailed` / `003100` | Wrong environment key pair for the company type, or wrong API host, or only identity scopes granted (§4, §6) | | Everything works, then all calls 401 after an hour | Access token expired; refresh not wired up (§9) | | Works for a day, then every refresh returns `invalid_grant` | Rotated refresh token not stored (§9) | | Connection dies after a quiet few months | 100-day absolute refresh-token expiry; customer must re-authorize (§9) | | Auth fine, but calls 404 or hit the wrong company | `realmId` not stored, or a token used against another realm (§9) | | Customer's second admin connects and the first connection stops working | Intuit binds user → app → company; a later admin authorization can displace the earlier one. Store and reuse the company's tokens rather than per-admin ones | | Redirect URI or app URL "saves" but is gone on reload | Portal-side persistence failure — re-read after saving, then open a support ticket with the App ID (§5) | | Production tab shows the same keys as Development, or "Get production keys" loops | Production keys not actually provisioned; the assessment status is not the same as key issuance (§7) — support ticket | ## 11. Product fact: the Unified.to connector and the QuickBooks surface split **As of 2026-09-20, Unified.to's QuickBooks connector requests:** `openid`, `email`, `profile` and `com.intuit.quickbooks.accounting` — the same four for every supported object, read and write alike, across accounting, commerce, HRIS, payment, storage and task categories. It does **not** request `com.intuit.quickbooks.payment`, `phone`, `address`, or any payroll scope. Scopes go on the authorize URL space-delimited; the token exchange is a form POST with HTTP Basic client authentication, and refresh sends the refresh token and grant type. Other connector behaviour worth knowing before you register anything, all as of 2026-09-20: - It carries **two host profiles on one app** — a Production profile on `https://quickbooks.api.intuit.com` and a Sandbox profile on `https://sandbox-quickbooks.api.intuit.com` — sharing the single authorize URL (`https://appcenter.intuit.com/connect/oauth2`) and the single token URL (`https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer`). That mirrors §4 exactly: the key pair, not the URL, picks the environment. - It reads the **realm from the callback query string** when the connection is initialised and keeps it with the connection's stored credentials, then builds every request path from it. **One connection is one company**, and the connector treats it that way throughout — no subsidiary or multi-company fan-out. - Identity comes from the OpenID userinfo endpoint, with the sandbox host substituted when the connection is pointed at the sandbox API. - Reads pin **minor version 75** (with the enhanced-custom-fields include) where custom fields matter. - Webhooks are **portal-configured**, matching §0 — there is no registration call. **The QuickBooks surface is split across four separate connectors**, not one: `quickbooks` (this skill), `quickbookspayment` (the Payments API — `com.intuit.quickbooks.payment`, a different API host, US-only), `quickbooksdesktop`, and `quickbooksworkforce` (which authorizes against the QuickBooks Time / TSheets host with no scopes at all). **This skill covers only the QuickBooks Online app.** If the user needs Payments, Desktop or Workforce credentials, say so and stop — those are different registrations with different requirements, and Payments in particular is an additional scope on the *same* app rather than a separate one. **Confirm all of the above with the connector's owner before you register.** This is a snapshot of a codebase that changes; it is a starting point for the conversation, not a specification. ## 12. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a redirect host, a scope, minor-version pinning, refresh-token persistence), keep it secret-free and say what the human must set out of band. - Close with: app name and App ID; the workspace that owns it; **client ID per environment** and where each secret was delivered; authorize, token and revoke URLs; the API base URL per environment; the exact scope strings; the redirect URIs registered in each list; the assessment questionnaire's status and what remains; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the assessment questionnaire asks about data handling, retention, encryption, breach process, sub-processors, volumes or regulated industries; the questionnaire returns "Not approved" (it may not be resubmittable — that is a support ticket, not a retry); the app would cross **500 connections** and therefore inherit the app-store security requirements; the client does not store rotated refresh tokens or the `realmId` (report it — do not register and hope); someone proposes rotating a live client secret; the request is really for Payments, Desktop or Workforce credentials (§11); app-store listing, legal terms or marketing claims are in scope; or the portal does not match the **Platform state** section above. ## References Verified 2026-09-20. Note that `developer.intuit.com` is a single-page app that returns HTTP 200 for any path, so a status check does not prove a docs page exists — every URL below was reached through Intuit's own site search index and its content read on that date. The `help.developer.intuit.com` links do return real 404s for bad paths and were checked that way. - Authorization and authentication overview — https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization - Set up OAuth 2.0 (authorize params, callback params, token exchange, expiry, revoke) — https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0 - OpenID Connect — https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/openid-connect - Set app redirect URIs — https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/set-redirect-uri - Authorization FAQ — https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/faq - OAuth 2.0 Playground — https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0-playground - OIDC discovery (production) — https://developer.api.intuit.com/.well-known/openid_configuration/ - OIDC discovery (sandbox) — https://developer.api.intuit.com/.well-known/openid_sandbox_configuration/ - Change app settings — https://developer.intuit.com/app/developer/qbo/docs/get-started/app-settings - Sandbox and tools — https://developer.intuit.com/app/developer/qbo/docs/develop/sandboxes - Sandbox FAQ — https://developer.intuit.com/app/developer/qbo/docs/develop/sandboxes/sandbox-faqs - Manage your sandboxes — https://developer.intuit.com/app/developer/qbo/docs/develop/sandboxes/manage-your-sandboxes - Go live — https://developer.intuit.com/app/developer/qbo/docs/go-live - Publish your app — https://developer.intuit.com/app/developer/qbo/docs/go-live/publish-app - Publishing requirements and guidelines — https://developer.intuit.com/app/developer/qbo/docs/go-live/publish-app/platform-requirements - Technical requirements for apps — https://developer.intuit.com/app/developer/qbo/docs/go-live/publish-app/technical-requirements - App-store security requirements (incl. the 500-connection rule) — https://developer.intuit.com/app/developer/qbo/docs/list-on-the-app-store/security-requirements - Maintaining compliance (annual review) — https://developer.intuit.com/app/developer/qbo/docs/list-on-the-app-store/maintaining-compliance - App-store technical requirements (connect/disconnect behaviour) — https://developer.intuit.com/app/developer/qbo/docs/list-on-the-app-store/technical-requirements - Minor versions of the API — https://developer.intuit.com/app/developer/qbo/docs/learn/explore-the-quickbooks-online-api/minor-versions - Basic schema and data formats — https://developer.intuit.com/app/developer/qbo/docs/learn/rest-api-features - Error codes — https://developer.intuit.com/app/developer/qbo/docs/develop/troubleshooting/error-codes - Configure webhooks — https://developer.intuit.com/app/developer/qbo/docs/develop/webhooks/configure-webhooks - Platform release notes — https://developer.intuit.com/app/developer/qbo/docs/release-notes/platform-release-notes - QuickBooks Payments API — get started — https://developer.intuit.com/app/developer/qbpayments/docs/get-started - QuickBooks Payments OAuth 2.0 — https://developer.intuit.com/app/developer/qbpayments/docs/develop/authentication-and-authorization/oauth-2.0 - Using the Online and Payments APIs together — https://developer.intuit.com/app/developer/qbo/docs/workflows/use-the-quickbooks-online-and-the-quickbooks-payments-apis-together - App assessment and compliance FAQ — https://help.developer.intuit.com/s/article/New-app-assessment-process-FAQ - Refresh token expiration and validity policy — https://help.developer.intuit.com/s/article/Validity-of-Refresh-Token - Handling OAuth token expiration — https://help.developer.intuit.com/s/article/Handling-OAuth-token-expiration - More than 25 redirect URIs — https://help.developer.intuit.com/s/article/More-than-25-redirect-URIs - QuickBooks Online API best practices — https://help.developer.intuit.com/s/article/QuickBooks-Online-API-Best-Practices - Developer portal (sign in / app dashboard) — https://developer.intuit.com/app/developer/homepage - Workspaces — https://developer.intuit.com/workspaces - Sandbox companies — https://developer.intuit.com/sandbox-companies ## ringcentral-oauth-app URL: https://docs.unified.to/skills/ringcentral-oauth-app/SKILL.md # RingCentral OAuth2 App Registration Get a working RingCentral OAuth2 client — a Developer Console login on a RingCentral account, a **REST API app**, a Redirect URI list, a permission set, and a client ID and secret — for a platform that connects many customers' RingCentral accounts. Four things about this portal cost more than they look, and the first is the one everybody gets wrong. **There is no sandbox any more, and no graduation step.** RingCentral retired the free developer sandbox and the `platform.devtest.ringcentral.com` host; that hostname no longer resolves, and the old `guide/basics/sandbox` and `guide/getting-started/graduate-app` documentation pages are gone. Runbooks that tell you to build in sandbox and then "graduate to production" describe a process that does not exist. See §0 — this is a premise worth checking before you plan anything around it. **Application Type — public vs private — is set at creation and cannot be edited afterwards.** Pick private for a multi-tenant connector and you will be building a second app. **Refresh tokens are off by default.** "Issue refresh tokens" is a checkbox in the app's settings, and without it every customer connection dies with the access token. RingCentral's refresh tokens are also **single-use** and live **seven days**, not ninety — a connector that is quiet for a week is a connector that needs every customer back in the authorize flow. **The authorize URL has no `scope` parameter.** The app's configured permission list *is* the consent screen. You cannot ask one customer for less, and adding a permission in the console silently widens what every new customer is asked to approve. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name** and **Display name** | App name is internal only; Display name is what users see in the OAuth consent screen (§3) | | **Which RingCentral account owns the app** | Needs Developer Console access on that account (§2) | | **Public or private?** | **Cannot be changed after creation** — a multi-tenant connector is public (§3) | | **Every Redirect URI** | One per data center, exact match, no trailing-slash drift (§4) | | **Permission set** | What the connector actually calls; note which ones say "requires permission" (§5) | | **Confidential or client-side?** | Decides Basic-auth vs PKCE at the token exchange (§3, §6) | | **App icon, primary contact, company website** | Contact is who RingCentral emails about the app; icon may be required by app type (§3) | | **Will you list in the App Gallery?** | Asked at creation; drives a separate marketing review later (§7) | | **Must AT&T Office@Hand / Verizon customers work?** | They are on a different API host entirely (§7) | | **Privacy policy, terms, support and documentation URLs** | Required for an App Gallery listing (§7) | ## Quick Start 1. Read §0 first — the sandbox/graduation model you may be expecting is gone. 2. Confirm a **new app** is needed; a new client ID orphans every existing customer connection (§1). 3. Sign in to the Developer Console on an account that grants **Developer Portal Access** (§2). 4. **Create App → REST API App**, then set **Application Type** carefully — it is permanent (§3). 5. Enable **3-legged OAuth** and tick **Issue refresh tokens** (§3). 6. Enter every **Redirect URI**, exactly (§4). 7. Select permissions; write a real justification for any marked "requires permission" (§5). 8. Capture the client ID and secret (§6). 9. Verify from a **second** RingCentral account, and force **two consecutive refreshes** (§8). 10. Hand the credentials over — never commit them (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The developer sandbox is retired.** `platform.devtest.ringcentral.com` has no DNS record at all. The authorization-request guide now lists exactly one environment — "Production, `https://platform.ringcentral.com/restapi/oauth/authorize`" — where it used to list two. The doc paths `/guide/basics/sandbox` and `/guide/getting-started/graduate-app` return HTTP 200 but render the documentation homepage, which on this SPA is what a **deleted page** looks like. Do not cite either as evidence they exist. - **What replaced it is a paid, separate RingCentral account.** The developer pricing page sells a *Development environment*, subtitled "Watermarked sandbox", at **$12/month** ($10 annually) for two included phone extensions plus $5/month per additional extension, "for ISV partners and RingEX customers looking for a separate development environment to build and test business applications without accessing, impacting, or modifying production data." The same page states plainly that a RingEX plan "includes access to our APIs in your production environment." - **App types you can create today** are **REST API App** (what a connector needs), **Bot Add-in**, and **Notification Add-in**, which is deprecated and which "many developers may no longer have access to." - **Auth methods offered at creation** are the **3-legged OAuth flow** and the **JWT auth flow**. "The password grant type has been deprecated." JWT authenticates a single service user and RingCentral says explicitly it "is not designed to scale to support the need to authenticate a large number of users" — it is not a multi-tenant option. - **Application Type (public/private) cannot be edited after the app has been created.** Public means "any RingCentral credential can be used to login to your application"; private means only credentials on your own account. RingCentral notes public apps "are subjected to greater scrutiny through the development process." - **Access token ~1 hour, refresh token 7 days, refresh tokens are single-use.** The docs' table says 1 hour / 7 days; sample responses show `expires_in: 7199` and `refresh_token_expires_in: 604799`. Read the fields, do not hardcode either. - **Refresh tokens are not issued unless you enable them**: "By default, refresh tokens are not issued, so if you observe that a refresh token was not returned along with access token, edit your app's settings." - **The authorize endpoint accepts no `scope` parameter.** Documented parameters are `response_type`, `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method`, `brand_id` and `display`. - **PKCE is supported and recommended but not mandatory** for a server-side confidential client, which exchanges the code with HTTP Basic `client_id:client_secret`. - **Two API hosts exist, and they are not sandbox and production.** Commercial is `https://platform.ringcentral.com/`; **segregated** is `https://platform.ringcentral.biz/`, and it serves **AT&T Office@Hand and Verizon** customers, who are in "a completely segregated environment" for regulatory reasons. - **Rate limits are per (authenticated user, application ID) and per API group** — Light 50, Medium 40, Heavy 10 and Auth 5 requests/user/minute by default, customizable per app. If the Developer Console or the docs do not look like this, stop and report what you actually see rather than clicking on. ## 0. The premise to check first: sandbox and "graduation" Most RingCentral runbooks, blog posts and forum answers older than 2025 describe this sequence: create an app, it lands in **sandbox** with a sandbox client ID against `platform.devtest.ringcentral.com`, exercise every permission with at least a handful of API calls, then request **graduation**, wait for review, and receive a *second, different* client ID and secret for production. **None of that is live.** The sandbox host does not resolve. The graduation and sandbox documentation pages have been deleted. The authorization guide, which used to carry a two-row environment table, now carries one row. What this means in practice: - **You get one credential pair, and it works against production immediately.** There is no second pair to swap in at launch, and no "which environment is this secret for?" class of bug. - **The scheduling risk moved.** It is no longer a formal graduation review. What can still block or delay you is (a) **restricted-permission approval tickets** (§5), (b) **App Gallery listing review**, if you want to be listed (§7), and (c) **acquiring a test account at all**, because testing now means either a paid Development environment or a live RingEX account (§2). - **Testing against production is the default posture.** Budget for it: a connector under development is making real calls against a real account's real call logs, contacts and messages. Two caveats, stated honestly. First, RingCentral announced this transition through its developer **community** posts, which are login-gated — I could not read them directly, so treat any specific retirement *date* you are told as unverified. The retirement itself is not in doubt: the host is gone and the docs are gone. Second, I could not sign in to the Developer Console, so **if the console still shows a Sandbox/Production toggle on an existing older app, believe the console and report it** — legacy apps created under the old model may carry artefacts the current documentation no longer describes. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer connection is bound to the old one** — every customer would have to re-authorize. Reuse the existing app for: adding a Redirect URI, adding or removing a permission, rotating a compromised secret, enabling refresh tokens, or diagnosing an authorization failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, or — the one RingCentral forces on you — an app that was created as **private** and now needs to serve customers outside your own account, because Application Type cannot be edited (§3). Say which path you are taking before you touch anything. RingCentral is explicit that you do not need one app per API: "A single RingCentral application can interact with all the APIs on the RingCentral platform. That means there is no need to register multiple apps." Voice, Team Messaging, Video and account data all live behind one client ID. ## 2. Account: sign in, and the Developer Portal permission Sign in at `https://developers.ringcentral.com/login`. There is no standalone developer account any more — the Developer Console is reached with a **RingCentral account** login, which now means a RingEX subscription or a paid Development environment (§0). **If the "Create App" button is visible but disabled, that is the diagnosis**: "your account lacks the permission required to create an app. Contact your account's administrator to request this permission." Do not hunt for another route. To add a colleague, they must first exist as a **user on your RingCentral account** (Admin Console → Users → Add User), and their assigned role needs the **Developer Portal Access** permission under the **Features** permission group. They get no invitation email; they simply become able to log in. Console roles are then: | Role | Can do | | --- | --- | | **Developer Admin** | Manage any app, manage developers, manage developer JWT credentials | | **Developer** | Manage only apps they created, and their own JWT credentials | | **Audit-user** | View apps and profiles only | Only a Developer Admin can assign roles; the **Organization** tab lists who the Developer Admins are. Hand control back to the user for anything only they can do: account signup and payment details, email verification, 2FA, accepting the API License Agreement, or granting Developer Portal Access. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (§4 Redirect URIs, §5 permission list), then continue once they report back with the client ID. ## 3. Create the app, and the choices that are permanent **Developer Console → Apps → Create App → REST API App.** - **App name** — internal only, "never displayed publically," so `Acme Connector (prod)` is fine and useful. - **Display name** — what customers see, including in the OAuth consent screen. This is the name a customer must recognise when they are asked to authorize; get it right. - **App icon** — may or may not be required depending on app type; bots and add-ins always need one. - **Primary contact** — "designate a individual to be responsible for receiving and responding to important communications relating to this app." Use a monitored role address, not a person who may leave. - **Will you be promoting your app?** — RingCentral asks whether you intend to list in the App Gallery. The answer is "for internal-use only" and does not itself start a review. Then the choices that are expensive: **Application Type: public or private. Permanent.** RingCentral's framing is about *whose credentials can log in*, not about client confidentiality: - **Public** — "any RingCentral credential can be used to login to your application." This is the only correct answer for a platform connecting other organizations' accounts. RingCentral names exactly this case: "You are a partner building an application that you will market and sell to RingCentral customers." - **Private** — "only credentials belonging to your account can be used." For internal tools and CPaaS-style usage where you are consuming your own RingCentral service. The docs say it twice and so will I: **"You cannot edit Application Type after your app has been created."** A private app pointed at customers fails at *login*, before consent, for everyone outside your account. **Authentication: 3-legged OAuth.** Tick it. JWT is the other option and it is for a single service user; RingCentral says outright it "is not designed to scale to support the need to authenticate a large number of users." **Issue refresh tokens: tick it.** This is the single most missed setting on the platform. "By default, refresh tokens are not issued." Without it, every customer connection dies about an hour after they connect and the only symptom is a token response with no `refresh_token` in it. **Platform Type** decides what else the app can do and how the token exchange authenticates. The documentation uses two vocabularies that do not fully agree — an older set (`Server/Web` "most common", `Web Browser (Javascript)`, `Server/Bot`, `Server/No UI`) and a newer one referenced in the refresh-token guide (`Client-side web app`). **Trust the console's current wording over both.** What matters is the behavioural split: - A **server-side** app is a confidential client: it sends `Authorization: Basic base64(client_id:client_secret)` at the token endpoint. This is what a hosted multi-tenant connector is. - A **client-side web app** does not send an Authorization header at all — "in order to keep the app's client secret hidden from the request/response" — and instead sends `client_id` in the request body. Picking the client-side type for a server-side connector, or vice versa, produces `OAU-116 Invalid authorization method` or `OAU-123 Invalid Authorization header value` at the exchange, which reads like a credential problem and is not one. ## 4. Redirect URIs RingCentral matches these exactly: "the `redirect_uri` must exactly match at least one of the Redirect URIs provided by the developer when the app was created." Register **every** callback host your platform can send a customer back to. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes that decide whether a connect flow works: - **The console field is plural and the docs say "one of"** — multiple URIs are supported. RingCentral does not publish a maximum; if the console refuses to add another, report the number it stopped at rather than guessing. - **Exact match means exact.** A trailing slash, `http` instead of `https`, a differing case in the path, or an extra query parameter is a different URI. RingCentral surfaces this as **`OAU-109 Redirect URIs do not match`** (HTTP 403), and an app with no registered URI at all as **`OAU-113 No redirect uri is registered for the client`**. - **A bad `redirect_uri` can also fail earlier**: "in some cases, HTTP 400 may be returned on the `/restapi/oauth/authorize` call. This can happen when the client provides an invalid redirect URI." - The failure appears **at connect time, not at save time**. Nothing in the console tells you the app is broken. - **For AT&T Office@Hand and Verizon customers the login and API hosts differ** (`login.ringcentral.biz` / `platform.ringcentral.biz`) but the *redirect* still comes back to your own callback host — see §7. ## 5. Permissions (RingCentral's word for scopes) RingCentral calls them **app permissions** or **scopes** interchangeably. "Required scopes are generally declared at the application registration stage and confirmed by the user during the authorization stage." ### The catalogue This is the current published list, verbatim in naming. Note the composition column — several permissions imply others, so you often need fewer entries than you think. | Permission | What it covers | Access type | Includes | | --- | --- | --- | --- | | `A2P SMS` | Sending SMS in large numbers | Special operation | | | `Accounts` | Create, view, update, delete accounts | CRUD | `EditAccounts` | | `AI` | Analyze audio and text; view/update analysis info | Read only | | | `Analytics` | Call analytics data via the Analytics product | Read only | | | `CallControl` | Manipulate and control calls in progress | Special operation | | | `Contacts` | Create, view, edit, delete personal contacts | CRUD | `ReadContacts` | | `ControlWebinars` | | Special operation | | | `DirectRingOut` | Direct (one-legged) ring-out calls — **available on request** | Special operation | | | `EditAccounts` | View and update account info | Read and Update | `ReadAccounts`, `EditExtensions` | | `EditCallLog` | View and update call logs | Read and Update | `ReadCallLog` | | `EditCustomData` | View and update client custom data | Read and Update | | | `EditExtensions` | View and update extension info, assigned numbers, devices, settings | Read and Update | | | `EditMessages` | View and update messages | Read and Update | `ReadMessages` | | `EditPaymentInfo` | View and update account billing settings | Read and Update | | | `EditPresence` | Get and modify presence | Read and Update | `ReadPresence` | | `EditReportingSettings` | Call reporting settings — **available on request** | Read and Update | | | `EditUserCredentials` | Manage a user's login credentials/password — **available on request** | Read and Update | | | `EditWebinars` | View and update webinars and sessions | Read and Update | | | `Faxes` | Sending and receiving faxes | Special operation | `ReadMessages` | | `Glip` | Read and post messages, read and manage chats | Read and Update | | | `InternalMessages` | Intra-company pager messages | Special operation | `ReadMessages` | | `Meetings` | CRUD meetings via **RingCentral Meetings** | CRUD | | | `ReadAccounts` | View account info | Read only | | | `ReadCallLog` | View call logs | Read only | | | `ReadCallRecording` | **Downloading call recording content** | Read only | `ReadCallLog` | | `ReadClientInfo` | Client app registered attributes and helper info | Special operation | | | `ReadContacts` | View personal contacts | Read only | | | `ReadMessages` | View messages | Read only | | | `ReadPresence` | Get presence | Read only | | | `ReadWebinars` | | Special operation | | | `RingOut` | Two-legged ring-out calls | Special operation | | | `RoleManagement` | Edit and assign user roles — **available on request** | Special operation | | | `SMS` | Sending and receiving SMS | Special operation | `ReadMessages` | | `SubscriptionWebhook` | Subscribing to and managing webhook notification preferences | Special operation | | | `TeamMessaging` | Post messages; view, edit, delete Team Messaging data | | | | `Video` | CRUD meetings via **RingCentral Video** | CRUD | | | `Voicemail` | Delivering voicemail to multiple internal recipients | Special operation | | | `VoipCalling` | Register as a VoIP device and make VoIP calls | Special operation | | Three corrections worth carrying: 1. **The webhook permission is `SubscriptionWebhook`**, not `WebhookSubscriptions`. 2. **`Meetings` and `Video` are different products.** `Video` is RingCentral Video (`/rcvideo/...`); `Meetings` is the older RingCentral Meetings product. Picking the wrong one authorizes cleanly and 403s at the API. 3. **`ReadCallRecording` is separate from `ReadCallLog`** and is the one that matters for audio. See below. ### The recording trap, in RingCentral's own table The Call Log access-control guide spells out the split, and it is the most common scope mistake on this platform: | Endpoint | Needs `ReadCallLog` | Needs `ReadCallRecording` | | --- | --- | --- | | `/v1.0/account/{accountId}/call-log` | YES | NO | | `/v1.0/account/{accountId}/extension/{extensionId}/call-log` | YES | NO | | `/v1.0/account/{accountId}/extension/{extensionId}/active-calls` | YES | NO | | `/v1.0/account/{accountId}/recording/{recordingId}` (metadata) | YES | YES | | `/v1.0/account/{accountId}/recording/{recordingId}/content` (**audio**) | NO | YES | An app holding only `ReadCallLog` lists calls perfectly and then fails every recording download with HTTP 403: ```json { "errorCode": "InsufficientPermissions", "message": "In order to call this API endpoint, application needs to have [ReadCallLog] permission", "errors": [ { "errorCode": "CMN-401", "permissionName": "ReadCallLog" } ] } ``` The shape is the same for any missing permission — read `permissionName`, not the prose. ### "Requires permission": the approval you can actually get blocked on Some permissions carry **"(requires permission)"** in the console. RingCentral: "When you add a restricted app scope to your app, you will be prompted to provide a justification for why your app requires the ability to perform the actions associated with that app scope. Then, when you save the settings of that app, a request will be filed with the support to review your petition, and approve (or reject) your request accordingly." The registration guide adds that "a support ticket will be filed automatically with the developer support team." - Separately, four permissions are marked **"Available on request"** in the catalogue — `DirectRingOut`, `EditReportingSettings`, `EditUserCredentials`, `RoleManagement`. Treat those as blocked until granted. - **RingCentral publishes no turnaround time for these tickets.** Do not promise one. This is the item to raise with the user early, because it is the remaining schedule risk now that graduation is gone (§0). - "To maximize the likelihood of your request being approved, please provide a detailed, well-written response to the justification prompt." Write one concrete sentence per permission naming the feature it powers, and request the narrow set: RingCentral's own guidance is "For security, request only permissions your application requires." ### Application permission is only half of it This is the RingCentral equivalent of admin-vs-user consent, and it is not a console setting: > "Setting an application's scope does not by itself confer upon a user of that application the ability to perform > the associated action… in order for a user of an application to perform a given operation, the app must declare the > corresponding scope, **and the user must have been assigned a role that possesses the corresponding user > permission as well**." So the effective access is the **intersection** of your app's permissions and the authorizing user's RingCentral role. Concretely: - **Account-wide reads use `account/~`** — the company call log, the company directory, account-level recordings. A plain extension authorizing your app produces a valid token that then returns **`CMN-402 Administrator permission required`** (403) on those paths. - **Reaching another extension's data** as a non-privileged user returns **`CMN-404 Attempt to access another extension`**. - **There is no flag in the token response that tells you the user is an admin.** You discover it at the API. If the product needs company-wide data, the install story has to be "an administrator connects the account", and that needs saying to customers up front — not discovered by them when the first sync returns nothing. ### You cannot narrow permissions per authorization The authorize endpoint takes no `scope` parameter. The consent screen shows what the app is configured with, full stop. Two consequences: adding a permission in the console changes what *every* new customer is asked to approve, and you cannot offer a "read-only tier" of the same app. The token response does return a `scope` string (e.g. `"AccountInfo CallLog ExtensionInfo Messages SMS"`) which you can inspect after the fact. ## 6. Capture the credentials From the app's credentials page in the Developer Console. Capture: - **Client ID** and **Client Secret** — "your app will be provisioned app credentials in the form of a Client ID and Client Secret." One pair; there is no development/production split any more (§0). - Endpoints, commercial environment: - authorize — `https://platform.ringcentral.com/restapi/oauth/authorize` - token — `https://platform.ringcentral.com/restapi/oauth/token` - revoke — `https://platform.ringcentral.com/restapi/oauth/revoke` - Endpoints, segregated environment (AT&T Office@Hand, Verizon) — the same paths on `https://platform.ringcentral.biz/` (§7). - The **Interactive Messages shared secret**, if you enabled that feature — "a string you should consider highly confidential," used to verify inbound event authenticity. **Token lifetimes and rotation, stated once.** Access token about an hour (`expires_in` 7199 in RingCentral's own samples — read the field). Refresh token **7 days** (`refresh_token_expires_in` 604799). And the rules that produce most "it stopped working" reports: - **"Refresh tokens can only be used once."** A new refresh token comes back with each refresh and you must store it. - **"Upon refreshing an access token, the previous access token is invalidated immediately."** Replaying the old access token gives HTTP 401. - There is a narrow grace window, and it is not a feature to rely on: if you *use* the new access token, the old refresh token dies in "approximately ten seconds"; if you never use it, the old refresh token stays valid up to 60 minutes and keeps returning the same access token. - A connection idle for **seven days** is dead and the customer must re-authorize. This is the single biggest behavioural difference from most OAuth platforms and it is worth a scheduled refresh job: RingCentral's own advice is a background task that wakes "at least once per day" and refreshes everything stored. **Other things that silently kill a token**, per the docs: the end user terminates the app's authorization session; the user's password changes; the company administrator enforces an **"Absolute Session Timeout"**; or "the application reached the limit (usually five) of parallel active sessions for the same user." That last one bites multi-instance connectors that authenticate per worker instead of sharing a token. **Revocation is session-wide.** `/restapi/oauth/revoke` "accepts just one token, [but] it actually terminates the entire authorization session associated with this token, i.e., invalidates ALL active access and refresh tokens for this session." The secret is regenerable from the console. Regenerating invalidates the old value: existing access tokens run out their hour, but every code exchange and refresh fails until the new value is deployed. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. ## 7. Limits, review, and listing **A public app does not need a listing to work.** Public means any RingCentral credential can log in; customers can authorize through your own connect flow. The App Gallery is *distribution*, not a gate on authorization. That is the main structural change from the old sandbox model — decide deliberately whether you want the listing at all. **If you do want the listing**, it goes through review. "Before your app can be listed in an app gallery, the RingCentral team will first review your app and will make recommendations." The published checklist has three headings: 1. **Trademarks and intellectual property** — the app name, descriptive text, icons, imagery and media must not infringe. 2. **Completeness** — a detailed description covering functionality, target users and value; "at least three screenshots showing your product in action, and that highlights specifically how RingCentral is integrated with it"; installation, usage and support documentation. 3. **Disclosures and agreements** — "post on your own website a clear privacy policy" and link it from the profile; agree to the RingCentral API License Agreement. The profile itself cannot be submitted until every required field is filled; you can **Save as draft** and come back. Marketing colleagues can be given Developer Console access to write it (§2). **RingCentral publishes no turnaround time for App Gallery review** — do not invent one. Compliance attestations and legal commitments are decisions for a human: collect what exists, do not draft claims. **Segregated partners are the real multi-host problem.** AT&T Office@Hand and Verizon customers "exist in a completely segregated environment in order to comply with specific regulations that govern the markets they operate in": | Environment | Base API URL | Login host | | --- | --- | --- | | Commercial | `https://platform.ringcentral.com/` | `https://login.ringcentral.com` | | Segregated (AT&T Office@Hand, Verizon) | `https://platform.ringcentral.biz/` | `https://login.ringcentral.biz` | RingCentral's guidance: detect the partner before you build the login URL, pass the partner's brand ID, "persist whether their users are associated with RingCentral or a segregated partner, as all subsequent API requests need to be directed to the proper server environment," and use partner-specific login buttons because "many customers are not always cognizant of the fact that RingCentral is powering their carrier cloud communication." Other white-label partners — Avaya Cloud Office, BT, TELUS, Atos Unify Office, Rainbow Office — are on the **commercial** host and need only the right `brand_id` for branding. (The docs spell that parameter `brandId` in prose and `brand_id` in the example URLs and the parameter table; send `brand_id` and verify.) **Rate limits are per (user, app) and per API group, not global.** Defaults, which "can be customized and vary among apps": | Usage plan group | Default limit | Penalty interval | | --- | --- | --- | | Light | 50 requests/user/minute | 60s | | Medium | 40 requests/user/minute | 60s | | Heavy | 10 requests/user/minute | 60s | | Auth | 5 requests/user/minute | 60s | Each endpoint's group is named in the API Reference under "Usage Plan Group". Your app's actual numbers are in the Developer Console under **Rate Limits**, and developer support can raise them per app. Over-limit is **HTTP 429** with `Retry-After` in seconds ("if `Retry-After` is not returned, the original request should be retried in 30 seconds or later"), plus `X-Rate-Limit-Group`, `X-Rate-Limit-Limit`, `X-Rate-Limit-Remaining` and `X-Rate-Limit-Window`. Note the header prefix is **`X-Rate-Limit-…`**, not `Rate-Limit-…`. Error codes in the 429 body are `CMN-301`/`CMN-302` (request rate exceeded), `CMN-303`, `CMN-304` (duplicate concurrent request) and `CMN-310` (global). **The 429 trap is the part that actually hurts a backfill**, and it is unusual enough to design for: > "Every time you send a request that is caught by our rate limiting system, we reset the clock on your penalty > window. Therefore, it is possible that your app could find itself trapped in an unending 429 trap, unless you code > your app such that it allows the penalty window to fully elapse before sending another request." A naive retry loop does not recover here — it extends the penalty indefinitely. Watch `X-Rate-Limit-Remaining` and stop *before* the limit; RingCentral's own claim is that an app that does so "should never encounter 429 errors due to violating user-level rate limits." **Auth is its own rate-limit group.** "Backend servers enforce some quotas for the number of authorization requests and number of active application sessions. If the quota is exceeded at any given time, the server starts to return HTTP 429 on authorization requests." Authenticate in one thread and share the token; do not refresh per worker. ## 8. Verify end-to-end Authorizing on the account that owns the app proves very little, especially now that that account is production. Test the path a customer takes: 1. Authorize from a **second RingCentral account** through your platform's real connect flow, with the role customers will use — an administrator if the connector reads `account/~` data, a plain extension if not. 2. Confirm the token response actually contains a **`refresh_token`**. If it does not, the "Issue refresh tokens" setting is off (§3) — this is the most common single failure on this platform. 3. Force a **refresh**, then force a **second** refresh using the token returned by the first. Refresh tokens are single-use; this is what catches a client that stores the original, and it is the highest-value check here. 4. Confirm the **old access token now 401s** — it is invalidated immediately on refresh, not at expiry. 5. Make one real read call per permission you requested, including at least one **account-level** (`account/~`) call, which is where an insufficiently privileged installer shows up. 6. If recordings are in scope, download actual **recording content**, not just metadata — they need different permissions (§5). 7. Inspect the `scope` string in the token response and confirm it matches what you configured. 8. Watch for `X-Rate-Limit-Remaining` on a real list call, and confirm your client backs off on the whole penalty window rather than retrying into it (§7). | Symptom | Cause | | --- | --- | | `OAU-109 Redirect URIs do not match` (403) | `redirect_uri` is not byte-identical to a registered one (§4) | | `OAU-113 No redirect uri is registered for the client` | App was saved without any Redirect URI (§4) | | HTTP 400 on `/restapi/oauth/authorize` | Invalid `redirect_uri` in the request (§4) | | `OAU-110 Authorization code was not issued for this application` | Code exchanged with a different client ID than the one that authorized (§6) | | `OAU-108 Authorization code is expired` | Code not redeemed in time — the redirect carries `expires_in=60` (§8) | | `OAU-146 Invalid client credentials` (401) | Wrong client ID/secret, or the secret was regenerated (§6) | | `OAU-116 Invalid authorization method` / `OAU-123 Invalid Authorization header value` | Platform Type mismatch: Basic auth sent by a client-side app, or omitted by a server-side one (§3) | | `OAU-125 Grant type is not allowed for application` | `authorization_code` not enabled — app is configured for JWT only (§3) | | `OAU-112 The client is unauthorized for the required grant type` | Same, seen at the token endpoint (§3) | | No `refresh_token` in the token response | "Issue refresh tokens" is off; it is **off by default** (§3) | | Second refresh fails, first one worked | Client replayed the original refresh token — they are single-use (§6) | | `OAU-128 Access token expired` right after a successful refresh | Old access token reused; refresh invalidates it immediately (§6) | | Connection dies after a quiet week | 7-day refresh token expiry — the customer must re-authorize (§6) | | Tokens die when the customer changes their password, or at a fixed hour | Password change invalidates sessions; admin "Absolute Session Timeout" (§6) | | Tokens drop under load with several workers | Parallel-session cap, "usually five", per user (§6) | | 403 `InsufficientPermissions` / `CMN-401`, `permissionName` names a scope | That permission is not on the app — add it, and re-check whether it "requires permission" (§5) | | Call log works, recording download 403s | `ReadCallRecording` missing; content needs it and `ReadCallLog` does not cover it (§5) | | Auth succeeds, `account/~` endpoints 403 `CMN-402 Administrator permission required` | Installer's RingCentral role lacks admin; app scope alone is not enough (§5) | | `CMN-404 Attempt to access another extension` | Reaching another user's data without the role that permits it (§5) | | Login fails for every external customer | App was created **private**; Application Type cannot be edited (§3) | | Works for most customers, 404/401 for AT&T Office@Hand or Verizon | Those accounts live on `platform.ringcentral.biz` (§7) | | 429 that never clears despite retrying | Each retry resets the penalty window — let it elapse fully (§7) | | 429 on `/restapi/oauth/token` specifically | `Auth` is its own group, default 5/user/minute; stop refreshing per worker (§7) | | A doc URL returns 200 but shows the docs homepage | That page has been deleted — the SPA falls back (§0) | ## Product fact — how the RingCentral connector is configured > **As of 2026-09-20, Unified.to's RingCentral connector is configured as follows.** > > **Endpoints.** It targets the **commercial** environment: `https://platform.ringcentral.com` with > `/restapi/oauth/authorize` and `/restapi/oauth/token`. It also still carries a second, "Sandbox" environment > pointing at `platform.devtest.ringcentral.com` — **that host no longer resolves** (§0), so any connection created > against it cannot work. Raise this with the connector's owner. > > **Authorize request.** It sends `client_id`, `redirect_uri`, `response_type` and `state` only. **No PKCE** > (`code_challenge` is not sent) and **no `scope`** — correct on both counts for this platform, since RingCentral's > authorize endpoint has no `scope` parameter and PKCE is optional for a confidential client. It follows that the > app's configured permission list is exactly the consent screen (§5). > > **Token exchange.** Form-encoded POST with `grant_type`, `code` and `redirect_uri`, authenticating with **HTTP > Basic `client_id:client_secret`** — the server-side/confidential pattern (§3). Refresh sends `grant_type`, > `refresh_token` and `client_id` in the body *and* Basic auth; per the docs, `client_id` in the body is only > required for a client-side web app, so confirm which Platform Type the app is actually registered as. > > **Permissions it expects**, by capability: > > | Capability | Permissions requested | > | --- | --- | > | Calls / call log | `ReadCallLog` | > | Call recordings | `ReadCallLog` | > | Contacts | `ReadContacts` (read), `Contacts` (write) | > | Directory, devices, sites | `ReadAccounts` (read), `EditAccounts` (site write) | > | Team Messaging channels, messages, teams | `TeamMessaging` | > | Meetings / calendar | `Video` | > > Three things to raise with the connector's owner before configuring an app: > > 1. **Recordings are under-scoped.** The connector downloads recording audio from > `…/account/~/recording/{id}/content`, which RingCentral's own access-control table says needs > **`ReadCallRecording`** and explicitly does *not* accept `ReadCallLog`. As configured, listing calls will work > and every audio download will 403 with `CMN-401`. Add `ReadCallRecording` (§5). > 2. **Everything is account-level.** The call log, call-log sync and directory calls all use `account/~`, so the > authorizing user must be a RingCentral **administrator**. There is no user-level fallback, and a non-admin > install authorizes cleanly and then 403s with `CMN-402` (§5). > 3. **No segregated-environment support.** Only the `.com` host is configured, so AT&T Office@Hand and Verizon > customers cannot be served (§7). > > It uses **polling** for change detection rather than RingCentral webhook subscriptions, so it needs no > `SubscriptionWebhook` permission. It also performs a post-authorization profile lookup against > `/restapi/v1.0/account/~`, whose documented response is account information and carries no verified-email field — > so that step is unlikely to resolve a user identity. Treat it as suspect until reviewed. > > Permission sets change. This note is dated, not live; confirm with the connector's owner before submitting > anything. ## 9. Hand off — never commit the secret - **Do not** write the client secret (or an Interactive Messages shared secret) into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a Redirect URI, a permission, single-use refresh-token handling, the `.biz` host), keep it secret-free and say what the human must set out of band. - Close with: app name and Display name; app type (REST API App) and **Application Type (public/private, permanent)**; Platform Type; which RingCentral account owns it; the client ID; where the secret was delivered; the authorize, token and revoke endpoints; the exact permission strings and which ones filed an approval ticket; whether "Issue refresh tokens" is on; the 7-day single-use refresh-token handling; whether segregated partners are supported; the App Gallery decision; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: - **The client does not store rotated refresh tokens** — report it; the app will register fine and fail on the second refresh, and every connection will die within seven days. - Nobody has decided **public vs private**, or someone proposes "we'll change it later" — you cannot (§3). - Nobody has provisioned an account to test against. Sandbox is gone; this means a paid Development environment or testing against live production data, and that is a decision with a cost and a blast radius (§0, §2). - A permission marked **"requires permission"** or **"available on request"** is needed and nobody has filed or chased the ticket — RingCentral publishes no turnaround for these, so it cannot be scheduled around (§5). - The product needs **account-wide** data but the install story is individual users self-connecting — that needs an administrator, and no app setting substitutes (§5). - **Call recordings** are in scope and nobody has established who is accountable for the legality of storing call audio and its retention. Recording law is jurisdictional; it is not a question the API docs settle. - **AT&T Office@Hand or Verizon** customers are in scope and the platform has no per-connection API host — that is a code change, not a console setting (§7). - Someone proposes **rotating the client secret** on an app with live customers without a cutover plan (§6). - Someone asks you to promise an **App Gallery review turnaround**, or to draft compliance, security or legal claims for a listing (§7). - The Developer Console does not match the **Platform state** section above — in particular, if it still offers a Sandbox/Production toggle (§0). ## References Official RingCentral pages only. Every URL verified on 2026-09-20 to return HTTP 200 **and** to render its own content — this documentation site is a single-page app that returns 200 with the docs homepage for deleted paths, so a status code alone proves nothing. - Developer Console — https://developers.ringcentral.com/console - Sign in — https://developers.ringcentral.com/login - Developer pricing: Development environment vs Production environment — https://developers.ringcentral.com/pricing - Registering an application and obtaining app credentials — https://developers.ringcentral.com/guide/getting-started/register-app - Building your first app (Application Type, Platform Type) — https://developers.ringcentral.com/guide/basics/your-first-steps - Collaborating with others in Developer Console (roles, Developer Portal Access) — https://developers.ringcentral.com/guide/basics/inviting-developers - Application permissions and scopes — https://developers.ringcentral.com/guide/basics/permissions - Choosing the best auth method for your app — https://developers.ringcentral.com/guide/authentication - Generating an authorization request (parameters, environment table) — https://developers.ringcentral.com/guide/authentication/oauth-request - Authorization code flow — https://developers.ringcentral.com/guide/authentication/auth-code-flow - Authorization code flow with PKCE — https://developers.ringcentral.com/guide/authentication/auth-code-pkce-flow - Using refresh tokens (single use, 7 days, "Issue refresh tokens") — https://developers.ringcentral.com/guide/authentication/refresh-tokens - Using access tokens (invalidation, revocation, session limits) — https://developers.ringcentral.com/guide/authentication/tokens - Call Log access control (`ReadCallLog` vs `ReadCallRecording` table) — https://developers.ringcentral.com/guide/voice/call-log/access - Rate limits (groups, `X-Rate-Limit-*` headers, the penalty-window trap) — https://developers.ringcentral.com/guide/basics/rate-limits - API error codes (OAU-*, CMN-*, SUB-*, rate-limit codes) — https://developers.ringcentral.com/guide/basics/errors - Methods, endpoints and parameters (production host, `~` shorthand) — https://developers.ringcentral.com/guide/basics/uris - Technical requirements for segregated partner environments (`platform.ringcentral.biz`) — https://developers.ringcentral.com/guide/basics/partners/segregated-environments - Authentication within RingCentral's partner ecosystem (brand IDs, login hosts) — https://developers.ringcentral.com/guide/basics/partners/auth - Partner and service-provider brand guidelines — https://developers.ringcentral.com/guide/basics/partners/brand-guidelines - Promoting your application in the RingCentral App Gallery — https://developers.ringcentral.com/guide/getting-started/promote-app - App Gallery checklist and best practices — https://developers.ringcentral.com/guide/basics/app-gallery-checklist - API Reference (per-endpoint permissions and Usage Plan Group) — https://developers.ringcentral.com/api-reference - API changelog — https://developers.ringcentral.com/guide/basics/changelog ## rippling-oauth-app URL: https://docs.unified.to/skills/rippling-oauth-app/SKILL.md # Rippling OAuth Credentials **Read this first: there is no Rippling developer console where you sign up, create an app and walk away with a client ID and secret.** Credentials live inside an *app listing*, an app listing lives inside a *partner company*, and a partner company only exists because Rippling created one for you after approving an application. Rippling's own integration checklist starts at "Apply for listing in the App Shop" and continues "If approved, look out for your onboarding email" — the email carries login credentials for a partner company and a test company that Rippling provisions. Everything technical comes after that approval. There is no paid tier, no instant path, and nothing to click your way into. Worse for a platform that connects *other organizations'* Rippling accounts on behalf of many customers: Rippling's Partner Requirements page addresses that model directly and rules it out. **REQ2** reads "Rippling does not support partners integrating through third-party integration services. All integrations must be built directly to Rippling. 3rd-party vendors using API keys are not supported… Partner integrations must leverage OAuth." The same page's standing note says "The use of non-sanctioned developer tools (apart from approved Rippling partners [merge.dev and tryfinch.com]) is prohibited," and points at Sections 2.4 and 3.1.4 of the Rippling Developer Terms of Use. Two named aggregators are sanctioned; the rest are not. **If the run is "get us Rippling OAuth credentials so our integration platform can connect customers' Rippling accounts," the honest first reply is that this is the exact arrangement REQ2 names, and the question is a business one — for Rippling's partnerships team, not for a form.** Stop at §2 and hand it back. Three more things cost real time here. **There are two live API generations** and the docs for both are reachable; picking the retired one is the expensive mistake (§7). **Scopes are fixed on the app listing, not chosen per customer at authorize time** (§6), which breaks the usual per-object-scope design. And **the customer's own admin installs your app from inside Rippling**, so your connect flow is one leg of an install that must return to Rippling to finish (§8). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Partner status** | Has the App Shop application been submitted? Approved? Does a partner company exist? (§2) | | **New listing, or an edit to an existing one** | A new listing means a new client ID and every existing install re-authorizes (§1) | | **Which API generation** | New work is REST; only pre-existing legacy partners stay on V1 (§7) | | **App listing name** | Appears in the App Shop and inside the install and authorize URLs (§4) | | **Category, icon, descriptions** | Required before an install will work at all (§4) | | **Default redirect URL, plus any additional ones** | Exact strings; the default is the only one used for Rippling-initiated installs (§5) | | **Scope set** | Exact catalog strings, and whether any are private scopes needing justification (§6) | | **Environment** | Sandbox and production are two credential pairs on the same listing (§9) | | **Who owns the partner company login** | A human receives the onboarding email and does two-factor setup (§3) | | **Whether SSO and user management are in scope** | Rippling requires both by default of listed apps (§11) | ## Quick Start 1. Establish whether a partnership exists at all — without it there are no credentials, at any price (§2). 2. Confirm whether an existing listing should be reused rather than replaced (§1). 3. Apply through Rippling's App Shop application; wait for the onboarding email with partner and test company logins (§2, §3). 4. Create an app listing in the partner company and configure name, category, icon, redirect URL and scopes (§4, §5, §6). 5. Target the **REST API** unless you are a pre-November-2025 legacy partner (§7). 6. Deploy the **sandbox** app, which is what makes the sandbox client ID and secret appear (§9). 7. Install the sandbox app in the **test company** and complete a real install round trip (§8, §12). 8. Submit the Integration Review form for beta; production credentials appear only after that approval (§9, §11). 9. Get three outside companies to install, submit Launch Review, publish (§11). 10. Verify end-to-end, then hand the secret to a human — never to source control (§12, §13). ## Platform state (verified 2026-09-20 — re-verify before trusting) Everything below came from fetching Rippling's own developer documentation and probing its hosts directly, with no browser session and no partner login. If the portal or docs do not look like this, stop and report what you actually see rather than clicking on. - **Credentials are partner-gated, not self-serve.** The documented sequence is: apply → approval → onboarding email → partner company → app listing → deploy sandbox → sandbox credentials → Integration Review → beta approval → production credentials. The sandbox client ID and secret "will only display… if you have already deployed your sandbox app listing"; the production pair appears only "once the app listing has been approved for beta testing." - **There are two API generations, both documented, both reachable.** Rippling's API Access page states: "As of November 2025, **all new App Shop partners** will use Rippling's **V2 REST API endpoints**. **Legacy partners** will continue to use Rippling's **V1 API endpoints**." New work targets REST. The V1 material is still published under its own legacy section for the partners already on it. - **REST API host:** `https://rest.ripplingapis.com`, with `https://rest.ripplingpreviewapis.com` published as a Preview endpoint. Both answered `401` to an unauthenticated read on 2026-09-20, which is how an authenticated host should answer. - **OAuth endpoints are on the `app.rippling.com` / `api.rippling.com` side, not on the REST host.** The install guide and the OIDC guide both name **`https://api.rippling.com/api/o/token/`** as the token endpoint for the authorization-code exchange and for refresh. - **Rippling's own machine-readable REST spec disagrees with its guides about the OAuth endpoints, and the spec is the one that is wrong.** The published spec (`https://developer.rippling.com/docs/rest-beta.yaml`, version `2024-08-01` on 2026-09-20) carries `authorizationUrl: https://app.rippling.com/o/authorize` and `tokenUrl: https://app.rippling.com/o/token`. A `POST` to `https://app.rippling.com/o/token` returned **404** on 2026-09-20, while `https://api.rippling.com/api/o/token/` returned **400** (i.e. it exists and rejected the empty request). Do not generate a client from that security scheme and assume the URLs. - **The OIDC authorize endpoint is `https://app.rippling.com/oidc/v1/authorize`, and Rippling says not to use it for installs.** Verbatim: "The OIDC flow should only be used after your app is installed in Rippling… The OIDC authorize endpoint should not be used as part the initial app installation flow or integration set up." - **The scope catalog is published and is REST-shaped**: `.read` / `.read-write`, e.g. `workers.read`, `work-locations.read-write`, `leave-requests.read`. The published spec carried roughly 250 scope entries on 2026-09-20. V1's grammar was different (`employee:name`, `company:teams`), and the two do not mix. - **Rate limits are a burst threshold, not a quota**: 300 requests per IP per sliding 10-second window, with a 10-second penalty during which everything is rejected. Pagination defaults to 50 and caps at 100; filters cap at 64 nodes; there is also a maximum expand depth. "There are no endpoint-specific limits as of today." - **Rippling explicitly disallows third-party integration services other than two named aggregators** (§intro, REQ2). This is the single most important fact on the page for a multi-tenant integration platform. - **Both `developer.rippling.com` and `app.rippling.com` are single-page apps that return HTTP 200 for any path, including nonsense ones.** A 200 from either proves nothing about a URL existing. The doc URLs in §References were confirmed against the site's own route manifest, not by status code alone. - **The Developer Terms of Use at `https://app.rippling.com/developer/tos` resolve but render client-side**, so the text of Sections 2.4 and 3.1.4 could not be read without a browser. Treat their content as unverified here and read them before relying on any summary of them. - **The REST API is actively developed.** Changelog entries dated 2026-08-25, 2026-08-28, 2026-09-01 and 2026-09-16 add endpoints, scopes and worker fields. This is the generation receiving investment. ## 1. Decide: reuse the existing listing, or create a new one **A new app listing means a new client ID, and every existing customer install is bound to the old one.** Every customer would have to install again. Reuse the existing listing for: adding a redirect URL, adding a scope, changing listing copy, or diagnosing a failure — Rippling supports all of these in place through *listing versions*, which let you stage and test changes without touching live customers. Create a new listing only when the user explicitly wants one: a genuinely separate product, or a replacement for a compromised client. Note two constraints that shape this decision: - **Only one listing version can be deployed as the sandbox at a time.** To test a new configuration you duplicate the listing version and replace the deployed sandbox app. - **Adding scopes does not reach existing installs.** Rippling: "current installs will need to reauthorize the app in order to grant the app access to the new scopes." Every already-connected customer must be walked back through the authorize step. Plan scope changes as customer-facing migrations, not config edits. Say which path you are taking before you touch anything. ## 2. The gate: applying to the App Shop **This is where most runs end, and ending here is a correct outcome.** Rippling's integration checklist begins: "To develop an integration for Rippling's App Shop, you will need to follow the development process listed below… Apply for listing in the App Shop" — through Rippling's public service desk at `https://rippling-public.atlassian.net/servicedesk/customer/portal/3`. Then: "If approved, look out for your onboarding email." What to know before starting: - **Approval precedes everything technical.** No partner company, no test company, no listing, no credentials. - **There is no fee.** "The usage of the Rippling API is completely free to partners as of now" and "there is no cost to the app partner to list their app on the Rippling App Shop." The cost lands on the *customer*: per REQ1, every partner integration requires the customer to hold Rippling's **Identity & Access Management** package. A customer without it cannot install your app no matter how correct your OAuth is. - **Rippling sets requirements that are not optional for a listed app** (§11): SSO by default, user management by default, pagination, refresh-token handling, and a Rippling-initiated install flow that ends back in Rippling. - **Do not promise dates.** REQ5: partners agree "not to commit to timelines or promise launch dates to customers prior to or during the integration process," and not to share the integration with customers or display Rippling-branded collateral before beta is approved. That is a contractual constraint on how this work gets communicated, and worth repeating to whoever is asking for an ETA. - **Partner support is an email address**, `partner.support@rippling.com`, named on every developer-portal page. When something in this skill does not match reality, that is the address to ask, and the answer is worth writing back into this file. - **The third-party-integration-service restriction (REQ2) applies here, up front.** Do not submit an application that misdescribes what is being built. Whether an exception exists for a given platform is a conversation with Rippling's partnerships team, and a decision for a human on your side. Everything in this section — eligibility, the application's contents, commercial and legal terms — is a business decision. Collect the questions and hand them back (§Stop and ask). ## 3. Accounts: the partner company and the test company Once approved, Rippling provisions **two** Rippling companies and emails the credentials: - **The partner company** — where app listings live, where scopes and redirect URLs are configured, and where the client ID and secret are displayed. Sign in at `https://app.rippling.com` with the provided credentials, complete a short onboarding flow, then open the **Partner** app. - **The test company** — a separate Rippling company you switch into to install and exercise your own app. This is Rippling's sandbox: **there is no self-serve sandbox**, and no way to get one before approval. Practical notes that waste an afternoon each: - **Profile switching is a real mechanic**, not a detail. You act as the partner company to build and as the test company to install, and the docs tell you to practise switching between them before you start. Rippling's deploy-notification email only works if the last tab you visited was the test company. - **Two-factor setup and reset are documented partner-company procedures**, so expect human-only steps. - **The first sandbox deploy can take up to 30 minutes.** Subsequent syncs take seconds. Do not interpret the first wait as a failure. **Human-only steps** — receiving the onboarding email, first login, two-factor enrolment, accepting terms, submitting the review forms — hand back rather than looping on them. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an ordered click path with the literal values to paste — the redirect URLs from §5 and the scope strings from §6 — and continue once they report back with the client ID. ## 4. Create and configure the app listing In the partner company: **Create a new listing**, name it, then open the **listing version**. An app listing can carry many versions; versions are how you stage changes, and they can be submitted for review, renamed, duplicated and deleted without affecting customers. Rippling's own advice is to "complete the OAuth connection with a light-weight app listing to start and then add capabilities." The minimum that makes an install work: | Setting | Where | Why it matters | | --- | --- | --- | | Display name | General → Identifiers | What customers see in the App Shop; also appears in the install and authorize URLs | | Main category | Listing Information → Categories | Required before install | | Icon | Listing Information → Icons | Required before install | | Default Redirect URL | Integration → Redirect | The only redirect used for Rippling-initiated installs (§5) | | Scopes | Integration → Scopes | Determines every endpoint you may call (§6) | The **Integration → Credentials** tab is where the client ID and secret appear, split into **Sandbox** and **Production** sub-tabs (§9). The **Testing** tab is where you deploy to the test company and track beta testers. Two FAQ answers from Rippling worth keeping: IP allowlisting is not required for an app listing, though Rippling publishes its IP ranges at `https://www.rippling.com/ip-ranges`; and listing assets (copy, images, promotions) can be updated at any time directly in the partner company. ## 5. Redirect URLs Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` How Rippling treats them, which is unusual enough to read twice: - **One is privileged.** The **Default Redirect URL** is "the URL that Rippling redirects to anytime an app installation starts from Rippling." Every Rippling-initiated install — the flow Rippling *requires* you to support, per REQ7 — uses that one and only that one. - **Additional redirect URLs exist but are only reachable from the partner-initiated flow**, and only when explicitly passed: "Rippling will only redirect to the additional URLs during the partner-initiated installation flow and if you explicitly pass in the URL as a parameter." The parameter form is `https://app.rippling.com/apps/PLATFORM/{APPNAME}/authorize?response_type=code&client_id={CLIENT_ID}&redirect_uri={URL}`. - **A URL passed as a parameter is ignored unless it is already configured.** "Rippling will not redirect to any URL that is passed as a parameter unless it has been configured as an additional redirect URL in your app listing." - **`http` is refused**; Rippling's own suggestion for local work is a tunnelling service. - **The exchange must echo it.** `redirect_uri` is a required parameter on the token request and "must match the redirect URL you had provided to Rippling." **The consequence for a multi-region platform is structural, and worth settling before anything else:** only one of your four hosts can be the default. Either every Rippling-initiated install lands on one region's callback and is routed onward from there, or each region needs its own app listing — which means its own client ID and its own App Shop listing. That is an architecture decision for a human, not something to improvise (§Stop and ask). ## 6. Scopes — and why per-customer scope selection does not exist here Scopes are managed per listing version under **Integration → Scopes**. "By default, your app listing will start with no scopes." You add them from a categorised picker, and each one shows a status. **The rule that breaks the usual design:** "Rippling API scopes are configurable within each app listing. **Scopes do not vary by customer** for your app listing. Customers cannot choose to turn off scope ABC but turn on scope DEF when they install your app listing. As part of every app installation flow, each customer admin must provide consent and authorize **all** of the scopes that your app listing requires." So a design that computes a per-connection scope string from the objects a customer enabled has nothing to attach to on Rippling. The listing's scope set *is* the grant. Pick the minimum set that covers every object you intend to support, and remember that widening it later forces **every existing customer to reauthorize** (§1). **Public vs private scopes.** Public scopes are "automatically approved and then added to your app listing." Private scopes "require an approval from Rippling before being added," and the request form demands a business case: "Reasons must be a comprehensively documented business case. Please capture why your app listing needs this data, what will you do with this data, and what value will this scope add to the customers." Rippling adds: "Access to Rippling's private API scopes is extremely limited." Which scopes are private is not published — the picker shows it. Plan for a refusal on anything sensitive, and never write a business case on the user's behalf. **The catalog, as Rippling spells it.** REST scopes are `.read` and `.read-write`. A sample from Rippling's published API Scope Reference, chosen for what an HRIS/ATS connector reaches for: | Scope | Covers | | --- | --- | | `workers.read` | Workers — list and retrieve. Marked as granting sensitive data | | `workers.custom-fields.read`, `workers.sensitive.personal.read` | Worker custom fields (Data Manager) and country-specific sensitive personal fields | | `compensations.read`, `compensation-bands-details.read` | Compensation and bands. Sensitive | | `companies.read` | Companies accessible to the authorized client | | `departments.read` / `.read-write`, `teams.read`, `supergroups.read` / `.read-write`, `levels.read`, `titles.read` | Org structure | | `work-locations.read` / `.read-write`, `legal-entities.read` | Locations and legal entities | | `leave-requests.read` / `.read-write`, `leave-balances.read`, `leave-types.read`, `leave-accruals.read` | Time off | | `time-entries.read` / `.read-write`, `time-cards.read`, `shiftassignments.read` / `.read-write`, `unassignedshifts.read` / `.read-write`, `schedules.read`, `shift-inputs.read` | Time and attendance | | `candidates.read`, `candidate-applications.read` / `.read-write`, `job-requisitions.read` | ATS | | `users.read`, `sso-me.read` | Users, and the current authenticated user's SSO information | | `custom-objects.read` / `.read-write`, `custom-object-records.read` / `.read-write`, `custom-fields.read` | Platform custom data | Traps in this catalog, each of which has cost someone a day: - **Do not extrapolate a scope name from a sibling.** The grammar is mostly hyphenated-plural-dot-verb, but there are deliberate exceptions — `shiftassignments.read` and `unassignedshifts.read` have no hyphen, while `shift-inputs.read` does. Read the endpoint page or the published spec for the operation you actually call. - **A scope that is not in the catalog is not a scope.** As of 2026-09-20 there is no `groups.read` in either the published scope reference or the published spec, and no `/groups` endpoint; group-shaped data comes from `/teams` and `/departments` under `teams.read` and `departments.read`. Any recorded scope string should be re-checked against the catalog rather than trusted because it once worked. - **Some fields have their own scopes, and a missing one returns `null` rather than an error.** Rippling's 2026-09-01 changelog adds `workers.pay-schedule.read`, `workers.work-schedule.read`, `workers.compensation-grade.read` and `workers.original-start-date.read`, noting "A request without the matching scope receives `null` for that field." Silent nulls are the most misdiagnosed Rippling symptom — see §12. - **Some scopes need a parent to appear.** Rippling: "Certain scopes, such as `employee:read`, are only available once a child scope has been added." - **Endpoints carry entitlement requirements on top of scopes.** The published spec tags operations with `Requires: API Tier 1` (the common case), and also `Global Payroll`, `Developer Program`, `Custom Apps Platform API`, `Worker Changes API`, `Functions API` and `Developer Base`. A customer without the entitlement fails even with a perfect scope set, and you cannot see their entitlements from your side. - **A scope added to the listing is not in the sandbox app until you redeploy it.** Rippling's own FAQ: "I don't see a scope on my sandbox app but it is on my app listing" — redeploy. `openid`, `profile` and `email` are OIDC-layer scopes used by the SSO flow (§8), not entries in the REST catalog. ## 7. Which API generation to build against Getting this wrong is expensive and quiet, because the retired generation's documentation is still published and still reads like current guidance. | | Current — **REST API** | Legacy — **V1** | | --- | --- | --- | | Who | "All new App Shop partners", as of November 2025 | "Legacy partners" already on it | | API host | `https://rest.ripplingapis.com` (Preview: `https://rest.ripplingpreviewapis.com`) | `https://api.rippling.com/platform/api/…` | | Scope grammar | `workers.read`, `leave-requests.read-write` | `employee:name`, `company:teams` | | Paging | forward-only, `limit` + `cursor` | `limit` + `offset` | | Versioning | dated versions, `Rippling-Api-Version` header | — | | Activity | endpoints and scopes added through September 2026 | maintained for existing partners | Notes that matter in practice: - **The OAuth endpoints are shared between generations.** Both exchange codes at `https://api.rippling.com/api/o/token/`. Only the data plane differs. So "our token exchange works" tells you nothing about which generation you are on. - **The install guide itself mixes the two**: it documents the token endpoint and a V1-era "mark app installed" call on `api.rippling.com`, then demonstrates linking the connection with a REST call to `https://rest.ripplingapis.com/companies`. That is not an error to route around — it is the actual shape of the platform, and it means a working integration talks to two hosts. - **Versioning is dated, and partners cannot pin.** Customers pick an API version when creating a token; for App Shop partners, "Reauthorization is necessary if a new version introduces breaking changes or alters scopes. Currently, partners cannot stay on the previous version once changes are adopted." Track the changelog. - **Pagination is capped by contract as well as by the API.** REQ6: "Partners must use pagination… Pagination must not be set to more than 100." The REST API defaults to 50 and caps at 100 anyway. ## 8. The connect flow is an *install*, not a consent screen This is the section that changes how a connector must be built. On most platforms your product sends the customer to an authorize URL and receives a code. On Rippling the customer starts in **their own Rippling account**, in the App Shop, and your OAuth leg is the middle of an installation that must come back to Rippling to finish. Rippling requires it (REQ7, REQ9) and enforces it at review. **The URLs:** | Purpose | URL | | --- | --- | | App Shop listing (Rippling-initiated install starts here) | `https://app.rippling.com/app-shop/app/{APPNAME}` | | Authorize step (partner-initiated install; also re-authorization) | `https://app.rippling.com/apps/PLATFORM/{APPNAME}/authorize` | | Return screen (where the install must finish) | `https://app.rippling.com/apps/PLATFORM/{APPNAME}/return` | | Token exchange and refresh | `https://api.rippling.com/api/o/token/` | | OIDC authorize — **SSO only, not install** | `https://app.rippling.com/oidc/v1/authorize` | | REST API base | `https://rest.ripplingapis.com` | **The flow, with the parts that bite:** 1. The customer's admin opens your listing in the App Shop and starts the install. **They must be a Super Admin, Full Admin or App Admin**; anyone else cannot complete it, and the fix is a permission change in Rippling, not in your code. 2. Rippling redirects to your **Default Redirect URL** carrying three query parameters: `code`, `buy` (whether the user said they are buying a new account or connecting an existing one), and **`redirect_uri` — the URL on Rippling you must send the user back to.** Rippling's example: `…/callback?code=…&redirect_uri=https%3A%2F%2Fapp.rippling.com%2Fapps%2FPLATFORM%2FPortalTest%2Freturn&buy=false`. **A connect flow that drops that parameter and finishes on your own success page does not satisfy REQ9**, and the customer is left with an install Rippling considers unfinished. 3. **The code is valid for 300 seconds and is single-use.** Miss the window and the customer repeats the redirect step. Extract it server-side: "The authorization code should not be grabbed directly from the URL link after the redirect." 4. Exchange it: `POST https://api.rippling.com/api/o/token/`, form-encoded, with `grant_type=authorization_code`, `code` and `redirect_uri` in the body and **client ID and secret as an HTTP Basic header** — base64 of `client_id:client_secret`. Rippling documents Basic for the exchange *and* for refresh; it does not document client credentials in the body. PKCE does not appear anywhere in Rippling's documented flow. 5. Sign the user in on your side so you can associate the connection with their account, then **redirect them to the Rippling `redirect_uri` you were handed** to finish the install. 6. Optionally call Rippling's mark-app-installed endpoint to finalize (REQ11), and read the company (REQ10) so the Rippling company is matched to the right tenant on your side without the customer typing anything. **One access token equals one Rippling company.** "If twelve Rippling companies have installed your app, then twelve Access Tokens will need to be used." There is no cross-tenant token. **The OIDC endpoint is for SSO after install, and only that.** If a connector points its authorize step at `https://app.rippling.com/oidc/v1/authorize`, it is using the SSO leg for an install — contrary to Rippling's explicit instruction — and is not running the App Shop install flow that REQ7 through REQ11 describe. Treat that as a finding to raise, not a shortcut to keep. ## 9. Capture the credentials, and know what each token is worth **Where they are.** Listing version → **Integration → Credentials**, with two tabs: - **Sandbox** — "only display a client ID/client secret if you have already deployed your sandbox app listing." These install your sandbox app in your test company. "Customers should never use these credentials." - **Production** — appears "once the app listing has been approved for beta testing." Every customer install uses this pair, including beta customers: "Can I use my sandbox app credentials for my beta customers? No, you cannot." **They do not switch.** Rippling: "Will the credentials switch? No, they will not." Sandbox and production are two permanent pairs on the same listing, and mixing them produces a specific error (§12). **Token shape and lifetimes:** | Token | What Rippling says | | --- | --- | | Authorization code | 300 seconds, single use | | Access token | Read `expires_in` from the response and honour it — Rippling's own example shows `129600` (36 hours), and its OIDC page says tokens are "valid for three days or until refreshed". **Do not hardcode a lifetime; the docs are not consistent and the response is authoritative.** | | Refresh token | **Single-use and rotating**: "Once a refresh token is used to retrieve another access token, that refresh token will be revoked." There is a **1-hour grace period** after first use in which it can be replayed. Store the new one from every refresh. | **Refreshing** posts to the same token URL with `grant_type=refresh_token` and `refresh_token`, again with the Basic header. REQ12 makes automatic refresh a listing requirement: "Customers should never see errors in the integration due to access tokens not being refreshed." **What revokes a connection outright** — worth building customer-facing messaging for, because none of it is your bug: - The customer uninstalls your app. Rippling emits a `company.deleted` webhook, and calls start returning an error whose body reads **"The token has been revoked"**. - **The admin who installed the app is terminated.** "All tokens associated with an employee are revoked when the employee is terminated… the person who has 'installed' the app must always be in the company." - That admin loses the permissions the app needs, which produces a 403 rather than a 401. **Recovery is always the same shape**: the customer re-installs or re-authorizes at `https://app.rippling.com/apps/PLATFORM/{APPNAME}/authorize`, producing a fresh code and a fresh token pair. There is no server-side way to revive a revoked pair. **Rotation.** Rippling does not document a self-serve secret rotation for a listing's credentials. Treat a rotation request as a partner-support conversation, and never rotate without a cutover plan and explicit go-ahead. ### The other credential: customer-created API tokens Separately from partner OAuth, **a Rippling customer can mint their own API token** inside their own account — `https://app.rippling.com/api-tokens/tokens`, or **Tools → Developer → API Tokens**. It is a bearer token against `https://rest.ripplingapis.com`, created with a name, an API version and a scope selection. Facts that decide whether it is usable: - **It inherits its creator.** Access is "determined by two factors: the permission profile of the user who created the token" and the scopes chosen. A user cannot select a scope their profile does not allow, and "changes to a user's permissions automatically applies to all tokens they've created." - **It is shown once.** "You cannot view the API token again." - **It dies quietly.** "Automatically revoked or expired if the owner is terminated or the token remains unused for over 30 days." A revoked token returns `401`. The 30-day idle expiry catches low-traffic tenants. - **Only the creator can edit its scopes**; token admins can change a token's name and API version but not its scopes. - **It is not a substitute for partner OAuth on the App Shop path.** REQ2: "3rd-party vendors using API keys are not supported." This credential is for a customer's own internal integration. ## 10. What this platform's connector currently does — dated product facts **As of 2026-09-20**, and stated as an observation to confirm with the connector's owner, because connector configuration changes far more often than a vendor portal does: - It supports **two credential modes** — an OAuth 2.0 authorization-code connection, and a customer-supplied API token sent as a bearer credential, with onboarding text pointing the customer at **Tools → Developer → API Tokens** and warning that the value is shown once. Note the API-token mode against REQ2 (§intro, §9). - It targets the **current REST generation**: `https://rest.ripplingapis.com`, with REST-shaped paths (`/workers`, `/work-locations`, `/leave-requests`, `/shiftassignments`, `/candidates`, `/candidate-applications`, `/job-requisitions`, `/payroll-runs`, `/teams`, `/departments`) and Rippling's `order_by`, `limit` and cursor-style paging. That is the right generation. - It sends the token exchange to **`https://app.rippling.com/api/o/token`** rather than the documented `https://api.rippling.com/api/o/token/`. Both hosts answered a credential-less `POST` with `400` on 2026-09-20, so the recorded one is live — but it is not the URL Rippling documents, and it is worth aligning rather than relying on an undocumented alias. - It points its **authorize step at `https://app.rippling.com/oidc/v1/authorize`** — the OIDC SSO endpoint Rippling says must not be used for installs (§8). This is the connector fact most worth raising with its owner. - It sends **client ID and secret as an HTTP Basic header** on both the exchange and the refresh, which matches Rippling's documentation, and sends **no PKCE**, which also matches (Rippling documents none). - It carries **per-object scope sets** — `workers.read` and `leave-balances.read` for employees, `work-locations.read` / `.read-write` for locations, `shiftassignments.read` for shifts, `departments.read` for groups, `candidates.read`, `job-requisitions.read` and `candidate-applications.read` for ATS, `leave-requests.read` / `.read-write` for time off, and `payroll-runs.read` with `worker-payroll-records.read` for payslips — plus `openid`, `profile` and `email` on every set and `sso-me.read` on the login set. Against §6: per-object scope sets have no effect on Rippling, where the *listing* holds the grant; and one recorded string, `groups.read`, is **not** in Rippling's published catalog or spec, while `teams.read` — needed for the teams half of group data — is absent from the recorded sets. - It ships **no shared platform-level Rippling OAuth credentials**: every workspace must supply its own client ID and secret, which in practice means its own Rippling partnership (§2). - It records Rippling's **sandbox as not self-serve** and links the partner process page — consistent with §2 and §3. - Its recorded **API-token onboarding path and filter grammar** (Rippling uses `ge`/`le` rather than `gte`/`lte` in filter expressions) match Rippling's current query-parameter documentation. ## 11. Review, requirements and publishing **The listing requirements are contractual, and they are broader than OAuth.** A listed Rippling app must, by default: - Support **SSO** — SAML or OIDC (REQ3), with SAML metadata fetched automatically per install and **unique to every customer install**, including re-installs by the same company (REQ20). - Support **user management** (REQ4): a webhook listener for access-granted and access-removed events (~5-minute lag), a scheduled sync at least every 24 hours, a manual sync button in your product, and **automated employee matching** so admins are not asked to map users by hand (REQ13–REQ17). - Support the **Rippling-initiated install** that ends back in Rippling (REQ7, REQ9), with **no manual data entry** to establish the connection (REQ10). - **Refresh tokens automatically** (REQ12) and **paginate** at no more than 100 (REQ6). - If OIDC SSO is enabled on the listing, **every customer must configure it** during setup — "unlike SAML SSO, OIDC SSO is not optional." **The review path:** 1. **Integration Review form** → approval for **beta**. The form covers a general requirements checklist, installation flow checklists and videos, SSO, user management, data sync, support and maintenance, a Rippling test account, and your beta testers. Once submitted, the only way to edit the listing is to cancel the review and revert to draft (which does not affect existing installs). 2. Production credentials appear; **at least three outside companies** (beyond your test company) must install the production listing. 3. **Launch Review form** → approval to publish. It asks for integration documentation (required) and optionally a sales demo. 4. **Publish** — the listing becomes discoverable in the App Shop. Publishing does not disturb beta installs. Beta is production in all but discoverability: "Beta testers interacting with your app will experience the same functionality as real customers would after the app is published." **Limits to design against** (§Platform state): 300 requests per IP per sliding 10-second window with a 10-second total rejection penalty; pagination 50 default / 100 max; 64 filter nodes; a maximum expand depth. On `429`, "stop sending requests temporarily," back off exponentially, and do not retry immediately — the penalty window resets from zero only after it elapses. Rippling reserves the right to change limits at any time. Everything about the review forms, the three beta companies, security and legal claims is a business decision. Do not answer any of it on the user's behalf. ## 12. Verify end-to-end "The token came back" proves very little here. Test the customer's path. 1. **Install the sandbox app in the test company** from the App Shop link on the sandbox-testing tab — not by hand-rolling an authorize URL. Confirm the listing has a category, an icon, a default redirect URL and its scopes, and that the sandbox app has finished deploying. 2. **Run the full install**, including the return leg: confirm your flow reads the `redirect_uri` parameter Rippling sent and sends the admin back to Rippling's return screen (§8). An install that ends on your own page is a review failure waiting to happen. 3. **Exchange within 300 seconds**, with Basic auth, and confirm the response carries a refresh token. 4. **Refresh, then refresh again using the token returned by the first refresh.** Two minutes of work for a failure that otherwise appears a day later (§9). 5. **Make one read per scope family** against `https://rest.ripplingapis.com`, and **compare the fields you get with the fields you expect** — a missing field-level scope returns `null`, not an error (§6). 6. **Page past the first page** with a cursor Rippling actually returned, at `limit` ≤ 100. 7. **Try installing as a non-admin** if any customer might. Expect failure, and design the message for it. 8. **Uninstall from the test company** and confirm your side handles the revoked-token error and the `company.deleted` webhook by resetting state for a future re-install. | Symptom | Cause | | --- | --- | | `401 Invalid Client` when installing your sandbox app in the test company | Production credentials used against the sandbox app, or the reverse. The two pairs never switch (§9) | | No client ID or secret visible on the Credentials tab | Sandbox: the sandbox app has not been deployed. Production: the listing has not been approved for beta (§9) | | A scope is on the listing but the sandbox app does not have it | The sandbox app was not redeployed after the scope was added (§6) | | `403` with body "The token has been revoked" | The customer uninstalled the app; also emitted as a `company.deleted` webhook. Reset state and await re-install (§9) | | `401` on a connection that worked for months | The admin who installed the app was terminated — all their tokens are revoked. Another admin must re-authorize (§9) | | `403` right after a successful install | The installing admin lacks the permissions the app's scopes imply. Re-installing as the same user yields the same 403; a higher-permissioned admin must install (§8) | | Fields come back `null` for everyone, no error | A field-level scope is missing — Rippling returns `null` rather than failing (§6) | | A whole endpoint `403`s for some customers only | An entitlement gate (`API Tier 1`, `Global Payroll`, `Developer Program`, …), or the customer lacks the Identity & Access Management package (§6, §2) | | Token exchange `404`s | The endpoint from the published spec (`app.rippling.com/o/token`) was used; it returned 404 on 2026-09-20. Use `https://api.rippling.com/api/o/token/` (§Platform state) | | Authorization code rejected as invalid | Older than 300 seconds, or already used once (§8) | | Second refresh fails although the first succeeded | The rotated refresh token is not being stored; the 1-hour grace period masked it once (§9) | | Redirect lands somewhere unexpected on a Rippling-initiated install | Only the **Default** Redirect URL is used for Rippling-initiated installs; additional URLs work solely in the partner-initiated flow and only when passed explicitly (§5) | | A redirect URL passed as a parameter is ignored | It is not configured on the listing. Rippling only redirects to configured URLs (§5) | | Cannot add a redirect URL | It is `http`. Rippling refuses plain `http` (§5) | | Everything fails for ~10 seconds under load | The 300-requests-per-IP-per-10-seconds burst threshold and its penalty window (§11) | | Endpoint or scope names from an older runbook are rejected | V1 grammar against the REST API, or the reverse (§7) | | The customer cannot find your app in the App Shop | Still in beta — reachable only by direct production URL — or the customer lacks the Identity & Access Management package (§11, §2) | ## 13. Hand off — never commit the secret - **Do not** write a client secret or a customer API token into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the human running this, for their secret store or console. - **Do not paste a live token into a support email.** Rippling's troubleshooting guide asks for what you were doing, when, the status code and the response's request identifier — send those, redact the credential. - If a code change is needed (a callback host, a scope string, the token URL), keep it credential-free and say plainly what the human must set out of band. - Report a secret once so it can be pasted into the secret store, say clearly that it is now in the transcript and can be rotated, then move on. - Close with: partnership status and who owns the partner company; the app listing name and the `{APPNAME}` that appears in the install, authorize and return URLs; which credential pair (sandbox or production) and its client ID; where the secret was delivered; the exact Default Redirect URL registered and any additional ones; the exact scope strings on the listing and any private-scope requests still pending; which API generation the integration targets; the Super/Full/App Admin requirement to pass to customers; the refresh-rotation behaviour your client must honour; and whatever is still waiting on Rippling. ## Stop and ask Hand back to a human rather than guessing when: - **There is no Rippling partnership** and the ask is OAuth credentials — say so in the first reply rather than starting something that cannot finish (§2). - **The integration is a third-party integration service connecting many customers' Rippling accounts.** REQ2 and the Developer Terms of Use address that model directly, naming only two sanctioned aggregators. This is a partnerships conversation and a legal review, not a form to fill in (§intro). - The App Shop application, Integration Review or Launch Review needs business, security or compliance claims, a customer count, or a commitment to a date (REQ5 forbids the last one). - **Four regional callbacks must coexist** and only one can be the Default Redirect URL — one listing routed through a single callback, or one listing per region, is an architecture decision (§5). - A **private scope** is needed: the justification is a business statement, and Rippling says access is "extremely limited" (§6). - Someone proposes creating a **new listing** for a live integration, which forces every customer to install again (§1). - A customer's admin cannot complete an install, or data is missing for one tenant only — that is Rippling permissions or entitlements, changed on their side (§8, §12). - Rippling's documentation contradicts itself on something load-bearing — token URL, token lifetime, scope name — and the answer must come from `partner.support@rippling.com` rather than from a guess (§Platform state). - The portal or docs no longer match the **Platform state** section above. Note that this skill was written without browser automation: every fact came from fetching Rippling's public documentation and probing its hosts. A run that also has no browser should hand click paths and literal values to the user rather than simulating a portal session. ## References Verified to return HTTP 200 on 2026-09-20. Note that `developer.rippling.com` and `app.rippling.com` are single-page apps that answer 200 for any path — the pages below were additionally confirmed to exist in the documentation site's own route manifest. - Developer home — https://developer.rippling.com/ - Integration Checklist (the partner process, end to end) — https://developer.rippling.com/documentation/developer-portal/getting-started/process - Partner Requirements (REQ1–REQ24, incl. REQ2 on third-party integration services) — https://developer.rippling.com/documentation/developer-portal/getting-started/requirements - API Access (which generation new partners use) — https://developer.rippling.com/documentation/developer-portal/getting-started/api - Pricing and packaging (Identity & Access Management requirement) — https://developer.rippling.com/documentation/developer-portal/getting-started/pricing - Register your Rippling Account (partner + test company onboarding) — https://developer.rippling.com/documentation/developer-portal/v2-guides/registration - Installation (OAuth) — install flow, token exchange, refresh, lifetimes — https://developer.rippling.com/documentation/developer-portal/v2-guides/installation - OAuth Credentials (sandbox vs production pairs) — https://developer.rippling.com/documentation/developer-portal/v2-guides/oauth - Redirect URLs (default vs additional) — https://developer.rippling.com/documentation/developer-portal/v2-guides/redirect - Scopes (listing-level grant, public vs private) — https://developer.rippling.com/documentation/developer-portal/v2-guides/scopes - OpenID Connect SSO (OIDC authorize endpoint, and when not to use it) — https://developer.rippling.com/documentation/developer-portal/v2-guides/openid - Errors (401/403/500/504, revoked tokens) — https://developer.rippling.com/documentation/developer-portal/v2-guides/errors - Webhooks — https://developer.rippling.com/documentation/developer-portal/v2-guides/webhooks - Create an App Listing — https://developer.rippling.com/documentation/developer-portal/creating-publishing/create - Deploy an App Listing (sandbox deploy; 30-minute first deploy) — https://developer.rippling.com/documentation/developer-portal/creating-publishing/deploy - Install an App Listing (with and without Postman) — https://developer.rippling.com/documentation/developer-portal/creating-publishing/install - Manage Beta Testing (Integration Review, three outside companies, Launch Review) — https://developer.rippling.com/documentation/developer-portal/creating-publishing/testing - Publish an App Listing — https://developer.rippling.com/documentation/developer-portal/creating-publishing/publish - Legacy API Reference (V1 partners) — https://developer.rippling.com/documentation/developer-portal/legacy-api/api - V1 scope list (the older grammar, for contrast) — https://developer.rippling.com/documentation/base-api/scopes - REST API Scope Reference (the current catalog) — https://developer.rippling.com/documentation/rest-api/essentials/permissions - API Tokens and Permissions (customer-created tokens) — https://developer.rippling.com/documentation/rest-api/essentials/api-tokens - API Usage and Rate Limits — https://developer.rippling.com/documentation/rest-api/essentials/api-limits - API Errors and Troubleshooting — https://developer.rippling.com/documentation/rest-api/essentials/troubleshooting - API Versioning and Upgrades — https://developer.rippling.com/documentation/rest-api/essentials/versioning - REST API changelog — https://developer.rippling.com/documentation/rest-api/essentials/changelog - Published REST OpenAPI document (scope catalog; note its OAuth URLs are wrong — §Platform state) — https://developer.rippling.com/docs/rest-beta.yaml - App Shop listing application (service desk) — https://rippling-public.atlassian.net/servicedesk/customer/portal/3 - Rippling Developer Terms of Use (renders client-side; read in a browser) — https://app.rippling.com/developer/tos - Rippling IP ranges — https://www.rippling.com/ip-ranges - Rippling App Shop — https://www.rippling.com/app-shop - Rippling partners overview — https://www.rippling.com/partners - Admin permissions for installing apps (Help Center) — https://help.rippling.com/article?articleId=508625197432 ## sage-accounting-oauth-app URL: https://docs.unified.to/skills/sage-accounting-oauth-app/SKILL.md # Sage Accounting OAuth2 App Registration Get a working Sage Accounting OAuth2 client — a Sage App Registry account, a registered app, callback URLs, a client ID and a client secret — for a platform that connects many customers' Sage Accounting businesses. Sage's registration form is the easiest part of this job. Four things outside the form decide whether the integration works, and all four are silent failures. **Sage Accounting is sold per country, and the country is part of the auth flow.** The v3.1 API serves exactly three countries — the UK, Ireland and Canada — behind one API host but **two regional authorization servers**. If the country the user picks does not match the country their business is registered in, authorization fails. That is §7. **One token reaches every business the user can see, and you get the wrong one by default.** A Sage access token belongs to a *user*, not a business. Unless you send an `X-Business` header, every call resolves against that user's *lead business* — the first business they ever created. For an accountant on Partner Edition with forty client businesses, that is the accountant's own practice file. That is §8. **The tokens are the shortest of any major accounting API.** Access tokens last **five minutes**. Refresh tokens are **single-use**, rotate on every refresh, and expire **31 days** after issue. A connector that refreshes lazily, or that fires two concurrent refreshes, breaks. That is §9. **Scopes are two words, not a list.** Sage offers `readonly` or `full_access`. There is no per-object scope, so the consent screen a customer sees is effectively "read everything" or "read and write everything". That is §5, and it is a conversation with the customer, not a configuration choice. ## Which Sage product this is Sage sells many things with similar names and they do not share a developer portal, an auth model or an API. | This skill covers | This skill does **not** cover | | --- | --- | | **Sage Accounting** (formerly Sage Business Cloud Accounting, formerly Sage One), **API v3.1**, host `https://api.accounting.sage.com/v3.1`, OAuth2 authorization code, countries **GB / IE / CA** | **Sage Intacct** — separate portal, separate REST API and OAuth model | | Includes the **Accounting Start** and **Accounting Plus** variants of the same product | **Sage 50**, **Sage 100**, **Sage 200**, **Sage X3** — desktop/mid-market products, not this API | | | **Sage Active**, **Sage Payroll** — separate products and portals | | | **Sage Accounting South Africa** — a different regional product on **API v2.0.0** using **Basic Authentication**, its own enrolment form and its own API key portal. Not OAuth2, not this app | | | Regional Accounting versions for **Australia, New Zealand and Asia (HK, SG, MY)** — Sage documents these as separate and outside the v3.1 guide | | | **API v3.0**, which differed regionally across UKI, US, DE, ES, CA and FR — see **Platform state** | If the user says "Sage" and means a US, German, French, Spanish, Australian or South African business, stop and confirm which product before registering anything. A v3.1 app cannot serve them. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name** | Shown to Sage Accounting users on the consent screen (§3) | | **Contact email address** | Required; Sage uses it to reach you about the app. Not shown to users (§3) | | **Homepage URL** | Optional, but shown to users when given (§3) | | **Callback URLs** | Every callback host your platform serves. **Case sensitive** (§4) | | **App logo** | Optional, 250×250 recommended, shown during authorization (§3) | | **Which countries** customers will be in | GB, IE and CA are the only ones v3.1 serves, and it changes the authorize URL (§7) | | **Read-only or read-write** | Sage has exactly these two scopes; there is no middle ground (§5) | | **Will customers have more than one business?** | Accountants and bookkeepers almost always do. Blocking design question (§8) | | **Does the client store the rotated refresh token, and serialize refreshes?** | Blocking — a client that does neither dies within days (§9) | | **Is a Sage Marketplace listing wanted?** | Triggers Sage's validation call and co-branding requirements (§11) | | **New app, or an edit to an existing one?** | New credentials orphan every existing customer connection (§1) | ## Quick Start 1. Confirm this is **Sage Accounting**, not another Sage product (see the table above). 2. Confirm a **new app** is actually needed — existing connections are bound to the current client ID (§1). 3. Create the **App Registry** account, and separately a **trial Accounting business** to test with (§2). 4. Register the app and capture the client ID and secret (§3, §6). 5. Add **every** callback URL, byte-for-byte, case included (§4). 6. Decide `readonly` vs `full_access` with the customer, not for them (§5). 7. Pin the `country` parameter, or build a country picker — this is the top cause of failed connects (§7). 8. Decide how a customer picks a business, and send `X-Business` on every call (§8). 9. Confirm the client handles 5-minute access tokens and single-use, 31-day rotating refresh tokens (§9). 10. Verify a real authorize → callback → token → refresh → refresh-again round trip (§12). 11. Hand the credentials over — never commit them (§14). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Sage rebuilt the Accounting developer docs.** The old `developer.sage.com/accounting/guides/...` and `developer.sage.com/accounting/quick-start/...` paths now return the portal's 404 page. The live tree is `developer.sage.com/accounting/docs/v1.0.0/guides/...`. Any runbook still linking the old paths is stale, and its content may be too. - **`developer.sage.com` is behind Cloudflare bot protection and returns HTTP 403 to non-browser clients**, including `curl` with full browser headers. Every documentation page also publishes a plain-Markdown twin at the same path plus `.md` (the portal exposes it as "View as markdown"), and **those return HTTP 200 and are machine-readable**. The References section below uses the `.md` form for exactly this reason. If you need to check a page from a script, add `.md`; if you need to show it to a human, drop it. - **API v3 was deprecated on 31 July 2026** (announced 21 July 2025 on the Accounting announcements page). v1 and v2 were deprecated earlier. **v3.1 is the only version to build against**, and it is the version that unified the per-country base URLs into the single host `https://api.accounting.sage.com/v3.1`. - **v3.1 serves three countries: `gb`, `ie`, `ca`.** Sage's own regional guide describes v3.1 as the "Global Accounting Core" API for the Northern Hemisphere and names UK, IE and CA. The same guide states that South Africa runs a separate v2.0.0 product on Basic Auth, and that Australia, New Zealand and Asia have separate regional versions it does not cover. - **Trial businesses are currently UK-only.** Sage's quick-start lists trial sign-up links for United Kingdom across Accounting Start, Standard and Plus, and marks **Canada and Ireland as "Currently Unavailable"**. If you need an IE or CA test business today, that is a request to Sage, not a self-service step — raise it early. - **A developer trial business can be extended to 12 months free**, via a form on Sage's quick-start that asks for the registration email, app name, client ID and country (Canada / Ireland / United Kingdom only). Sage aims to confirm in **3–5 working days**. Without the extension you are on the ordinary commercial Accounting trial and the business will lapse; Sage does not publish the base trial length in the developer docs, so confirm it on the sign-up page for the region you use rather than assuming. - **PKCE is supported but optional.** `code_challenge` and `code_challenge_method` (`S256` or `plain`) are documented optional authorize parameters; `code_verifier` is then required at the exchange. - **There is no documented review gate before third parties can connect.** Sage states that no enrolment is required prior to developing, and that terms are agreed regionally *prior to review of your application* — which is the Marketplace listing path (§11), not a prerequisite for a private integration. Do not promise a customer either way without checking §11. - **Sign-in runs through Sage Identity (`id.sage.com`, Auth0)** with username → password → MFA OTP, before the consent screen. The consent screen appears **only** on the first authorization, after tokens are revoked, or after the user removes the app from Sage's "Manage Apps and Connections" page. A silent re-authorization is normal, not a bug. - **Partner Edition API support is documented as still in development**, even though Partner Edition users' businesses do show up on the businesses endpoint (§8). If the portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer connection is bound to the old one** — every customer re-authorizes, and each re-authorization is an action inside their Sage business that you cannot perform for them. Reuse the existing app for: adding a callback URL, changing the app name or logo, rotating a compromised secret, or diagnosing a failure. Sage lets you edit name, homepage and callback URLs in place. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, or a deliberate test app. Say which path you are taking before you touch the portal. ## 2. Two accounts, and they are not interchangeable This is the single most common first-time failure, and Sage calls it out twice in its own docs. | Account | What it is | Where | | --- | --- | --- | | **App Registry / developer self-service account** | Registers and manages apps, holds the client ID and secret, holds the callback URLs. **Free.** | `https://developerselfservice.sageone.com/` | | **Sage Accounting business user** | The account that actually *authorizes* your app and owns the accounting data | `https://app.sbc.sage.com/` | **Signing in with App Registry credentials during the OAuth flow fails.** The credentials the flow wants are those of a registered user of a Sage Accounting business — including a freshly created trial business. Creating the developer account: - Sign up at `https://developerselfservice.sageone.com/` with **GitHub** or **email**. Email signup asks for email, name, phone number and a password, then sends a confirmation link. - The verification mail comes from `donotreply@sageone.com`. Sage explicitly says to safelist that address **before** registering — a spam-filtered verification mail is a dead end that looks like a broken signup. - GitHub signup requires a public email on the GitHub profile, or Sage asks for one separately. Creating a test business: - Create a **trial Sage Accounting business** for the region you will test against (UK only, today — see **Platform state**), then request the **12-month developer extension** (§ Platform state). - Sage's own tip: use an email provider that supports plus-addressing or aliases, so several trial businesses live in one inbox. You will want more than one. - Check the business is live and you can reach its dashboard *before* attempting OAuth. Hand control back to the user for anything only a human can do: email verification, MFA enrolment and OTP entry, accepting terms, creating the trial business, and submitting the trial-extension form. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste — the callback URLs from §4 and the scope decision from §5 — then continue once they report back with the client ID. ## 3. Create the app In the App Registry, click **Create App**. Four fields: | Field | Required | Notes | | --- | --- | --- | | **App Name** | yes | **Displayed to Sage Accounting users when they authorize.** Treat it as go-to-market copy | | **Email Address** | yes | Sage contacts you here about app issues. Not shown to users | | **Homepage URL** | no | Shown to users when given | | **Callback URLs** | yes, at least one | Up to **100**, one per line (§4) | After saving, open the app to reach **App Details**, where the pencil icon edits name/homepage/callbacks and **Show** reveals the client ID and secret (§6). You can also upload a **symbol image** shown during authorization; Sage recommends **250 × 250 pixels**. There is no app-type choice, no confidential/public toggle, and no sandbox/production split. One app, one credential pair, and the same endpoints for trial and paying businesses — which is why §7's country parameter and §12's real round-trip matter more here than a key-environment check would elsewhere. ## 4. Callback URLs Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Rules, all documented by Sage: - **Up to 100 callback URLs per app**, at least one, **one per line**. - **`https://` is required**, with `http://` allowed only for local hosts. - **Callback URLs are case sensitive** and must match the `redirect_uri` in the authorize request *exactly*. Sage says this in both the quick-start and the OAuth troubleshooting guide, and lists it first among common issues both times. - The `redirect_uri` sent at the token exchange must be the registered value **with no additional parameters appended**. - If you test with Postman, register Postman's own callback URL as well — Sage documents this explicitly. An unregistered or mis-cased callback URL fails at the *start* of the flow, on Sage's side, before the user ever sees a login box. ## 5. Scopes: two words, and that is the whole vocabulary Sage Accounting has no per-object scopes. The `scope` parameter is **optional** on the authorize request and takes one of exactly two values: | Scope | Meaning | | --- | --- | | `readonly` | Read access | | `full_access` | Read and write | Consequences worth saying out loud before a customer sees the consent screen: - **There is no "invoices only" or "contacts only".** An integration that writes anything asks for `full_access`, which is read *and write* across everything the API exposes. If a customer's security review expects granular scopes, that conversation happens now, not at go-live. - **Send one value.** Sage documents the parameter as "Can be `readonly` or `full_access`", and `invalid_scope` as the error when the value "is invalid or unknown", with the advice to ensure the scope "is either `readonly` or `full_access`". A client that concatenates both into a space-delimited list is not sending a documented value — treat that as a defect to check for (§13), not as a supported way to ask for both. - The granted scope comes back in the token response as `scope`, so you can assert what you actually got rather than what you asked for. - **What the token can do is still bounded by the user.** The access token carries the rights of the Sage Accounting user who authorized, and Sage stamps that user's initials on transactions the API creates. Sage's best-practice guide is explicit: in a multi-user integration, hold **per-user** tokens, or every transaction your app writes will be attributed to whichever single user happened to connect. ## 6. Capture the credentials and endpoints From **App Details**, click **Show** on Client ID and Client Secret; a clipboard icon copies each. Unlike portals that reveal a secret once, Sage's App Registry lets you re-read both. Endpoints — the same for every country, and for trial and paying businesses alike: | Purpose | URL | | --- | --- | | Authorize | `https://www.sageone.com/oauth2/auth/central?filter=apiv3.1` | | Token (exchange and refresh) | `https://oauth.accounting.sage.com/token` | | Revoke (refresh tokens only) | `https://oauth.accounting.sage.com/revoke` | | API base | `https://api.accounting.sage.com/v3.1` | Mechanics that catch clients out: - Both the exchange and the refresh are **form POSTs** with `Content-Type: application/x-www-form-urlencoded` and `Accept: application/json`, carrying **`client_id` and `client_secret` in the body** — not an HTTP Basic header. Sage lists "the required header parameters have not been set" among its common OAuth failures. - **Sage client IDs can contain a `/`.** Sage's own worked example is two UUIDs joined by a slash, URL-encoded as `%2F` in the authorize URL. A client that interpolates the client ID into a URL without encoding it will build a broken authorize link. - `filter=apiv3.1` on the authorize URL restricts the country list to countries that support v3.1. Sage notes it is **ignored when a `country` is supplied** (§7). - **Revocation takes the refresh token, and only the refresh token.** Access tokens cannot be revoked — they are valid for their full five minutes no matter what. Revoking returns `{"success": "ok"}` with HTTP 200, and the user must re-authorize afterwards. Rotating the client secret breaks every exchange and refresh until the new value is deployed. Issued access tokens live out their five minutes. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. ## 7. Country and region: the routing trap Sage Accounting is sold per country, and the authorize request is where that shows up. The authorize URL's optional **`country`** parameter takes `ca`, `gb` or `ie`. There is also an optional **`locale`** (e.g. `en-CA`, `en-GB`), which may be combined with `country`. What happens when you omit it: 1. The user first sees a **country/region selection screen**. 2. Their choice routes the request to **one of two authorization servers — UK or North America**. 3. **If the country they pick does not match the country their business is registered in, authorization fails.** Sage gives the exact example: pass or pick `GB` for a business registered in the US and the auth fails. Sage's own guidance, which is also the right architecture: - If your integration supports **one** country, **always pass `country`**. It skips the selection screen and removes the whole class of failure. - If you support **some but not all**, put a country picker in *your* UI and pass the result. - The callback carries the country back: the redirect looks like `...?code=GB%2F12abc...&country=GB&state=random_string`. Note that the **authorization code itself is prefixed with the region and URL-encoded** — a client that fails to decode `%2F` back to `/` before the exchange sends a code Sage does not recognise. Region differences do not stop at auth. Sage documents **endpoint and attribute availability varying by region and by variant** — for example, corrective-invoice endpoints marked "Spain only" in the v3.1 reference, and inventory requiring Accounting Plus. A connector tested against a UK Accounting Plus business can legitimately 404 or return a different shape against an Irish Accounting Start business. Check the per-endpoint region/variant notes in the API reference before promising coverage. ## 8. `X-Business`: one token, many businesses, and a default that will bite you An access token belongs to a **user**, and a user may have access to many businesses. Sage's model: ``` GET /businesses # every business this user can reach GET /businesses/lead # the "lead" business — the first one they created GET /businesses/{id} # one business, including its subscription variant ``` Every other request may carry a header naming the business it applies to: ``` X-Business: ``` The rules, straight from Sage: - **If you omit `X-Business`, the API reads and writes the lead business.** Not an error. Not a warning. The wrong business, silently. - **The lead business can change.** Sage names two ways: the user deletes their first-created business, or a user who was previously only invited to businesses creates one of their own. Both re-point a connector that relies on the default. - **You cannot tell from the authorization response whether the user has more than one business.** You must call the businesses endpoint after connecting. Sage says this explicitly, twice. - **Every response except `/businesses` and `/user` returns an `X-Business` response header** naming the business the request actually resolved against. Store it on the first call and compare on later ones — that is Sage's documented way to detect a lead-business change. - **Sage's stated best practice is to always send `X-Business` in production.** The lead-business default is described as a development convenience. - **Partner Edition makes this acute.** When an accountant or bookkeeper on Partner Edition authorizes, the API can reach **every** business under their Active tab — including demo businesses — and the **Partner Edition business is returned as the lead**. Without a business picker, an accountant connecting your app syncs their own practice file instead of their client's. Sage calls providing that selection "imperative". - **Business ids are your join key.** Sage's best-practice guide is blunt that any local cache must key on business id, so two businesses cannot be merged into one local representation. - Since **July 2025** the businesses endpoint supports `active_only`, `name` and `product_family` (`sbc_accounting` / `sbc_payroll`) filters plus pagination. Pagination has sharp edges: `items_per_page` below 25 is forced to 25; `page` without `items_per_page` uses the maximum of 500; and `page` greater than 1 with fewer than 500 results returns an empty array. - Check `subscriptions[].active` is `true` before completing a connection, and read the subscription id to learn the variant: `START`, `MICRO` (Partner Edition), `ACCOUNTS` (pre-May 2020 Standard), `ACCOUNTING` (post-May 2020 Standard), `ACCOUNTING_10` (Plus). Endpoint availability differs between them. ## 9. Token lifetimes: five minutes, 31 days, single use | Token | Lifetime | Rule | | --- | --- | --- | | authorization `code` | **60 seconds**, single use | Exchange it immediately. Reuse or delay gives `invalid_grant` | | `access_token` | **300 seconds / 5 minutes** (`expires_in: 300`) | Cannot be revoked. Reserve **2048 bytes** to store it | | `refresh_token` | **2,678,400 seconds / 31 days** (`refresh_token_expires_in`) | **Single use.** Every refresh returns a new one. Reserve **2048 bytes** | The token response also carries `requested_by_id` — the id of the user who authorized — and `scope`, the granted access level (§5). Four ways this kills an integration: 1. **Rotation.** The refresh token is consumed by the refresh that uses it. Persist the new one in the same transaction as the new access token. Sage lists four separate causes for a rejected refresh token: it expired, the user revoked access, **it has been used before**, or **it is not the most recent token the auth service issued**. 2. **Concurrency.** Because rotation is strict, two parallel requests that both notice an expired access token will both refresh, and one of them poisons the other. Sage devotes a section of its troubleshooting guide to this and tells you to serialize refreshes behind a single-flight lock. With a **five-minute** access token this is not a rare race; it is the normal case for any worker pool. 3. **The 31-day wall.** The window resets on every refresh, so an actively-synced connection lives indefinitely — but a connection nobody touches for 31 days is gone, and the customer must re-authorize. Compute and store `now + refresh_token_expires_in` at every refresh so you can warn before it lapses rather than after. 4. **There is no access-token revocation.** Revoking kills the refresh token; the outstanding access token keeps working until its five minutes are up. Plan disconnect flows accordingly. ## 10. Rate limits Set **per app**, and — per Sage's own FAQ — **against the user and the app**, so a user with two businesses splits one budget across both: - **1,296,000 requests per app per day** (which is 15/second sustained). - **150 concurrent requests** at any time, per app. - Exceeding either returns **HTTP 429**. Sage recommends wait-and-repeat handling and, more usefully, **queueing requests** so your app controls its own rate rather than discovering the ceiling. The daily number is generous; the **concurrency cap is the one a parallel backfill hits first**. ## 11. Validation, the Marketplace, and what is actually gated Sage separates "can other organizations connect to my app" from "is my app listed on the Sage Marketplace", and only the second has a documented process. - **No enrolment is required before developing.** Sage's regional guide states this plainly, and contrasts it with the South African v2.0.0 product, which *does* gate production API keys behind a process. - **Marketplace listing runs through a validation call.** Sage invites you to a recorded online meeting (usually Teams) to demo the integration. The review covers: does the integration perform as advertised; is Sage branding used correctly; what data is read from and written to Accounting; how that data is secured in application and database; where the data is hosted; and the customer experience. - **Terms are accepted per region**, and a designated company admin is then invited to the Marketplace platform, with an offered 30-minute onboarding call. The listing carries privacy policy and T&Cs links, logos, target industries and categories, benefits, features and screenshots; editions and pricing are optional. - **A co-branded landing page on your own site is required** for the listing to point at. - Sage product logos are supplied as a partner pack and their use is bound by Sage's co-marketing guidelines, which you must accept before download. None of this is a step you can perform. Demo scheduling, data-hosting and security answers, legal terms and marketing copy are the platform owner's, not yours. Surface the requirement and hand back (see **Stop and ask**). ## 12. Verify end-to-end Authorizing one UK trial business proves almost nothing about a production Irish accountant with nine clients. Work the list. 1. Run the full authorize → callback → exchange against a trial business, **with `country` pinned** (§7). 2. Confirm the callback carried `code`, `country` and your `state`, that `state` matches, and that your client **URL-decoded** the region-prefixed code before exchanging (§7). 3. Confirm the token response carried `access_token`, `refresh_token`, `expires_in` 300, `refresh_token_expires_in` 2678400 and the `scope` you expected (§9). 4. Call the businesses endpoint. Confirm your platform lists them and lets a human choose, and that **every** subsequent call sends `X-Business` (§8). 5. Read back the `X-Business` **response** header on a data call and confirm it equals the business you selected (§8). 6. **Force a refresh, then force a second refresh using the token the first one returned.** This is the step that catches a client ignoring rotation. Nothing else catches it before production does. 7. **Fire two refreshes concurrently** and confirm only one reaches Sage. With a five-minute access token, this will happen in production on day one (§9). 8. If customers may be accountants, authorize once from a **Partner Edition** login and confirm you do not silently sync the practice file (§8). | Symptom | Cause | | --- | --- | | Flow fails before any login screen | Callback URL not registered, or registered with different casing (§4) | | Sage rejects the login with the developer's own credentials | App Registry account used instead of an Accounting business user (§2) | | `invalid_scope` on the authorize URL | `scope` is not exactly `readonly` or `full_access` — check for a concatenated list (§5) | | `unsupported_response_type` | `response_type` is not `code` (§6) | | `unauthorized_client` at authorize | Wrong or unknown `client_id` — check `/` encoding in the client ID (§6) | | `access_denied` | The user declined on the consent screen — not a bug | | Auth fails right after the country/region screen | Selected country ≠ the country the business is registered in (§7) | | `invalid_grant` immediately after consent | Code expired (**60 seconds**), already used, or not URL-decoded (§7, §9) | | `invalid_client` at the exchange | Wrong client ID or secret, or credentials sent as a header instead of form fields (§6) | | Exchange fails with a valid-looking code | `redirect_uri` at exchange differs from the registered value, or has extra params appended (§4) | | Everything works, then 401s five minutes later | Access token expired; refresh not wired up (§9) | | First refresh works, second fails `invalid_grant` | Rotated refresh token not persisted (§9) | | Refreshes fail intermittently under load | Concurrent refreshes — the older token is already consumed (§9) | | Connection dies after a quiet month | 31-day refresh-token expiry; the customer must re-authorize (§9) | | Data is real but from the wrong company | `X-Business` omitted, so the lead business answered (§8) | | A connection that worked starts returning another business | The user's lead business changed (§8) | | An accountant connects and you sync their practice, not their client | Partner Edition lead business; no business picker (§8) | | An endpoint 404s for one customer only | Region or variant does not support it — check the reference's region/variant notes (§7) | | HTTP 429 | 1,296,000/day or 150 concurrent, per app, shared across the user's businesses (§10) | | All transactions your app creates show one person's initials | Single shared user token in a multi-user integration (§5) | | The consent screen does not reappear on reconnect | Expected — it shows only on first auth, after revocation, or after the user removes the app (Platform state) | ## 13. Product fact: how this platform's Sage Accounting connector is wired **As of 2026-09-20**, and worth confirming with the connector's owner before you register anything: - The connector carries **three country profiles — CA, UK and IE** — that share the API base `https://api.accounting.sage.com/v3.1` and the token endpoint `https://oauth.accounting.sage.com/token`, and differ only in the authorize URL's `country` value (`ca`, `gb`, `ie`), each pinned with `filter=apiv3.1`. **The country is chosen when the connection is created, not detected** — so a customer routed to the wrong profile hits §7's failure. There is no US, ZA, AU or Asia profile, matching what v3.1 supports. - The authorize request sends `client_id`, `redirect_uri`, `response_type`, `state` and `scope`, space-delimited. **PKCE is not used**, which Sage permits. - The token exchange is a form POST carrying `client_id`, `client_secret`, `redirect_uri`, `code` and `grant_type` **in the body**; refresh sends `client_id`, `client_secret`, `refresh_token` and `grant_type`. Both match Sage's documented shape (§6). - Scopes are mapped **per unified object**: every read maps to `readonly`, every write to `full_access`, and the identity/login leg asks for `readonly`. The connector is configured to drop a read scope when the matching write scope is also present. **Check this behaviour against §5 before trusting it**: Sage's two values are not a read/write pair of the same word, so a mixed read-and-write permission set can plausibly emit `scope=readonly full_access` — two values where Sage documents one, and `invalid_scope` is the documented response to an unrecognised value. Worth reproducing with a mixed permission set before a customer does. - **It is not configured to use this platform's shared OAuth credentials.** Each deployment needs its own registered Sage app — which is why this skill exists. - **No business-selection header is sent on any request.** Every object other than the organization listing therefore resolves against the authorizing user's **lead business** (§8). The organization object, by contrast, lists the businesses endpoint, so it returns **all** businesses the user can reach while every other object reads only one of them. For a customer with several businesses — and for any accountant on Partner Edition — that is a real mismatch: raise it with the connector's owner before promising multi-business behaviour, and model each business as its own connection in the meantime. - **The revocation endpoint is not wired up**, so disconnecting on this platform does not invalidate the refresh token on Sage's side; the customer clears it from Sage's Manage Apps and Connections page. - Listing is page-based and 1-indexed with an items-per-page parameter, and incremental sync uses Sage's `updated_or_created_since` filter. - Coverage spans accounting (accounts, contacts, invoices, bills, journals, transactions, tax rates, trial balance, organizations, bank-feed accounts), commerce (items, collections, inventory), payments and file storage. This is a snapshot of a codebase that changes. It is a starting point for the conversation, not a specification. ## 14. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a callback host, a scope decision, `X-Business` support, refresh-token persistence or single-flight refresh), keep it secret-free and say what the human must set out of band. - Close with: app name; the App Registry account that owns it; client ID; where the secret was delivered; the authorize, token and revoke endpoints and the API base; the callback URLs registered, exactly as typed; whether `readonly` or `full_access` was chosen and why; which countries are in scope and whether `country` is pinned; how a business is selected and whether `X-Business` is sent; the trial business used and whether its 12-month extension was requested; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the client does not persist rotated refresh tokens, or refreshes concurrently without a lock (report it — do not register and hope); the connector cannot send `X-Business` and the customer has more than one business, or is an accountant (§8); the customer's Sage product turns out to be Intacct, Sage 50/100/200/X3, Sage Active, or a South African / Australian / Asian regional Accounting product; the customer needs Ireland or Canada and no trial business can be created there (Platform state); `full_access` is broader than the customer's security review will accept and no narrower scope exists (§5); a Marketplace listing, the validation demo, co-branding, data-hosting or security answers, or regional terms are in scope (§11); someone proposes rotating a live client secret; or the portal does not match the **Platform state** section above. ## References Verified 2026-09-20. **`developer.sage.com` returns HTTP 403 to non-browser clients** (Cloudflare bot protection), so each documentation link below is given in its `.md` form, which Sage serves as "View as markdown" and which returns HTTP 200 and plain text. **Drop the trailing `.md` to open the same page in a browser.** Every URL below was fetched and returned HTTP 200 on that date. - Authentication — authorize/token/revoke, parameters, scopes, token lifetimes, errors — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/authenticating/authentication.md - High-level auth flow — identity hand-off, MFA, consent, region routing — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/authenticating/high-level-auth-flow.md - OAuth troubleshooting — callback casing, country mismatch, refresh-token causes, concurrency — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/authenticating/oauth-troubleshooting.md - Sign up for a developer account — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/getting-started/developer_signup.md - Create your first app — fields, callback URL rules, client ID/secret, app image — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/getting-started/client_app_registration.md - Quick start — https://developer.sage.com/accounting/docs/v1.0.0/guides/quick-start.md - Getting started — the two accounts, trial businesses by region, variants, setup mistakes — https://developer.sage.com/accounting/docs/v1.0.0/guides/quick-start/getting-started.md - Extend your trial to 12 months — https://developer.sage.com/accounting/docs/v1.0.0/guides/quick-start/extend-your-sage-business-cloud-accounting-trial.md - Multi-business and business GUIDs — `X-Business`, lead business, businesses filters and pagination — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/key-concepts/multi-business.md - Best practices — `X-Business` in production, token storage, per-user tokens, variants — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/key-concepts/best-practices.md - API overview — products, versions, rate limits — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/key-concepts/overview.md - Differences between Accounting and Start — per-variant endpoint availability — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/key-concepts/differences-between-accounting-and-start.md - Partner Edition — accountant access and the lead-business problem — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/key-concepts/partner-edition.md - Regional considerations — v3.1 country coverage, and how South Africa differs — https://developer.sage.com/accounting/docs/v1.0.0/guides/learning/regional-considerations.md - Migrating from older API versions — https://developer.sage.com/accounting/docs/v1.0.0/guides/migrating/migrating-from-v3-to-v31.md - Partner journey — build, validate, list, succeed — https://developer.sage.com/accounting/docs/v1.0.0/guides/journey.md - Support — https://developer.sage.com/accounting/docs/v1.0.0/guides/support.md - Support checklist — what Sage asks for on a case — https://developer.sage.com/accounting/docs/v1.0.0/guides/support/support-checklist.md - App Registry (developer self-service) — https://developerselfservice.sageone.com/ - App Registry sign-in — https://developerselfservice.sageone.com/session/new - Sage Accounting sign-in (the business account that authorizes) — https://app.sbc.sage.com/ - Authorization endpoint — https://www.sageone.com/oauth2/auth/central - Sage service status — https://status.sage.com/ ## sage-intacct-oauth-app URL: https://docs.unified.to/skills/sage-intacct-oauth-app/SKILL.md # Sage Intacct OAuth 2.0 App Registration **Boundary:** this skill covers **Sage Intacct** and nothing else; **Sage Accounting** (Sage Business Cloud Accounting, API v3.1) is a different product with a different portal, a different auth model and its own skill, `sage-accounting-oauth-app`, which scopes Intacct out on purpose. They are not variants of one product — do not carry a fact from one page to the other. Get a working Sage Intacct REST API client — a Web Services license, a developer organisation, a registered application, redirect URIs, and a client ID and client secret — for a platform that connects other organizations' Intacct companies on behalf of many customers. Intacct is closer to NetSuite than to any self-serve OAuth vendor, and four things account for almost all of the lost time. **You cannot register anything until Sage has issued you a Web Services license.** The registration form's very first required field is your **sender ID**, and the next one is your **sender password**. That license is sold, not self-served: Sage's own words are that it "is available exclusively to Sage Intacct customers and partners", obtained from "your Sage Intacct account manager or partner representative". There is no signup button that produces one. If the user has not got one yet, the calendar, not the console, is the blocker. That is §2 and §3. **Credentials come in two layers, and only one of them is yours.** The sender ID, sender password, client ID and client secret are **partner-level** — one set, issued to you, reused for every customer. What each *customer* supplies is consent, plus three pieces of configuration inside their own company: the **Web Services subscription**, an **authorization for your sender ID**, and a **user whose role can see the objects you sync**. None of it is visible from your side, and all of it fails after a successful authorization. That split is the whole onboarding story, and it is §2, §8 and §9. **Yes, there is a real redirect-based OAuth 2.0 flow, and it is current.** Intacct's historical model — sender credentials plus company credentials over the XML gateway — still exists and still works, but the REST API takes an ordinary authorization-code grant at `https://api.intacct.com/ia/api/v1/oauth2/authorize`, with PKCE optional, and Sage states that "new objects and features are released using the REST API". Register redirect URIs; they are used. That is §5 and §14. **The scope vocabulary is one word.** Intacct's `scope` parameter takes `offline_access`, and its only effect is "I want a refresh token". There is no per-object scope, no read/write split, and no consent screen that enumerates permissions. Everything the token can do is what the *authorizing user's role* can do. That is §6 and §9, and it moves the access conversation from your registration form into the customer's user administration. ## Which Sage product this is | This skill covers | This skill does **not** cover | | --- | --- | | **Sage Intacct**, REST API **v1**, host `https://api.intacct.com`, OAuth 2.0 authorization code (and client credentials), registered through the Sage developer console at `developer.sage.com/intacct` | **Sage Accounting** / Sage Business Cloud Accounting — different portal (App Registry), different API (v3.1), different scopes, different token lifetimes. Use `sage-accounting-oauth-app` | | The legacy **XML gateway** (`/ia/xml/xmlgw.phtml`), which shares the sender-ID model and is still supported (§14) | **Sage 50**, **Sage 100**, **Sage 200**, **Sage X3**, **Sage Active**, **Sage Payroll** — separate products and portals | | Intacct **Platform Services** triggers and outbound webhooks, insofar as they need your client ID (§13) | **Sage Construction Management**, which publishes its own API under a different host | If the user says "Sage" and the product turns out not to be Intacct, stop and switch skills before registering anything. An Intacct application cannot serve a Sage Accounting business and vice versa. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Do you already have a Web Services license?** | Sender ID **and** sender password. Blocking — registration cannot start without it (§2) | | **Which sender ID this application will bind to** | One sender ID per developer organisation. A second sender ID means a second organisation (§3) | | **Application name** | Alphanumerics plus `.()@!?&_-` only. Appears in Sage's dashboards and reporting (§4) | | **Contact email address** | Sage sends the approval notification here; the client ID and secret follow approval (§4, §7) | | **Redirect URIs** | Every callback host your platform serves. HTTPS only, comma-separated (§5) | | **Webhook receiver URLs (Async URIs)** | Registered in the same field as redirect URIs. Skip only if you are certain you will never take webhooks (§5, §13) | | **Allowed origin domains** | Only if the browser will call the API directly with these credentials. A server-side connector usually has none (§5) | | **Production or Non-production?** | **Cannot be changed after the application is created.** Get this right (§4) | | **Terms and conditions URL** | Optional on the form (§4) | | **A test Intacct company you can actually authorize against** | Ideally one you do not own, to prove the per-customer setup in §8 | | **Will customers be multi-entity?** | Decides whether you need an entity picker or will silently read the wrong books (§10) | | **New application, or an edit to an existing one?** | A new client ID orphans every existing customer connection (§1) | ## Quick Start 1. Confirm this is **Sage Intacct**, not Sage Accounting (table above). 2. Confirm a **Web Services license** exists — sender ID and sender password in hand (§2). 3. Confirm a **new application** is actually needed; existing connections are bound to the current client ID (§1). 4. Sign in to the console, create the **developer organisation**, and **enrol** in the Sage Intacct Developer Programme using the sender ID as the licence key (§3). 5. Create the application, choosing **Production** or **Non-production** deliberately — it is permanent (§4). 6. Paste **every** redirect URI and every webhook receiver URL, comma-separated, HTTPS only (§5). 7. Add the **sender password** to the application; the sender ID is prefilled (§4). 8. Wait for Sage's approval email, then capture the client ID and client secret (§7). 9. Write down the per-customer checklist — subscription, sender-ID authorization, user and role — and put it in your onboarding docs, because you cannot do any of it (§8, §9). 10. Decide how a customer picks an **entity** in a multi-entity company (§10). 11. Verify a real authorize → callback → token → refresh round trip against a company you do not own (§14). 12. Hand the credentials over — never commit them (§16). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **`developer.sage.com` sits behind Cloudflare bot protection and returns HTTP 403 to every non-browser client.** Each documentation page publishes a plain-Markdown twin at the same path plus `.md`, and those return HTTP 200 and are machine-readable. Every `developer.sage.com` link in **References** is given in that form. **Drop the trailing `.md` to open the same page in a browser.** - **The Intacct documentation tree moved.** Live paths are `developer.sage.com/intacct/docs/1/sage-intacct-rest-api/...`. The older `developer.sage.com/intacct/docs/developer-portal/...` paths no longer resolve to those pages. Any runbook, code comment or bookmark still pointing at `developer-portal` is stale, and its content may be too. - **The developer "workspace" has been renamed "organisation".** Sage's quick start says so explicitly. The console is reached from `developer.sage.com/intacct` via **View Console**. - **Registration is gated twice.** First you enrol in the **Sage Intacct Developer Programme** with your sender ID as the *Intacct Web Services License Key*; only then can you create an application. After you submit, you "await confirmation and retrieve your API keys", and Sage emails the address on the form. It is not instant self-service. - **Client scope is permanent.** Production or Non-production is chosen at creation and "cannot change the client scope after it's created". A web services ID "might be associated with up to five production API keys". - **There are hard object limits per account**: organisations per user 10, users per organisation 10, applications per organisation 10, credentials per organisation 20 — and **only one sender ID per organisation**. Multiple sender IDs require multiple organisations. - **API v1 is current.** Version is explicit in the path (`/ia/api/v1/...`). Sage deprecated **1-beta2 on 10 December 2025**. New major versions ship only with quarterly releases, and a previous version is supported for **two years** after its successor appears. - **The XML API is not dead, but it is not where new work goes.** Sage: "Intacct continues to support the XML API, but new objects and features are released using the REST API." Build new integrations on REST (§14). - **As of the 2026 R1 release, an invalid authentication token returns `401 Unauthorized`**, where previous releases returned `400 Bad Request`. Error handling written against the old behaviour is now wrong. - **PKCE is supported and optional.** Sage recommends it "for mobile apps and public clients where storing the client secret is unsafe" and documents `S256` with a 43–128 character verifier. Nothing says it is becoming mandatory. - **Sage's own samples disagree about access-token lifetime** — `expires_in` appears as `43200` in the authorization-code samples and `28800` in the PKCE sample, and the token-introspection sample's `exp` minus `iat` is 10800 seconds. Read `expires_in` from the response; do not hardcode any of them (§11). - **Sage documents no refresh-token lifetime at all.** There is no published expiry, no rotation rule and no statement that a refresh token is single-use. Treat the reconnect path as untested until you measure it (§11). If the console does not look like this, stop and report what you actually see rather than clicking on. ## 1. Reuse the existing application, or register a new one A new application means a **new client ID, and every existing customer connection is bound to the old one** — every customer re-authorizes, and each re-authorization is an action inside their Intacct company that you cannot perform for them. Reuse the existing application for: adding a redirect URI, adding a webhook receiver, regenerating a compromised secret (Sage: "You can generate a new key at the app registry whenever you need one"), or diagnosing a failure. Register a **new** application only when the user explicitly wants one: a replacement for a compromised application, a genuinely separate product — Sage's rule is that "each product or integration connected to Sage Intacct must be registered as a separate application" — or the Non-production twin of a Production application, which *must* be separate because client scope is immutable (§4). Say which path you are taking before you touch the console. ## 2. The crux: which credentials are partner-level and which are per-customer This is the section to read before promising anyone an onboarding flow. | Credential | Level | Who issues it | Where it lives | | --- | --- | --- | --- | | **Sender ID** | **Partner-level.** One, reused for every customer | Sage, with a Web Services developer license | Bound to your developer organisation and prefilled on your application | | **Sender password** | **Partner-level** | Sage, alongside the sender ID | Entered once into your application; changing it needs a support case | | **Client ID** | **Partner-level.** Public identifier, "does not change" | Sage, on approval of your application | Your config; sent in every authorize and token request | | **Client secret** | **Partner-level.** Regenerable at the registry | Sage, on approval | Your secret store only | | **Web Services subscription** | **Per-customer** | The customer's admin enables it | *Company > Admin > Subscriptions* in their company | | **Sender-ID authorization** | **Per-customer** | The customer's admin adds *your* sender ID to *their* allow-list | Company Configuration → Security → Web Services authorizations | | **The authorizing user** | **Per-customer** | The customer | Their company; the token inherits that user's permissions | | **Company ID, user ID, entity** | **Per-customer** | The customer, at the Intacct sign-in screen | Baked into the issued token (§10) | Two consequences that shape everything else: - **You never collect a sender ID from a customer.** Sage is explicit that Web Services credentials "are not necessarily tied to a particular Sage Intacct company/user", and that "a Marketplace Partner can use a sender ID for any Sage Intacct company that has authorized that sender ID for Web Services". One sender ID, many companies. This is the opposite of the per-tenant credential collection some Intacct runbooks describe. - **You cannot complete onboarding alone.** Consent is necessary and not sufficient. Every item in the per-customer half of the table is done by an administrator inside the customer's company, before or during the connect flow, and none of it reports back to you. §8 is the checklist to put in your onboarding documentation. There is one shortcut worth knowing, straight from Sage's OAuth page: > "The company to which you will be sending API requests must authorize your sender ID. You can either add your web > sender ID to the Company Security tab or **log in as an admin user when approving the OAuth authorization request**." So an admin who authorizes can implicitly satisfy the sender-ID authorization. A non-admin user authorizing a company that has not allow-listed your sender ID cannot. Design the connect flow to ask for an admin the first time. ## 3. Accounts: the licence, the organisation, and enrolment Three things, in this order. None of them is skippable. **The Web Services license.** Sender ID plus sender password. Sage's quick start: "To register a new application and configure your API integration as described in this quick start, you must have a Sage Intacct Web Services license. This license consists of a sender ID and sender password and is available exclusively to Sage Intacct customers and partners. If you do not have a web services license, contact your Sage Intacct account manager or partner representative to obtain one." Sage's REST FAQ adds that "to access the Sage Intacct REST API, you must have a web services developer subscription", and points at the account manager for cost. Partners go through the Sage Intacct Marketplace partner programme. **Sender IDs are case sensitive and cannot be changed after creation**, and the password can only be changed by opening a support case. **The developer organisation.** "A container that houses REST API client application credentials", formerly called a workspace. It holds your applications, generates API keys and reports usage by customer and application, and you can invite colleagues into it. Remember: **one sender ID per organisation** (§Platform state). **Enrolment.** From Sage's quick start, verbatim in shape: 1. Go to `https://developer.sage.com/intacct/`. 2. Select **View Console** and sign in with your Sage account email and password. 3. **Applications > New Application**. 4. For **Select an API**, select **Sage Intacct**, then **Continue**. 5. Under **Sage Intacct Developer Programme**, select **Enrol**. 6. For the **Intacct Web Services License Key**, use your **Sage Intacct Sender ID**. 7. Now that you have enrolled, select **Applications > New Application** again. 8. **Select an API** → **Sage Intacct** → **Continue**. 9. Complete the application form (§4). Hand control back to the user for anything only a human can do: obtaining the licence, the Sage account sign-in and any MFA, accepting terms, and the wait for Sage's approval email. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user the ordered click path above with the literal values to paste — the redirect URIs from §5 — warn them that the client scope in §4 is permanent, and continue once they report back with the client ID. ## 4. Create the application The form, field by field. | Field | Required | Notes | | --- | --- | --- | | **Application name** | yes | "Choose a clear, descriptive name that reflects the API use case. This name will appear in dashboards and reporting." Allowed characters: alphanumerics and `.()@!?&_-` | | **Terms and conditions URL** | no | Sage says you can use `https://www.sage.com` if needed | | **Redirect URIs** | yes | Full URLs, comma-separated; also the home of webhook Async URIs (§5) | | **Origin Domains** | no | Full `https://` origins allowed to make browser-based (CORS) API calls with these credentials. A server-side connector normally leaves this empty | | **Intacct Web Services License Key** | prefilled | Your sender ID, carried over from enrolment | | **Intacct Web Services License Password** | yes | Your sender password | | **Client Scope** | yes | **Production** or **Non-production**. Permanent (below) | | **Enable Try it** | no | Non-production only; lets you fire requests from Sage's API reference pages | Things that cost a cycle if you get them wrong: - **Client scope cannot be changed after creation.** Production is "for live, production companies"; Non-production is "for sandbox or other test environments". If you need both, that is two applications — and, given the limit of ten applications and twenty credentials per organisation, budget for it up front. - **A web services ID "might be associated with up to five production API keys."** That is the ceiling on production credential pairs behind one sender ID. Plan rotations and per-environment splits inside it. - **Approval is asynchronous.** After **Create Application** you "await confirmation and retrieve your API keys", and the notification goes to the email on the form. Do not promise same-hour credentials. - The **Usage** view in the organisation reports API consumption by application and by company, but metrics can lag by 10 minutes after first binding the licence key, refresh every 24–36 hours, go back at most twelve months, and Sage warns "do not use API metrics to determine billable API totals". ## 5. Redirect URIs (and the webhook URIs that share the field) Add **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Rules, all from Sage: - **Full URLs, entered as a comma-separated list.** This is not a one-per-line field. - **"Only secure website URLs such as `https://` or mobile URI schemes are supported."** Sage additionally states that it requires HTTPS "for the redirect URIs used in the OAuth authorization flow". - **The value must match exactly.** Sage: the redirect URI "must exactly match what you registered in your application settings". The same value must be sent at the authorize step *and* at the token exchange — both requests take a `redirect_uri` parameter. - **Webhook receivers go in this same field.** Sage: "Add webhook allowable Async URIs for any endpoints that will receive the webhook notifications as well." If you intend to take outbound webhooks later (§13), register those hosts now rather than editing under pressure. - For **local testing**, Sage's documented approach is a self-signed certificate plus a custom domain mapped to `localhost`, with that custom domain added to the application's callback URI list. ## 6. Scopes: one value, and it is not about permissions Intacct's `scope` parameter takes exactly one documented value: | Scope | Meaning | | --- | --- | | `offline_access` | "Use `offline_access` if you want to get a refresh token in addition to an access token" | That is the entire vocabulary. Consequences worth saying out loud: - **There is no per-object scope and no read/write split.** Do not build a permission-to-scope mapping; there is nothing to map onto. Asking for `offline_access` on a read-only integration is correct and normal — it buys a refresh token, not write access. - **Access is decided by the authorizing user's role.** Sage: "your application will have the authorization of a specific user for each session, and thus inherit the same permissions as that user." That is §9, and it is where a customer's security review should be pointed. - **Omitting `scope` is legal and means "no refresh token".** A connector that forgets it gets a short-lived access token and no way to renew — which looks like a token-lifetime bug and is actually a missing parameter. ## 7. Capture the credentials and endpoints After Sage's approval email, open the application to read the client ID and client secret. - **The client ID "does not change"** and is safe to log and to show. Its published shape is a hex string plus a Sage suffix, for example `.INTACCT.app.sage.com` or `.app.sage.com`. It contains dots; it is not a UUID. - **The client secret can be regenerated** — "You can generate a new key at the app registry whenever you need one" — which is not the same as being re-readable. Treat regeneration as breaking: every exchange and refresh fails until the new value is deployed. - Sage's own handling rules for the secret: do not store it on a mobile device, do not expose it in client-side JavaScript or HTML, do not put it in externally viewable server files, **and do not record it in log files or error messages**. Endpoints — one host, no per-customer hostname, which is a real simplification versus other enterprise ERPs: | Purpose | URL | | --- | --- | | Authorize | `https://api.intacct.com/ia/api/v1/oauth2/authorize` | | Token (exchange and refresh) | `https://api.intacct.com/ia/api/v1/oauth2/token` | | Revoke | `https://api.intacct.com/ia/api/v1/oauth2/revoke` | | Introspect (RFC 7662) | `https://api.intacct.com/ia/api/v1/oauth2/introspect` | | REST base | `https://api.intacct.com/ia/api/v1` | | Legacy XML gateway | `https://api.intacct.com/ia/xml/xmlgw.phtml` | Request mechanics, as Sage documents them: - **Authorize** (GET): `response_type=code`, `client_id`, `redirect_uri`, `state`, `scope=offline_access`. The callback carries `code` and `state`. - **Token exchange** (POST, `application/x-www-form-urlencoded`): `grant_type=authorization_code`, `code`, `redirect_uri`, `client_id`, `client_secret`. Sage's samples put the **client credentials in the body**. Its security page notes the client ID may be sent "in the header or in the payload", and the **revoke** endpoint explicitly accepts either body fields or HTTP Basic — so both styles exist, but the body form is what the token samples show. - **Refresh** (POST, form-encoded): `grant_type=refresh_token`, `refresh_token`, `client_id`, `client_secret`. The documented response includes a **new `refresh_token`** alongside the access token. - **PKCE**, when used: `code_challenge` plus `code_challenge_method=S256` on the authorize request, and `code_verifier` (43–128 characters) at the exchange. - **`state` is your CSRF defence.** Sage asks for a value that is "unique, difficult to guess, and does not contain private or sensitive information", and tells you to reject a mismatch. - **Tokens are JWTs.** Sage says so, and the introspection response shows the payload carries the client ID, the user ID, the company ID, the issuer, `exp`, `iat`, and — under Basic authentication — the company name and key, entity ID and key, user key, session ID and an AI-enabled flag. Useful for diagnostics; **do not** treat a self-decoded JWT as validation. - **Bearer, spelled that way.** `GW-0034 Invalid Authorization Header` is "issued when the header value does not start with `Bearer`". Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. ## 8. What each customer must do — the part you cannot do Put this in your onboarding documentation verbatim. Every item is an administrator action inside the customer's own Intacct company, and every one of them fails *after* a successful OAuth authorization. 1. **Enable the Web Services subscription.** *Company > Admin > Subscriptions*. Sage lists this first among what "the administrator for the target company must" do. Without it, Web Services requests do not work at all — and the error you see is a generic gateway rejection, not "please enable the subscription" (§15). 2. **Authorize your sender ID.** Company Configuration → **Security** tab → **Web Services authorizations** → **Add** → enter the sender ID, optional description, status **Active** → **Save**. Sage's help is blunt: "If a sender ID is not on this list, any Web Services requests they make to your company will fail." **The sender ID is case sensitive** and must be entered exactly. The alternative, per Sage's OAuth page, is that an **admin user performs the OAuth authorization**, which authorizes the sender implicitly (§2). 3. **Provide a user to authorize with, and a role that can see your objects.** Sage recommends a dedicated **Web Services user** (*Company > Admin > Web Services Users*) — these "exchange information programmatically with Sage Intacct via Web Services API calls — they are not allowed to log in to the UI", their passwords do not auto-expire, and SSO and MFA are disabled for them. Note the tension: a Web Services user **cannot** complete the interactive authorization-code flow, because that flow requires a UI sign-in. So either a UI-capable user authorizes (authorization code, §7), or you use the **client credentials** grant with a Web Services user (below). 4. **Grant the role the permissions your integration needs.** Sage's stated best practice is to "create Sage Intacct users that only have access to the Sage Intacct application areas and data required by your application". That is exactly right and exactly the thing that later produces a 403 on one object and not another (§9). ### If you use the client credentials grant instead For unattended access with no user present, Sage documents a second per-customer setup: 1. An admin creates a **Web Services user** (*Company > Admin > Web Services Users > Add*) with the necessary permissions or role. 2. An admin adds your application under *Company > Setup > Company > [Edit] > Security > **Authorized Client Applications** > Add*, entering your **Client ID** and the **Web Services User ID**. Sage warns: "This field is case-sensitive, so ensure you enter it exactly as it was created." 3. You then POST `grant_type=client_credentials` with `client_id`, `client_secret`, and either `username` or `session_id`. The username format is **`userId@companyId`** for top-level access, or **`userId@companyId|entityId`** for entity-level access. This is *more* per-customer work than the authorization-code flow, not less — every customer configures an allow-list entry keyed to your client ID. Use it when there is genuinely no user to consent, not to avoid the consent screen. ## 9. Roles and permissions: why a valid token still fails Nothing about permissions is visible at registration time, and none of it is a scope. - **The token inherits the authorizing user's permissions**, per session. A user who cannot see Accounts Payable in the UI cannot read bills through your token, and no amount of re-authorizing changes it. - **The failure looks like a bad request.** Sage's own status-code table defines **403 Forbidden** as "The authenticated user does not have sufficient permission to run the operation or to access the target resource", and **422 UnprocessableEntity** as "Business logic or business validation error" where "the payload conforms to the schema, but the values are not valid". A permission gap on one object, on an otherwise healthy connection, reads like your request is malformed. It usually is not. - **Reads that work for one object and not another are the signature.** That is per-module permission, not scope, not token, not sender ID. - **The remedy is a named role.** Ask the customer for a role you can state requirements against, and list the modules your integration touches. One sentence in an onboarding doc removes most Intacct support tickets. Sage exposes roles, permissions and permission assignments as REST objects under company configuration, so a support flow can read back what the authorizing user actually has. ## 10. Company, user and entity: three ways to read the wrong books The token is bound to a company and a user, and — in multi-entity companies — to an entity context. Get any of them wrong and you get **data from the wrong place rather than an error**. **Company ID and user ID** are supplied by the customer at the Intacct sign-in screen during authorization, and land inside the issued JWT (`cny_id`, `user_id` in the introspection response). You do not ask for them and cannot change them after the fact. If a customer authorizes with the wrong company — easy, when a group has several — the connection is silently wrong, and nothing in your callback says so. **Introspect the token after connecting and show the company back to the human**; that is what the endpoint is for. **Entity context** is the sharp one. Sage: "Multi-entity companies have a top-level parent entity with one or more sub-entities underneath. These sub-entities may own their records." There are three documented ways to choose: | Mechanism | How | Effect | | --- | --- | --- | | Default | Send nothing | The request runs against **the default entity for the access token** — top level, for a top-level token | | Per request | `X-IA-API-Param-Entity: ` header, e.g. `CentralUS-35` | That one request runs in that sub-entity | | Per token | Pass the sub-entity id at the **refresh** request | Returns a sub-entity access token to use for subsequent calls | Three traps: - **The header takes the entity's `id`, not its key or your own identifier.** Sage's example is `CentralUS-35`, the `company-config/entity.id` property. Passing anything else is a silent mis-read at best. - **Sage's own refresh documentation is internally inconsistent about the parameter name.** The refresh parameter table names `entity_id` ("The ID of the entity that you want the user to have access to. Default is a top-level access token"), while the worked example a few paragraphs later sends `location_id=CentralUS-35`. Test which one your environment honours before shipping; do not assume from either. - **Mixed defaults are worse than a wrong default.** If your list calls run at top level and your single-record reads or writes fall back to some sub-entity, the same connection reads one set of books and writes another. Pick one rule and apply it everywhere. Enumerate a customer's entities from the company-configuration entity list after connecting, let a human choose, and store the choice. Do not infer it. ## 11. Token lifetimes, sessions, and revocation blast radius | Token | What Sage documents | | --- | --- | | authorization `code` | No published lifetime. Exchange it immediately | | `access_token` | JWT. `expires_in` in the response — Sage's samples show **43200** (12 h), **28800** (8 h) and an introspection sample implying **10800** (3 h). **Read the field** | | `refresh_token` | **No documented lifetime, no documented rotation rule, no statement that it is single use.** The refresh response does return a new `refresh_token` | Four things follow: 1. **Never hardcode an access-token lifetime.** The three numbers in Sage's own documentation are mutually exclusive; at least two of them are stale or environment-specific. Trust `expires_in` and nothing else. 2. **Store whatever refresh token comes back**, and fall back to the existing one if a response omits it. That is correct whether or not Intacct rotates, and costs nothing. 3. **An access token can die before `expires_in`, because it rides an Intacct session.** The introspection response carries a `session_id`, and Sage lists "**The session no longer exists**" among the reasons a token introspects as inactive. Intacct session duration is a company/user setting — "The session timeout is calculated based on the session duration specified for the user or company plus the current time" — so a customer's own security policy can cut your token short. Handle 401 by refreshing, not by alerting. 4. **Revocation is not scoped to your token.** Sage, verbatim: "You may request multiple tokens for a web services user/company, and use them simultaneously to interact with the REST API. However, **when one of these tokens is revoked, the other tokens for that user/company are revoked too** and can no longer be used." If a customer runs two integrations through the same Web Services user, your disconnect kills theirs. Decide deliberately whether your "disconnect" calls revoke or merely forgets the row, and say which in your UI. `introspect` is the cheap health check: it reports `active`, the company, the user, `exp` and `iat`. Note that **refresh tokens always introspect as `{"active": false}`** — that is documented behaviour, not a dead connection. And a **503 Service Unavailable** from introspect means an infrastructure problem, deliberately distinguished from an invalid token. ## 12. Rate limits, concurrency and volume Intacct meters **the customer's company**, on a purchased performance tier — closer to NetSuite's model than to a per-app quota. | Performance tier | API transactions / month | API concurrency (app/company) | Offline process concurrency | | --- | --- | --- | --- | | Tier 1 | 100,000 | 6/8 | 1 | | Tier 2 | 250,000 | 8/10 | 2 | | Tier 2-500k | 500,000 | 8/10 | 2 | | Tier 3 | 1,000,000 | 12/15 | 3 | | Tier 4 | 2,500,000 | 15/20 | 4 | | Tier 5 (custom) | >2,500,000 | custom | custom | What the numbers mean, per Sage: - **`application/company`**: "`6/8` indicates that any single application can use up to 6 API processes at a single point in time, while the company can use up to 8 concurrent API processes in total." Your parallelism ceiling is the first number, not the second. - **A transaction is a record, not a request.** "A request can include several records. Each record equals a transaction for create, update, and delete operations. A query transaction can return many records; each query counts as one transaction." Queries are cheap; bulk writes are not. - **Exceeding the monthly entitlement is billed, not blocked.** "Sage Intacct does not block API transactions that exceed the API transaction entitlement" — customers pay an overage fee. A runaway sync costs your customer money rather than erroring, which is worse. - **Marketplace-partner traffic is exempt from the customer's entitlement.** "API transactions from authorized Marketplace Partners, ISV solutions, and Sage Intacct's own applications do not count against the Performance Tier transaction entitlement purchased by a customer." A real commercial reason to pursue partner status. - **Throttling shows up as 429 and 503**, and Sage returns `X-IA-Throttle-Limit*`, `X-IA-Minute-Rate-Limit*`, `X-IA-Hour-Rate-Limit*`, `X-IA-CPU-Limit*` and `X-IA-TENANT-Throttle-Limit*` response headers, with `-Retry-After` values. Read them; do not guess a backoff. - **Payload ceilings**: POST/PATCH request body **1 MB**; batch request **500 operations**; query service **4,000 records** maximum per response, paged via `start` and the response's `next`. ## 13. Webhooks: your client ID, their trigger Intacct's outbound webhooks are not subscribed through the API. A **Platform Trigger** is configured *inside the customer's company*, set to HTTP post, with **Use webhook delivery** selected and **your client ID** entered in the field that then appears. Which means: - The receiver host must be among the **Async URIs** you registered on the application (§5). - Every customer configures their own trigger. There is no fan-out from your side. - Delivery retries up to **four attempts** total (10 s, 30 s, 90 s) on 408, 429, 500, 502, 503 and 504; any `2xx` is success. - Each request carries an **`Idempotency-Key`** (retain keys at least 24 hours), an `X-ClientContext` header, and `X-IA-Sage-Signature` — "Base64 encoded context information, including the client ID and the JWT token". Verify the signature; do not trust the body alone. If you cannot ask customers to build triggers, plan for polling and say so up front. ## 14. XML gateway versus REST — which is current Both exist. They share the sender-ID model and nothing else. | | REST API (current for new work) | XML gateway (legacy, supported) | | --- | --- | --- | | Endpoint | `https://api.intacct.com/ia/api/v1/...` | `https://api.intacct.com/ia/xml/xmlgw.phtml` | | Payload | JSON | XML only — "Sage Intacct currently does not support receiving JSON through Web Services" | | Method | Standard verbs | **POST only**; GET "blocked as of January 15, 2023" | | Version | `v1` in the path; 1-beta2 deprecated 10 Dec 2025 | `dtdversion` **3.0**; 2.1 "still supported, however it will likely not receive new product enhancements" | | Auth | OAuth 2.0 (authorization code, PKCE, client credentials) | Two layers in the request body: sender ID + password, then company credentials (company ID / user ID / password) **or** a session ID from `getAPISession` | | New features | Yes — "new objects and features are released using the REST API" | No | **Build new work on REST.** Keep the XML gateway in mind for two reasons only: an existing integration may still use it, and Sage publishes an **XML-to-REST object map** that is the fastest way to translate a customer's or a predecessor's XML object names into REST resources. If you do touch the XML path, Sage's guidance is to call `getAPISession` once and reuse the session rather than sending login credentials on every call, to **validate any session ID you receive** with `getAPISession` and to confirm returned endpoints are `*.intacct.com` domains, to keep queries under 1,000 records, and to keep record manipulation under 100 — the sender process times out at **15 minutes**. ## 15. Sandbox and non-production - **Sandbox is a customer-side environment**, not a developer freebie: "a copy of your production environment that includes all of your transactional data, configurations, and customizations", for "testing, training, and development purposes", with no impact on production. Sage documents Sandbox, Preview and Production environment types, and notes a sandbox "cannot be refreshed more than four times per subscription period" and that some features are unavailable in it. - **Your application's client scope must match.** A **Non-production** application is "for sandbox or other test environments" and can enable **Try it**, which fires live requests from Sage's own API reference pages after you sign in to a non-production company. A **Production** application is for live companies. Because the scope is permanent (§4), a full lifecycle means **two applications and two client-ID/secret pairs**. - There is no self-serve, Sage-hosted demo company documented in the developer portal the way other vendors offer trial tenants. If the user has no Intacct company to test against, that is a request to their account manager or partner rep — raise it on day one, not the day before the demo. ## 16. Verify end-to-end Authorizing your own company proves almost nothing about a customer's. Work the list. 1. Run the full **authorize → callback → exchange** against a test company, with `scope=offline_access`. 2. Confirm the callback carried `code` and your `state`, and that `state` matches. 3. Confirm the token response carried `access_token`, `refresh_token` and `expires_in` — and **record which `expires_in` you actually got** (§11). 4. **Introspect the access token** and confirm `cny_id` is the company the human believes they connected, and that `entity_id` is what you expect (§10). 5. Make one read call, then one read call for an object in a different module. Two modules, because that is where role permissions bite (§9). 6. **Force a refresh, then force a second refresh with whatever the first returned.** This catches both a client that ignores a rotated token and one that assumes rotation where there is none. 7. If customers may be multi-entity, list entities, pick a sub-entity, and confirm `X-IA-API-Param-Entity` changes what comes back — and that your *writes* land in the same entity as your reads (§10). 8. Authorize against a company **you do not own**, with a **non-admin** user, on a company that has your sender ID allow-listed. That is the only test that exercises §8 honestly. 9. Deliberately test the failure you will see most: authorize against a company that has **not** allow-listed your sender ID, and record the exact error your platform surfaces. 10. Decide and test what your **disconnect** does, given §11's revocation blast radius. | Symptom | Cause | | --- | --- | | Cannot get past the application form; the licence key field blocks you | No Web Services license — sender ID and password are a prerequisite, not an output (§2, §3) | | Enrolment rejects the licence key | Sender ID entered with wrong case, or belongs to another organisation — one sender ID per organisation (§3) | | `GW-0011 Invalid Request` | "a tenant not being available or not found, an inactive or invalid sender ID, an inactive or invalid client ID, or an inactive tenant" — the catch-all that a missing Web Services subscription or an un-allow-listed sender ID lands in (§8) | | `XL03000006 Invalid Web Services Authorization` on the XML path | "The sender ID '…' is not authorized to make Web Services requests to company ID '…'" — customer admin must add it (§8, §14) | | Flow fails before any Intacct sign-in screen | Redirect URI not registered, or not byte-identical to the one sent (§5) | | Exchange fails with a valid-looking code | `redirect_uri` at the exchange differs from the one used at authorize (§5, §7) | | `GW-0037 Missing Client ID` / `GW-0038 Multiple Client IDs` | `client_id` absent from, or duplicated in, the token request (§7) | | `GW-0032 Invalid Token Payload` | `grant_type` invalid, or the token request body is not attribute-value encoded (§7) | | `GW-0035 Missing Token Payload` | The token request has no body at all (§7) | | `GW-0033 Invalid Token Endpoint` | The `/oauth2` sub-path is not one of authorize / token / revoke / introspect (§7) | | `GW-0036 Missing User Name` / `GW-0012 Missing Tenant Data` | Client credentials grant with an empty `username`, or a `username` lacking the `@companyId` part (§8) | | `GW-0014 Invalid Content Type` | XML sent to a REST endpoint — wrong API (§14) | | `GW-0034 Invalid Authorization Header` | The header value does not start with `Bearer` (§7) | | `GW-0031 Invalid Token` | The JWT cannot be decoded or lacks required elements (§7) | | No refresh token in the token response | `scope=offline_access` was not sent (§6) | | 401 on a connection that worked minutes ago | Access token expired, or the underlying Intacct session ended per the company's session-duration policy — refresh (§11) | | Everything 401s after a colleague disconnected another integration | Revoking one token revokes every token for that user/company (§11) | | 403 on one object, fine on others | The authorizing user's role lacks that module's permission — not a scope, not a token (§9) | | 422 on a write that looks well-formed | Business-logic validation, often a permission or a required dimension — read `ia::error.details` (§9) | | Data is real but from the wrong entity | No `X-IA-API-Param-Entity`, or the wrong entity id, or reads and writes using different defaults (§10) | | Data is real but from the wrong company | The customer authorized the wrong company — introspect and show it back (§10) | | 429, or 503 under load | Concurrency or rate ceiling for the tier; read the `X-IA-*-Limit*` headers before retrying (§12) | | "There are too many operations running for this company" | Offline-process concurrency for the tier is exhausted (§12) | | Monthly bill surprises the customer | Overages are charged, not blocked — transactions are records, not requests (§12) | | Webhooks never arrive | No Platform Trigger in the customer's company, or the receiver host is not a registered Async URI (§5, §13) | | An API version stops answering | A previous version is supported two years after its successor ships (§Platform state) | Sage's error payloads carry a **`supportId`** (and gateway errors a `requestId`). Capture it and hand it to Support; it is what they ask for first. ## 17. Product fact: how this platform's Sage Intacct connector is wired **As of 2026-09-20**, and worth confirming with the connector's owner before you register anything: - It uses the **OAuth 2.0 authorization code grant against `https://api.intacct.com`**, with the documented authorize and token endpoints, and one **production-only profile** — no separate non-production profile, although the registry forces that choice permanently at application creation (§4). A non-production deployment therefore needs a second registered application *and* a connector profile that does not exist yet. - The authorize request sends `client_id`, `response_type`, `state`, `scope` and `redirect_uri`. **The scope is `offline_access` for every object it supports, read and write alike** — which is correct, since Intacct has no other scope (§6). **PKCE is not used**, which Sage permits for a confidential client. - The token exchange and the refresh are **form POSTs carrying `client_id` and `client_secret` in the body**, matching Sage's samples (§7). - It assumes a **12-hour access-token lifetime** as its fallback default. That matches one of Sage's three published sample values and contradicts the other two (§11). Harmless while Intacct returns `expires_in`; a latent bug if it ever does not. - **It never calls the revoke or the introspect endpoint.** So disconnecting on this platform leaves the customer's tokens live on Sage's side — which is arguably the safer default given §11's revocation blast radius, but it should be a decision, not an accident, and the customer needs telling. - After connecting it **decodes the access token as a JWT and writes the Intacct user id and company id to its observability backend**, then lists the company's entities and caches them on the connection, and calls the model service per object to cache queryable field lists. Two things to raise: the JWT decode is **not defensive** — a non-JWT or unparseable token would fail the whole connection setup with a type error rather than a clear message; and Sage's security guidance about not recording credentials in logs is worth applying to customer identifiers too. - **Entity handling is the biggest thing to check.** It caches the **first entity returned by the company's entity list** as the connection's default. List operations default to **top level** (no entity header), while single-record reads, creates and deletes fall back to that **cached first entity**. On a multi-entity customer that means a list and a get can resolve to different books (§10). Worse, on those fallback paths — and on the delete path generally — the value passed into the entity header is the connector's own **encoded** organization identifier rather than the Intacct entity `id` the header expects, where the primary paths decode it first. Sage's documented header value is the plain `company-config/entity.id`, e.g. `CentralUS-35`. **Reproduce this on a multi-entity company before a customer does.** - Paging uses the query service's `start`/`size` with a **page size capped at 100**, where Sage allows up to **4,000** records per response (§12). Conservative, but it multiplies request count — which matters because Intacct meters the customer's company and charges overages. - **There is no webhook handler** — the webhook entry point is an empty stub containing only a documentation link, and incremental sync is polling. Consistent with §13: Intacct webhooks need a Platform Trigger built inside each customer's company, which is per-customer work this platform does not currently ask for. - **There is no field anywhere for a sender ID or sender password**, which is right: those are bound to the registered application, not collected per connection (§2). It does expose an organization (entity) selector on list and create. - Its recorded documentation links still point at the retired `developer-portal` documentation paths (§Platform state). - **No plaintext credential was found** in the connector's source. The bundled copy of the vendor's own Postman environment carries a masked placeholder secret and a sample client ID, not a live value. This is a snapshot of a codebase that changes. It is a starting point for the conversation, not a specification. ## 18. Hand off — never commit the secret - **Do not** write the client secret or the sender password into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Sage says the same in its own words: do not record it in log files or error messages. Values go to the user, for the secret store or console. - If a code change is needed (a redirect host, entity-header handling, refresh-token persistence, a reconnect prompt), keep it secret-free and say what the human must set out of band. - Close with: the application name; the developer organisation that owns it and **which sender ID it is bound to**; the **client scope** (Production or Non-production) and the fact that it is permanent; the client ID; where the secret was delivered; the authorize, token, revoke and introspect endpoints and the REST base; the redirect URIs and Async URIs registered, exactly as typed; that the only scope is `offline_access`; the **per-customer checklist** from §8 to put in onboarding docs; how an entity is chosen (§10); and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: there is **no Web Services license** and no account manager conversation in flight (§2, §3); the sender ID already belongs to another developer organisation and a second organisation would be needed (§3); the Production/Non-production choice is not clearly settled, because it cannot be undone (§4); customers may be multi-entity and the connector cannot select an entity, or selects different ones for reads and writes (§10); a customer's security review needs per-object scopes, which Intacct does not have (§6); someone proposes calling revoke on a shared Web Services user, given the blast radius (§11); a Marketplace partner listing, partner terms, or the transaction-entitlement exemption is in scope (§12); someone proposes regenerating a live client secret or changing a sender password (which needs a Sage support case); the customer's product turns out to be Sage Accounting or another Sage product (switch skills); or the console does not match the **Platform state** section above. ## References Verified 2026-09-20 — every URL below was fetched and returned HTTP 200 on that date. **`developer.sage.com` returns HTTP 403 to non-browser clients** (Cloudflare bot protection), so each of its links is given in its `.md` form, which Sage serves as the page's plain-Markdown twin. **Drop the trailing `.md` to open the same page in a browser.** Sage Intacct REST API (current documentation tree): - OAuth 2.0 authorization — authorize/token/revoke/introspect, PKCE, client credentials, entity-level access — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/authorization-and-security/oauth2.md - Application security — client ID and secret handling, HTTPS, `state`, roles and permissions — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/authorization-and-security/security.md - Quick start — Web Services license, developer organisation, enrolment, the application form, client scope — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/get-started/quick-start.md - About developer organisations — limits, one sender ID per organisation, what redirect URI / origin domain / client ID / secret are for — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/get-started/organizations-faq.md - Try it — non-production testing from the API reference pages — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/get-started/try-it.md - REST API metrics — usage by application and company — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/get-started/metrics.md - XML-to-REST object map — and the statement that new features ship on REST — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/get-started/xml-rest-object-map.md - REST API FAQ — developer subscription, size limits, pagination, entity requests, breaking changes — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/rest-api-faq.md - Error handling — HTTP status codes, `ia::error` payload, the `GW-` gateway error table — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/error-handling.md - HTTP headers — `X-IA-API-Param-Entity`, rate-limit and throttle response headers — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/headers.md - API transaction volume, concurrency and scaling — the performance-tier table — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/txn-volume-concurrency-scaling.md - API versions — versioning policy and the 1-beta2 deprecation — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/versioning.md - Query service — filters, sorting, pagination — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/query-service.md - Batch, bulk and composite requests — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/bulk-requests.md - Outbound webhooks — Platform Triggers, client ID binding, retries, signature headers — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/webhooks-and-triggers/webhooks.md - Release notes index — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/release-notes.md - Release notes 2026 R1 — invalid tokens now return 401 — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/release-notes/2026-r1.md - REST API reference (OpenAPI) — https://developer.sage.com/intacct/apis/intacct/1/intacct-openapi.md Sage Intacct Developer (XML gateway and legacy): - XML Web Services overview — sender ID / company credentials, endpoint, dtdversion, concurrency, timeouts — https://developer.intacct.com/web-services/ - Developer FAQ — the `XL03000006` "Invalid Web Services Authorization" response, sender-password changes, offline-job throughput — https://developer.intacct.com/support/faq/ - XML API reference — https://developer.intacct.com/api/ - API sessions — session duration and timeout behaviour — https://developer.intacct.com/api/company-console/api-sessions/ Sage Intacct Help Center (the customer-side setup you cannot do): - Web Services authorizations — adding a sender ID to a company, and what happens if you do not — https://www.intacct.com/ia/docs/en_US/help_action/Company/Company_setup/Company_Information/Security/company-web-services-authorizations.htm - Web Services users — what they are, and that they cannot sign in to the UI — https://www.intacct.com/ia/docs/en_US/help_action/Administration/Users/web-services-only-users.htm - Web Services sender ID — obtaining sender credentials — https://www.intacct.com/ia/docs/en_US/help_action/More/Customization_and_Platform_Services/Setup/web-services-sender-id.htm - Company environments — sandbox, preview and production — https://www.intacct.com/ia/docs/en_US/help_action/Administration/Company_environments/company-environments.htm Commercial: - Sage Intacct Marketplace — become a partner — https://marketplace.intacct.com/BecomeAPartner - Sage service status — https://status.sage.com/ ## salesforce-oauth-app URL: https://docs.unified.to/skills/salesforce-oauth-app/SKILL.md # Salesforce OAuth2 App Registration Get a working Salesforce OAuth2 client — a developer org, an External Client App, callback URLs, scopes, consumer key and secret — for a platform that connects customer Salesforce orgs on behalf of many customers. Salesforce is the most changed of the major OAuth providers right now, and the changes are not cosmetic. **Connected Apps can no longer be created**, and four OAuth controls that used to be optional are now enforced and cannot be turned off. Two of them — PKCE and refresh token rotation — are *client* requirements: registering the app correctly is not enough if the code exchanging the tokens does not implement them. The other thing to understand before you start: a Salesforce OAuth app lives in **one** org but is authorized by users in **every customer org**. Customer orgs see it as an *uninstalled* app, and Salesforce now restricts those. §7 is the part that decides whether customers can actually connect. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, contact email, logo | Shown on the authorization screen | | **Which org owns the app** | An existing org, or a new Developer Edition org (§2) | | **Callback URLs** | Every callback host your platform serves (§4) | | **Scope set** | What the connector actually calls (§5) | | **Does the client do PKCE and store rotated refresh tokens?** | Blocking — see §6 and §7 | | **Static egress IPs** | For IP binding, if enforced for this app (§7) | | **Customer install story** | Package/install vs uninstalled authorization (§7) | ## Quick Start 1. Confirm a **new app** is needed — existing connections are bound to the current consumer key (§1). 2. Sign in to, or create, the org that will own the app (§2). 3. Create an **External Client App** in Setup (§3). 4. Add **every** callback URL (§4). 5. Select the scopes the connector calls, and no more (§5). 6. Capture consumer key and secret via identity verification; note the `instance_url` rule (§6). 7. Work through the enforced controls and the uninstalled-app restriction — this is where connectors break (§7). 8. Verify by authorizing from a **second** org, not the one that owns the app (§8). 9. Hand the credentials over — never commit them (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) Three changes land directly on this task. Check the linked articles before following any older runbook: - **New Connected Apps can no longer be created, as of Spring '26.** The block covers the UI *and* the Metadata API. Existing connected apps keep working and can still be installed into new orgs via package installation. Salesforce Support can grant a temporary exception, but says future releases will remove even that. **Use an External Client App (ECA)** for anything new. - **Four OAuth controls are now technically enforced and cannot be disabled once on**: OAuth **PKCE**, **refresh token rotation**, **absolute TTL** (refresh tokens expire after 30 days), and **IP binding** (requests must come from allowlisted static, publicly routable IPs). Partners and ISVs are told to update client code to match. Confirm the exact scope and enforcement date for the org and app type you are working with — this is the claim most worth re-reading at the source. - **Uninstalled connected apps are restricted** since early September 2025. New users are blocked from authorizing an app that is not installed in their org unless they hold the **Approve Uninstalled Connected Apps** permission (granted to System Administrators automatically). Device-flow usage of uninstalled apps is blocked outright. If Setup does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or create a new one A new app means a **new consumer key, and every existing customer connection is bound to the old one** — every customer would have to re-authorize. Reuse the existing app for: adding a callback URL, adding a scope, rotating a compromised secret, or diagnosing an authorization failure. Create a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, or a deliberate migration from a legacy Connected App to an ECA. Note that migrating is itself a customer-visible re-authorization event — it is a project, not a config change. Say which path you are taking first. ## 2. The org that owns the app - **Developer Edition org** — free, does not expire, full API access: sign up at `https://developer.salesforce.com/signup`. This is the right home for an integration's OAuth app when you do not already have one. - **An existing production org** — fine, and normal for an established platform. You need System Administrator access. - **Sandbox** — authorization host is `test.salesforce.com` rather than `login.salesforce.com`. Useful for testing the enforced controls (§7) before touching production. - **My Domain** — most orgs authorize against their own `https://.my.salesforce.com` host. Your client must handle all three host forms, not just `login.salesforce.com`. Hand control back to the user for anything only they can do: signup email verification, MFA enrollment (Salesforce requires MFA), accepting terms, or granting themselves the permissions below. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (§4 callback URLs, §5 scope list), then continue once they report back with the consumer key. ## 3. Create the External Client App In the owning org: **Setup → App Manager → External Client App Manager → New External Client App**. 1. **Basic information** — app name, API name, contact email, logo and description. These appear on the consent screen customers see; fill them from what the user supplied. 2. **API (Enable OAuth Settings)** — turn OAuth on. This reveals callback URLs, scopes and the flow toggles. 3. **Distribution State** — *Local* (this org only, authorized by other orgs as an uninstalled app) or *Packaged* (distributable to customer orgs via a managed package). For a multi-tenant connector this choice interacts with §7's uninstalled-app restriction — read that section before deciding. 4. **Flow enablement** — enable the **Authorization Code and Credentials Flow** (web server flow) for a customer- authorizes-us connector. Leave client-credentials, device and JWT flows off unless the user asked for them. 5. **Policies** — after creation, the app's **Policies** tab holds permitted users, refresh token policy, session policy and the security controls. "Admin approved users are pre-authorized" gives admins the most control; "All users may self-authorize" is what a self-serve connector usually needs. Changing this changes who can connect. ECAs split configuration into **global** OAuth settings (consumer key and secret) and **local** settings (everything else), which is how they avoid shipping credentials inside packages. If a field you expect is missing, check whether it belongs to the other half. ## 4. Callback URLs Register **every** callback host your platform serves — Salesforce accepts multiple, one per line, and matches them exactly. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes: - The `redirect_uri` you send must match a registered value **exactly** — no subdirectory matching, no trailing-slash forgiveness. Mismatch returns `redirect_uri_mismatch`, at connect time rather than at save time. - The callback URL is your platform's, not Salesforce's. It does not change per customer org, even though the *authorization host* does (`login` / `test` / My Domain). - Changes to callback URLs can take a few minutes to propagate. ## 5. Scopes Salesforce scopes are space-delimited in the authorize URL. The ones that matter for an API connector: | Scope | Why | | --- | --- | | `api` | Call the REST/SOAP APIs as the authorizing user | | `refresh_token` | Receive a refresh token at all | | `offline_access` | Keep that refresh token usable when the user is not present | | `openid` `profile` `email` | Identity only — for sign-in flows, not data access | **Unified.to's Salesforce connector uses `api`, `refresh_token` and `offline_access`** for every data object, plus `openid profile email` for the identity/login flow. Confirm the current set with the connector's owner before submitting. Avoid `full`. It grants far more than any connector needs, it is a standard flag in AppExchange security review, and customer security teams reject it. Request the narrow set and say in your summary what you chose and why. Two ways scope requests fail that look like Salesforce being broken: - The org lacks **API Enabled** (Professional Edition without the API add-on). No scope fixes that; the customer needs the add-on or a different edition. - The authorizing **user's profile** lacks API access or the permission to use the app. Scopes cap what a token can do; they never exceed what the user may do. ## 6. Capture the credentials From the **External Client App Manager** → your app → **Edit Settings** (or **Settings**) → expand **OAuth Settings** → **Consumer Key and Secret** → verify your identity. Salesforce emails or prompts for a verification code before it shows the values; there is no way to read them without that step. Capture: - **Consumer key** (the OAuth `client_id`) and **consumer secret** (`client_secret`) - Authorization host(s) in play: `https://login.salesforce.com`, `https://test.salesforce.com` (sandbox), or the customer's My Domain - Endpoints: `/services/oauth2/authorize` and `/services/oauth2/token` on that host - Whether this is a production or sandbox app **The `instance_url` rule.** The token response returns an `instance_url`, and *that* is the API base for every subsequent call — not `login.salesforce.com`, and not a host you can guess from the org name. A client that hardcodes the login host authenticates fine and then 404s on every API request. Store `instance_url` alongside the token. Rotating the consumer secret invalidates the old one immediately: existing access tokens keep working until they expire, but every token exchange and refresh fails until the new secret is deployed. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 7. The enforced controls and the uninstalled-app restriction This section is why Salesforce connectors break. None of it is fixed by re-registering the app. **The four enforced controls** — PKCE, refresh token rotation, 30-day absolute TTL, IP binding — cannot be disabled once enforced, and three of them are client-side work: | Control | What the client must do | | --- | --- | | **PKCE** | Generate a `code_verifier`, send `code_challenge` (S256) on authorize, send `code_verifier` on exchange. A client that never sent PKCE will start failing at the exchange. | | **Refresh token rotation** | Store the **new** refresh token returned by each refresh. A client that keeps reusing the original will fail on the second refresh. | | **Absolute TTL** | Refresh at least once every 30 days, or the connection dies and the customer must re-authorize. Idle connections are the ones that break. | | **IP binding** | Call from static, publicly routable, allowlisted IPs. Serverless or autoscaled egress without stable IPs is a problem to solve before launch, not after. | **Before registering anything, ask whether the client already does PKCE and stores rotated refresh tokens.** If the answer is no or unknown, say so plainly — the app will register fine and then fail in production. That is a finding to report, not a step to work around. Two things to establish separately, because a platform can support PKCE without using it: 1. **Is the PKCE code path implemented?** (Does the client know how to send `code_challenge` and `code_verifier`?) 2. **Is it switched on for Salesforce?** A connector that supports PKCE per-connection but ships with it off will behave exactly like one that cannot do PKCE at all. As of 2026-09-20, **Unified.to's Salesforce connector is in that state**: PKCE is implemented and Salesforce is marked as optionally supporting it, but it is **off by default** and must be opted into per workspace connection — including for connections using Unified.to's shared credentials. Under an org or app that enforces PKCE, every new authorization needs that opt-in, or the flag flipped connector-wide. Confirm the current state with the connector's owner before you register an ECA, rather than assuming either answer. **The uninstalled-app restriction.** Your app lives in your org; a customer authorizing it has not *installed* it. Since early September 2025: - **New users cannot authorize an uninstalled app** unless they hold **Approve Uninstalled Connected Apps** (System Administrators have it by default; it must be added to other profiles deliberately). - Users who authorized before the change keep working — unless the app uses the **device flow**, which is blocked outright for uninstalled apps. So a connector that "worked for years" can start failing for *new* customers only. The fixes are customer-side, and the skill's job is to say which one applies: have the customer's admin install the app in their org (packaged distribution makes this possible), or have them grant the permission to the authorizing user. Salesforce's own ISV guidance is to get customers to install the app proactively. ## 8. Verify end-to-end Authorizing in the org that owns the app proves almost nothing — that org has the app installed by definition. Test the path a customer takes: 1. Authorize from a **second org** (a sandbox, or a fresh Developer Edition org) through your platform's real connect flow, with a non-admin user if customers will use one. 2. Confirm the token response carries a **refresh token** and an **`instance_url`**, and that API calls use the `instance_url` host. 3. Force a **refresh**, then force a **second** refresh using the token returned by the first — that is what catches a client that ignores rotation. 4. Confirm the PKCE round trip actually happened, rather than assuming the library did it. | Symptom | Cause | | --- | --- | | `redirect_uri_mismatch` | Callback URL not registered exactly (§4) | | `invalid_client_id` | Wrong consumer key, or authorizing against the wrong host (`login` vs `test` vs My Domain) | | New customers cannot authorize, existing ones fine | Uninstalled-app restriction (§7) | | Second refresh fails | Client is not storing rotated refresh tokens (§7) | | Connection dies after a quiet month | 30-day absolute TTL (§7) | | Auth succeeds, every API call 404s | Client ignored `instance_url` (§6) | | `invalid_grant` from one customer only | Their session policy, IP restrictions, or the user's API access | ## 9. Hand off — never commit the secret - **Do not** write the consumer secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a callback host, a scope, PKCE support), keep it secret-free and say what the human must set out of band. - Close with: app name, type (External Client App), and owning org; consumer key; where the secret was delivered; the authorize and token endpoints; the exact scope strings; the state of each enforced control and who owns the remaining work; the customer install story; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the client does not implement PKCE or rotated refresh tokens (report it — do not register and hope); IP binding requires infrastructure decisions about static egress; the fix requires customers to install a package or change profile permissions; someone proposes migrating a live Connected App to an ECA (that re-authorizes every customer); an AppExchange listing or security review asks for compliance, legal or volume claims; or Setup does not match the **Platform state** section above. ## References - External Client Apps — https://help.salesforce.com/s/articleView?id=xcloud.external_client_apps.htm&language=en_US&type=5 - Configure ECA OAuth settings — https://help.salesforce.com/s/articleView?id=xcloud.configure_external_client_app_oauth_settings.htm&language=en_US&type=5 - Connected Apps vs External Client Apps — https://help.salesforce.com/s/articleView?id=xcloud.connected_apps_and_external_client_apps_features.htm&language=en_US&type=5 - New Connected Apps can no longer be created (Spring '26) — https://help.salesforce.com/s/articleView?id=005228017&language=en_US&type=1 - Connected app usage restrictions (uninstalled apps) — https://help.salesforce.com/s/articleView?id=005132365&language=en_US&type=1 - Security controls post-enforcement guidance for partners — https://help.salesforce.com/s/articleView?id=005388177&language=en_US&type=1 - Enabling PKCE for OAuth — https://help.salesforce.com/s/articleView?id=005316703&language=en_US&type=1 - OAuth 2.0 web server flow — https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_web_server_flow.htm&language=en_US&type=5 - OAuth tokens and scopes — https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_tokens_scopes.htm&language=en_US&type=5 - REST API: authorization through ECAs/connected apps — https://developer.salesforce.com/docs/platform/api-rest/guide/intro-oauth-and-connected-apps.html - Developer Edition signup — https://developer.salesforce.com/signup ## servicenow-oauth-app URL: https://docs.unified.to/skills/servicenow-oauth-app/SKILL.md # ServiceNow OAuth2 App Registration Get working ServiceNow OAuth 2.0 credentials — the registry entry, the redirect URL, the client ID and the client secret — for a platform that connects *other organizations'* ServiceNow instances. **Read this before anything else: there is nothing for you to register.** ServiceNow's OAuth application registry lives inside each ServiceNow instance. A registry entry created on `acme.service-now.com` exists only on `acme.service-now.com`; its client ID is auto-generated by that instance and is read-only on the form, so it cannot be made to match anyone else's. ServiceNow publishes no global, partner or Store-distributed OAuth client, and no developer portal where you sign up an app once. **Every customer produces their own client ID and client secret inside their own instance, and hands both to you.** The deliverable for a run of this skill is therefore usually not a credential pair at all — it is (a) a short admin runbook a customer's ServiceNow admin can follow in about ten minutes, and (b) a credential pair for *your own* test instance so you can prove the flow. §1 is the section that decides everything else. Three more things are expensive to get wrong. **The instance subdomain is mandatory and undiscoverable** — it is part of the authorize URL, the token URL and the API base, so it must be collected from the customer *before* the browser is redirected anywhere (§3). **ServiceNow has no requestable scope catalogue**: a token acts as the human who authorized it, and what it can read is decided by that user's roles and ACLs, so least privilege means putting a narrow role on a dedicated integration user, not asking for narrow scopes (§6). And **token lifetimes are fields on the customer's registry record** — 30 minutes and 100 days by default, editable by their admin — so nothing in the connector may assume a fixed lifetime (§8). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Whose instance** | Your own test instance, or a named customer's production instance — they are different runs (§1, §9) | | **Instance subdomain** | The bare instance name from `https://.service-now.com`. Required for every URL (§3) | | **Who holds `oauth_admin`** | Only an admin of that instance can create the entry. Usually not you (§2) | | **Application name** and optional logo URL | Shown on the instance's authorization page (§4) | | **Which redirect URL** | The one matching the data centre the customer's workspace lives in (§5) | | **Which integration user authorizes** | The identity the token acts as, and therefore the whole access model (§6) | | **Which objects the connector reads/writes** | Drives the role and ACL conversation with their admin (§6) | | **Any REST API Auth Scope restrictions in force** | An instance-side allow-list that can silently exclude your calls (§6) | | **Release family the instance is on** | Changes the UI path and whether the Machine Identity Console exists (§4, Platform state) | ## Quick Start 1. Establish that credentials are **per customer instance** and that no central client exists — then decide whether this run is a customer runbook, your own test credentials, or both (§1). 2. Confirm the instance prerequisites: OAuth plugin active, activation property true, an admin with `oauth_admin` (§2). 3. Collect the **instance subdomain** in the exact form the connector expects, before any redirect (§3). 4. Create the entry: **All → System OAuth → Application Registry → New → Create an OAuth API endpoint for external clients** (§4). 5. Put the correct **redirect URL** in, exactly, with no trailing slash (§5). 6. Settle access the ServiceNow way — a dedicated integration user with a narrow role, not a scope string (§6). 7. Record the **client ID** and **client secret** at creation; treat the secret as write-once (§7). 8. Check **Access Token Lifespan** and **Refresh Token Lifespan** on the record, and note that the customer can change both (§8). 9. Test on a **Personal Developer Instance**, knowing it hibernates and can be reclaimed (§9). 10. Verify end-to-end against a second, unrelated instance and a non-admin user (§10), then hand off (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) Verified against the **Australia** release family documentation (the current family; its release notes page was updated 10 September 2026). ServiceNow ships two feature families a year and the OAuth surface has moved twice recently, so check which family the customer's instance is on before quoting a UI path. - **Documentation moved.** `docs.servicenow.com/bundle/-…` URLs now 301-redirect to `www.servicenow.com/docs/r/…`, and the redirect lands on the *current* release rather than the one named in the old URL. Links written against Washington DC or Xanadu still resolve but no longer pin a version. - **The classic path still works**: **System OAuth → Application Registry → New → "Create an OAuth API endpoint for external clients"**, role `oauth_admin`. - **A newer path exists: the Machine Identity Console**, for managing the service accounts and credentials behind inbound API integrations. Its docs are dated to the Australia release and it is gated behind the Machine Identity Management plugin (`com.glide.identity.machine_identity_management`). It offers a guided per-grant-type form for inbound integrations, including the authorization code grant. Either path produces the same thing — an entry in the instance's OAuth registry. - **Documented defaults on the registry record**: Access Token Lifespan **1,800 seconds (30 minutes)**, Refresh Token Lifespan **8,640,000 seconds (100 days)**. Both are editable fields, so both are per-customer facts, not constants. - **Client ID is auto-generated and read-only**; the client secret is auto-generated if the field is left blank. - **Prerequisites**: the **OAuth 2.0** plugin (`com.snc.platform.security.oauth`) must be active and the property **`com.snc.platform.security.oauth.is.active`** must be `true`. - **Documented grant types for inbound OAuth**: authorization code, client credentials, JWT bearer, third-party ID token (OIDC), resource owner password credentials, and implicit (described as legacy). The authorization code grant is documented as supporting **both confidential clients (client secret) and public clients (PKCE)**. - **Scopes are an instance-side allow-list, not a request-time catalogue.** `useraccount` is the default scope and functions as an override — an entity carrying it "can access any API even if you have created a REST API Auth Scope record with a different auth scope." REST API Auth Scope records are created by the customer's admin (roles `api_service_admin`, `adaptive_auth_policy_admin`). - **Rate limits are per-instance rules**, in `sys_rate_limit_rules`, created by a `rate_limit_admin` as a **request limit per hour** applied to one user, a role, or everyone. No out-of-the-box limit is documented. Over the limit returns **429** with a **`Retry-After`** header in seconds. - **Personal Developer Instances hibernate**, and ServiceNow emails when one has been inactive for over 10 days; a reclaimed PDI "is reset to its original state and reassigned to another Developer Program member." Signing in to the Developer Site at least weekly keeps it. If the instance does not look like this, stop and report what you actually see rather than clicking on. ## 1. There is no central ServiceNow OAuth client — decide what this run produces This is the whole task. ServiceNow is not a vendor you register with; it is software each customer runs an instance of, and each instance is its own OAuth authorization server. | | What ServiceNow does | Consequence for a multi-tenant connector | | --- | --- | --- | | Where the app record lives | In the customer's instance, `System OAuth → Application Registry` | You cannot create it. Their admin must | | Who owns the client ID | The instance generates it; the field is read-only | No shared identifier is possible, even in principle | | Who owns the secret | The instance | You store N secrets, one per customer | | Who authorizes | A user *on that instance* | Access follows that user's roles and ACLs (§6) | | Central listing / marketplace client | None documented for inbound OAuth | A Store listing does not get you a shared client | So a run of this skill produces one of three things, and you should say which before doing anything: 1. **A customer runbook** — the ordered click path, the literal redirect URL, the role guidance, and a safe way for them to return the client ID and secret. This is the normal output, and it is what to write when the person asking does not have admin on the instance in question. 2. **Your own test credentials** — a registry entry on a Personal Developer Instance (§9), used to prove the flow and to keep a regression environment. Legitimate, and frequently the only thing you can actually create yourself. 3. **A diagnosis** — an existing customer connection is failing and you need to work out whether it is their registry entry, their integration user's roles, an auth scope, or the connector (§10). **Do not ask a customer for a ServiceNow username and password as a shortcut.** Basic authentication against the REST API exists and some connectors support it, but it hands you a reusable human credential with that person's full access, cannot be revoked without changing their password, and is the thing customer security reviews object to. Client credentials grant is also available but binds the token to a fixed OAuth Application User with no user consent and needs its own property enabled (`glide.oauth.inbound.client.credential.grant_type.enabled`) — it is a different integration shape, not a drop-in for a customer-authorizes-us flow. If either is being proposed, raise it rather than implementing it quietly. > **Product fact — as of 2026-09-20, this platform's ServiceNow connector** is built for the per-instance model: it > substitutes a customer-supplied instance host into the authorize URL, the token URL and the API base, and it also > offers a username/password/subdomain alternative for customers who will not run the OAuth flow. It deliberately does > **not** offer ServiceNow as a sign-in provider, precisely because an instance host has to be supplied before any > redirect can be built. Confirm the current behaviour with the connector's owner before writing it into a runbook. ## 2. Prerequisites inside the instance, and the errors when they are missing Before the form will do anything useful: - **Plugin**: **OAuth 2.0**, `com.snc.platform.security.oauth`, must be **active**. - **Property**: **`com.snc.platform.security.oauth.is.active`** must be `true`. The documentation ties this directly to whether "the instance can generate OAuth 2.0 tokens." - **Role**: **`oauth_admin`** is the role the create-endpoint procedure names. Related pages name `admin` and `security_admin` for adjacent tasks, and the inbound OAuth overview lists `oauth_admin`, `mi_admin` or `admin` as the roles that configure OAuth integrations — in practice, a platform admin. A normal ITIL user has none of them. - **Only for the client credentials grant** (not the authorization code flow): the property **`glide.oauth.inbound.client.credential.grant_type.enabled`** must be created and set to `true`, and an **OAuth Application User** must be set on the record. Mentioned here only so nobody chases it for the wrong flow. What missing prerequisites look like from outside, and why they mislead: - **"System OAuth" is simply not in the navigator.** This reads as "my instance is broken" or "wrong release." It almost always means the plugin is inactive or the signed-in user lacks the role. The module is invisible rather than greyed out, so there is nothing to click that explains it. - **The form saves but tokens never issue.** The plugin can be active while the activation property is false. The registry entry looks perfect, the client ID is there, and the token endpoint refuses — the failure surfaces at exchange time, hours later, on someone else's screen. - **A 401 that has nothing to do with your credentials.** Instances sit behind IP access control and login policies; an instance that only accepts requests from allow-listed ranges will reject a correct client ID and secret in a way indistinguishable from a wrong one. Ask the customer's admin whether IP access control is on before debugging the credential. ## 3. The instance subdomain — mandatory, undiscoverable, and easy to collect in the wrong shape Every URL in the flow is per customer: ``` https://{instance}.service-now.com/oauth_auth.do # authorize https://{instance}.service-now.com/oauth_token.do # token exchange and refresh https://{instance}.service-now.com/api # REST API base (e.g. /api/now/table/incident) ``` There is no discovery endpoint, no email-domain lookup and no directory. **The customer tells you their instance name, or the connection cannot be built.** So the connect experience must ask for it *before* the authorize redirect — a connector that sends the user to a generic ServiceNow URL and hopes to learn the instance later has no URL to send them to at all. **Ask for the bare instance name** — `acme`, `acmedev`, `dev123456` — the label in front of `.service-now.com`. The three answers customers actually give, and what to do about each: | What they paste | What to do | | --- | --- | | `acme` | Correct. Use as-is | | `acme.service-now.com` | Strip `.service-now.com` before use | | `https://acme.service-now.com/now/nav/ui/…` | Take the first host label only | Also worth knowing: large customers have several instances — production, one or more sub-production clones, and developer instances — each with its own hostname, its own registry and its own data. "Our ServiceNow" is ambiguous. Confirm which instance the connection is meant to reach, and expect the sub-production clone to contain a stale copy of production data. Some customers front their instance with a vanity domain; if the host is not `*.service-now.com`, stop and ask rather than guessing at the URL shape. > **Product fact — as of 2026-09-20, this platform's ServiceNow connector** requires the instance value at connect > time and substitutes it into all three URLs above. It performs **no normalisation of what the customer types**: the > bare instance name works, and a value that already contains `.service-now.com` or a scheme produces a malformed > host. Until that changes, a runbook and a connect-screen hint must both say **"just the instance name"** in so many > words. Confirm with the connector's owner. ## 4. Create the registry entry **All → System OAuth → Application Registry → New → "Create an OAuth API endpoint for external clients."** Do not pick "Connect to a third party OAuth Provider" — that is the instance authenticating *outbound* to someone else, and it produces no client ID for you. It is the single most common wrong turn on this form, because both options live behind the same **New** button. | Field | What matters | | --- | --- | | **Name** | Identifies the application requesting OAuth access; it is what the customer will see in their own registry later. Use your product's name so they can audit it | | **Client ID** | **Auto-generated, read-only.** This is the `client_id`, and it is unique to this instance | | **Client Secret** | The shared secret. Auto-generated if left blank. §7 | | **Redirect URL** | The callback URL the authorization server redirects to. §5 | | **Logo URL** | URL of an image to use as the application logo | | **Active** | Must be ticked, or nothing authorizes | | **Access Token Lifespan** | Seconds. Default **1,800** (30 minutes). §8 | | **Refresh Token Lifespan** | Seconds. Default **8,640,000** (100 days). §8 | | **Enforce Token Restrictions** | "Only allow tokens to be used with APIs set to allow the authentication profile." Leave as the customer's security team wants, but know that turning it on narrows what your token can call | | **Client Type** | Iframe Embedded / Integration as a User / Integration as a Service. A customer-authorizes-us connector is the *user* shape; the service shape belongs with client credentials | | **Token Format**, **Subject Claim** | Only relevant if the instance issues JWT-format tokens | | **Comments** | Free text. Ask them to note who owns the integration — it is the field that stops the entry being deleted in a future audit | On a newer instance the customer may instead be steered to the **Machine Identity Console**, which asks the same questions through a per-grant-type wizard and stores the result in the same place. If their screen does not match this table, that is the likely reason — have them say which release family the instance is on rather than improvising. ## 5. Redirect URL The redirect URL is **yours**, not theirs, even though it is typed into their instance. It does not change per customer; only the authorize and token hosts do (§3). ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Because credentials are per instance, **each customer normally needs exactly one of these** — the one for the data centre their workspace lives in. Confirm which before writing the runbook; handing a customer four URLs invites them to pick the wrong one. Confirm the current list with the platform owner rather than assuming. Notes that save a support round-trip: - **Exact match.** Send the identical string on authorize and again on the token exchange. No trailing slash, no added query string, no subpath forgiveness. - The documented example for this field is of the form `…/oauth_redirect.do`, which is the path ServiceNow's *own* callback uses when an instance is the OAuth **client**. It is an example, not a requirement, and it confuses admins into typing their own instance's `oauth_redirect.do` here. Your URL is the one that belongs in this field. - The field is documented in the singular. Comma-separating several URLs is widely reported to work and is not documented; if a customer genuinely needs more than one, have them verify it on their instance instead of assuming, or create a second registry entry. - Changing the redirect URL later is a form edit on their side and takes effect immediately — unlike most portals, nothing is frozen. That is the one respect in which ServiceNow is more forgiving than a central provider. ## 6. "Scopes" — ServiceNow does not have them in the usual sense There is no list of scope strings to request, no consent screen enumerating permissions, and nothing to line up between the registry entry and the authorize URL. The documented default and only scope for ServiceNow as an OAuth provider is **`useraccount`**, and it is an override rather than a restriction: an entity associated with it "can access any API even if you have created a REST API Auth Scope record with a different auth scope." **Access is the authorizing user's roles and ACLs.** The token acts as that person. The Table API documentation is blunt about it: "The calling user must have sufficient roles to access the data in the table specified in the request." ACLs are then evaluated per record and per field. The consequence is concrete and is the single most common "the integration is broken" ticket: > A read of a table the user can see, but whose rows or fields they cannot, returns **HTTP 200 with fewer rows than > the table holds — or rows with fields blanked out — and no error at all.** Version 2 of the Table API returns 200 > and an empty array when nothing matches or nothing is visible. Nothing in the response says "filtered." A customer > looking at 4,000 incidents in their browser and 120 in your product is looking at an ACL result, not a pagination > bug. So **least privilege here means the identity, not the request**: 1. Have the customer create a **dedicated integration user** rather than authorizing as a named human. A human's token dies with their offboarding, and carries their entire access. 2. Give that user the **narrowest role that covers the tables the connector reads**, and no more. Ask their admin to confirm read access to each table the connector touches, by name, before go-live — it is much cheaper than discovering it as missing rows three weeks later. 3. Expect their admin to want **REST API Auth Scope** on top: scope records link an auth scope to a specific REST API, method, version and resource, and once one is enabled on an API, "all the existing OAuth token won't able to access this API anymore unless admin adds this auth scope to the corresponded OAuth entity." That is an instance-side change that can break a working connection with no action on your side — so if a customer's connection starts returning 403s for one endpoint only, this is the first thing to ask about. 4. Remember **domain separation** on large instances: a user scoped to one domain sees only that domain's records, which is another silent row filter. > **Product fact — as of 2026-09-20, this platform's ServiceNow connector** sends **no `scope` parameter** on the > authorize call, which matches ServiceNow's model. Its `scope` configuration is present but commented out, with > `useraccount` as the value it would send. It reads and writes through the Table API against the incident, user, > journal-field and knowledge-base tables, and lists an attachment-backed file object — so the integration user's role > must cover exactly those tables, read and (where the workspace writes) write. Confirm the current object set with > the connector's owner before telling a customer's admin which role to grant. ## 7. Capture the credentials Per customer, collect and store: - **Client ID** and **client secret**, from their registry entry - **Instance subdomain**, in the bare form (§3) - Which **user** authorized, and with what role - The **Access Token Lifespan** and **Refresh Token Lifespan** values on that record (§8) - Whether **Enforce Token Restrictions** is on, and whether any REST API Auth Scope applies (§6) **Treat the secret as write-once.** It is stored in an encrypted field type on the record; depending on the instance and release, an admin may not be able to read it back afterwards, only replace it. Tell the customer to copy it at creation. If it is lost, the recovery is to set a new one — which is a rotation (below), not a retrieval. **Rotation breaks every exchange and refresh for that one customer** until the new value is deployed. Access tokens already issued keep working until they expire, which by default is 30 minutes. Never rotate without explicit go-ahead. The upside of the per-instance model is that the blast radius is one customer, not all of them. **Getting the secret from the customer to you is part of the runbook.** Ask for it through whatever secret-sharing channel the platform already uses. Do not accept it in a ticket comment, an email thread or a chat message, and do not paste it into one yourself. If a customer sends it in the clear anyway, say so and have them rotate it once the connection is up. > **Product fact — as of 2026-09-20, this platform's ServiceNow connector** exchanges the code with a form-encoded > POST carrying `client_id`, `client_secret`, `grant_type`, `code` and `redirect_uri` — credentials in the body, not > HTTP Basic — and refreshes with `client_id`, `client_secret`, `grant_type` and `refresh_token`. It also sends PKCE > (`code_challenge_method=S256` on authorize, a verifier on exchange) alongside the client secret. ServiceNow > documents PKCE for public clients on the authorization code grant; the confidential-client-plus-PKCE combination is > worth confirming on a test instance rather than assuming. Confirm all of this with the connector's owner. ## 8. Tokens: nothing here is a constant - **Access token**: default **1,800 seconds (30 minutes)**. - **Refresh token**: default **8,640,000 seconds (100 days)**. - **Both are fields on the customer's registry record.** A security-conscious admin may set the access token to five minutes; a lax one may extend it. **The connector may not hard-code either.** Store `expires_in` from the response and refresh on it, and treat a 401 as "refresh once and retry," not "the customer must re-authorize." - **The 100-day refresh token is the quiet one.** A customer who connects once and is never re-authorized will drop off roughly three months later with no warning, mid-quarter, looking like an outage. Whatever the platform does about expiring refresh tokens — proactive refresh, re-consent email, an alert — decide it deliberately rather than discovering it. - **Revocation is theirs.** Deactivating the registry entry, deactivating or locking the integration user, changing their roles, or deleting the token record all stop your access immediately and without notice to you. - **Rate limits are per instance.** Rules live in `sys_rate_limit_rules` as a *request limit per hour*, applied to a single user, a role, or all users, and created by a `rate_limit_admin`. No default limit is documented, so the honest statement is that you cannot know a customer's budget without asking. Over the limit is **429** with **`Retry-After`** in seconds — honour it. An hourly budget behaves differently from a per-minute one: a backfill can consume the whole hour's allowance in two minutes and then stall for fifty-eight. > **Product fact — as of 2026-09-20, this platform's ServiceNow connector** implements the refresh grant. Its list > calls page with an offset parameter and a limit parameter, up to 10,000 records per page, and suppress the row > count. That page size matches the Table API's documented default maximum, and ServiceNow warns that large values > affect performance — on a slow instance a full-page read is the thing that trips a customer's rate limit rule. > Confirm with the connector's owner. ## 9. A test instance: the Personal Developer Instance, and its two surprises You cannot rehearse this against a customer. Get a **Personal Developer Instance (PDI)** from ServiceNow's developer site: free, full platform, its own `*.service-now.com` hostname, and admin rights — which is what you need, since creating a registry entry requires `oauth_admin`. Two behaviours catch teams mid-project: 1. **It hibernates.** An idle PDI shuts down its database and application server. Waking it from the Developer Site "usually take[s] three to five minutes … and should not take any longer than 20 minutes." A pipeline or a regression suite pointed at a hibernating PDI does not get a clean error; it gets timeouts and a very confused afternoon. 2. **It can be reclaimed.** ServiceNow emails when a PDI has been inactive for over 10 days, and a reclaimed PDI "is reset to its original state and reassigned to another Developer Program member." Everything on it — your registry entry, its client ID and secret, your test data — is gone, and the same credentials will never come back. Signing in to the Developer Site at least weekly is the documented way to keep it. So: never make a PDI the only home of anything you cannot recreate, keep the registry entry's creation steps in the runbook rather than in your head, and if a test connection breaks after a quiet fortnight, check whether the instance still belongs to you before debugging the connector. A customer's own **sub-production (development or test) instance** is the other useful target, and is the right place to have them rehearse the runbook before touching production. Note its registry is separate from production's, so the credentials do not carry over. ## 10. Verify end-to-end Testing as an instance admin proves almost nothing — an admin sees everything, so every ACL problem in §6 stays invisible. Test the path a customer's integration user actually takes: 1. Authorize through the platform's real connect flow, supplying the instance name the way a customer would. 2. Authorize as the **dedicated integration user with its narrow role**, not as `admin`. 3. Confirm the token response carries a **`refresh_token`** and an **`expires_in`**, and store both. 4. **Force a refresh** and make a read call with the new token. This is the check that catches a connector built for tokens that do not expire. 5. Read **one object per supported type**, and compare the row count against what that same user sees in the ServiceNow UI. Equal counts mean the role is right; fewer rows through the API means ACLs are filtering (§6). 6. Repeat the whole thing against a **second, unrelated instance** — a colleague's PDI is enough. The per-instance model means the second instance is the first honest test of the runbook. | Symptom | Cause | | --- | --- | | "System OAuth" missing from the navigator | OAuth plugin inactive, or the signed-in user lacks `oauth_admin` (§2) | | Registry entry looks correct, token endpoint refuses | `com.snc.platform.security.oauth.is.active` is not `true` (§2) | | Authorize URL 404s or shows the instance's login page forever | Wrong instance host, or a value entered with `.service-now.com` or a scheme already in it (§3) | | No client ID and secret anywhere on the form | "Connect to a third party OAuth Provider" was chosen instead of the external-clients option (§4) | | Redirect rejected, or exchange fails after a good authorize | Redirect URL not identical between the registry entry, the authorize call and the exchange (§5) | | One customer's credentials work; another's are rejected | Expected — client IDs are per instance and never portable (§1) | | 200 OK, but far fewer rows than the customer sees | The authorizing user's roles and ACLs, or domain separation (§6) | | Fields present in the UI arrive empty over the API | Field-level ACLs on those fields (§6) | | 403 on one endpoint only, everything else fine | A REST API Auth Scope record now covers that API and the OAuth entity is not linked to it (§6) | | Works for 30 minutes, then 401 on everything | Access token expired and the client did not refresh (§8) | | Connections die roughly three months after connecting | The 100-day refresh token lifespan elapsed (§8) | | Sudden 401s across every call for one customer | The registry entry was deactivated, or the integration user was locked or had roles removed (§8) | | 429 with `Retry-After` during a backfill | The instance's hourly rate limit rule; honour the header and pace the job (§8) | | Correct credentials rejected from your servers only | IP access control on the instance (§2) | | Test instance stops responding after a quiet week | PDI hibernating — or reclaimed (§9) | ## 11. Hand off — never commit the secret - **Do not** write a client secret, a ServiceNow password or an access token into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the user, for their secret store. - If a code change is needed (instance-name normalisation, refresh handling, pagination), keep it secret-free and say what the human must set out of band. - The customer-facing artefact is a **runbook**, and it should contain: the click path (§4), the literal redirect URL for their data centre (§5), the instance-name format you need (§3), the integration-user and role guidance (§6), the two lifespan fields to leave alone or to tell you about (§8), and how to return the client ID and secret safely (§7). - Close with: whose instance; the instance subdomain; the client ID; where the secret was delivered; which user authorizes and with what role; the two lifespan values on the record; whether Enforce Token Restrictions or any REST API Auth Scope applies; what was verified end-to-end and on which instance; and anything left for the customer. ## Stop and ask Hand back to a human rather than guessing when: someone expects a single shared ServiceNow OAuth client and the per-instance reality changes the product plan (§1); nobody with `oauth_admin` on the target instance has been identified; a customer proposes handing over a human's username and password, or their own admin credentials (§1); the role to grant the integration user is a security decision their admin has not made (§6); an instance-side REST API Auth Scope or Enforce Token Restrictions setting would have to be changed (§6); the customer has multiple instances and nobody has said which one is in scope (§3); the instance is fronted by a vanity domain or behind IP access control (§2, §3); a client secret needs rotating on a live connection (§7); or the instance's UI does not match the **Platform state** section above. ## References - OAuth 2.0 overview — https://www.servicenow.com/docs/r/australia/platform-security/authentication/c_OAuthApplications.html - Set up OAuth (plugin and activation property) — https://www.servicenow.com/docs/bundle/australia-platform-security/page/administer/security/task/t_SettingUpOAuth.html - Create an OAuth API endpoint for external clients (the field table) — https://www.servicenow.com/docs/bundle/australia-platform-security/page/administer/security/task/t_CreateEndpointforExternalClients.html - OAuth authorization code grant flow — https://www.servicenow.com/docs/bundle/australia-platform-security/page/administer/security/concept/c_OAuthAuthorizationCodeFlow.html - Authorize access to the endpoint (the user-facing consent step) — https://www.servicenow.com/docs/bundle/australia-platform-security/page/administer/security/task/t_AuthorizeAccessEndpiont.html - Authorization code grant (confidential vs public clients, PKCE) — https://www.servicenow.com/docs/r/australia/platform-security/authentication/authorization-code-grant.html - OAuth inbound (grant types, roles) — https://www.servicenow.com/docs/r/australia/platform-security/authentication/oauth-inbound.html - OAuth inbound and outbound — https://www.servicenow.com/docs/r/australia/platform-security/authentication/oauth-inbound-and-outbound.html - REST API Auth Scope (how instance-side scoping really works) — https://www.servicenow.com/docs/r/australia/platform-security/authentication/rest-api-auth-scope.html - Configure auth scope — https://www.servicenow.com/docs/r/australia/platform-security/authentication/configure-rest-api-auth-scope.html - Add the OAuth Application User (client credentials only) — https://www.servicenow.com/docs/r/australia/platform-security/authentication/add-oauth-application-user.html - Create the client credentials system property — https://www.servicenow.com/docs/r/australia/platform-security/authentication/create-cc-sys-prop.html - Machine Identity Console — https://www.servicenow.com/docs/r/australia/platform-security/identity/machine-identity-console.html - Inbound integrations in the Machine Identity Console — https://www.servicenow.com/docs/r/australia/platform-security/identity/inbound-integrations.html - Table API (roles, ACLs, sysparm parameters, 200-with-empty-array) — https://www.servicenow.com/docs/bundle/australia-api-reference/page/integrate/inbound-rest/concept/c_TableAPI.html - Inbound REST API rate limiting (429 and Retry-After) — https://www.servicenow.com/docs/bundle/australia-api-reference/page/integrate/inbound-rest/concept/inbound-REST-API-rate-limiting.html - Create an inbound REST API rate limit rule — https://www.servicenow.com/docs/bundle/australia-api-reference/page/integrate/inbound-rest/task/create-REST-API-rate-limits.html - REST API overview — https://www.servicenow.com/docs/bundle/australia-api-reference/page/build/applications/concept/api-rest.html - Australia release notes (current release family) — https://www.servicenow.com/docs/r/australia/release-notes/family-release-notes.html - Personal Developer Instance FAQ (hibernation, 10-day reclamation) — https://developer.servicenow.com/print_page.do?release=yokohama&category=now-platform&identifier=pdi_faq&module=guide - ServiceNow Developer Program (get a PDI) — https://developer.servicenow.com/dev.do ## shopify-oauth-app URL: https://docs.unified.to/skills/shopify-oauth-app/SKILL.md # Shopify OAuth2 App Registration Get a working Shopify OAuth2 client — a developer organization, an app of the right type, redirect URLs, scopes, client ID and client secret — for a platform that connects many merchants' Shopify stores. Four things here are expensive to get wrong, and three of them cannot be undone by editing the app later. **The distribution method is permanent** and decides whether you can serve more than one merchant at all (§1). **Protected customer data approval** is a separate review from App Store review, and without it every customer name, email, address and phone comes back `null` rather than erroring (§6). **Expiring offline access tokens** became mandatory for new public apps on 1 April 2026 and for *all* public apps on 1 January 2027 — a client that stores one never-expiring token per shop is on a deadline (§7). And **the mandatory compliance webhooks plus HMAC verification** are review blockers, not hygiene (§8). ## The per-shop model — read this first Shopify is not single-tenant OAuth with a tenant parameter bolted on. Every part of the flow is scoped to one store: - The authorize URL is **on the merchant's own host**: `https://{shop}.myshopify.com/admin/oauth/authorize`. There is no central `login.shopify.com` to send merchants to. - The token exchange is on that same host: `POST https://{shop}.myshopify.com/admin/oauth/access_token`. - The API base is that host too: `https://{shop}.myshopify.com/admin/api/{version}/…`. - The access token that comes back **is scoped to that one store**, and is sent as `X-Shopify-Access-Token`, not as a bearer token. So "the Shopify credential" is **one client ID and one client secret for the app, plus one access token per store**. The client secret is also the key for HMAC verification of both the OAuth callback and every webhook (§8). Your connect flow must therefore collect the shop domain *before* it can even build an authorize URL — that is a UI requirement, not an implementation detail, and it is the single biggest difference from Salesforce, HubSpot or Google. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, app URL, contact email, icon | Shown to merchants at install and in any listing | | **Distribution intent** | Many merchants (public) or one store / one Plus org (custom) — permanent (§1) | | **Which organization owns the app** | Existing partner/dev organization, or a new one (§3) | | **Redirect URLs** | Every callback host your platform serves (§5) | | **Scope set** | The exact scope strings the connector calls (§6) | | **Does the app need customer PII?** | Names, emails, addresses, phones → protected customer data review (§6) | | **Does the client store a refresh token and refresh it?** | Blocking for new public apps (§7) | | **Privacy policy URL, data-retention and security answers** | Required by the protected-data request (§6) | | **Where the compliance webhooks are handled** | `customers/data_request`, `customers/redact`, `shop/redact` (§8) | ## Quick Start 1. Decide **public vs custom distribution** — permanent, and it caps how many stores you can serve (§1). 2. Confirm a **new app** is needed; a new client ID orphans every existing per-shop token (§2). 3. Sign in to, or create, the partner/developer organization that will own the app (§3). 4. Create the app and mark it **not embedded** if it has no Shopify admin UI (§4). 5. Add **every** redirect URL, exactly (§5). 6. Declare the scopes, and file the **protected customer data** request if you touch PII (§6). 7. Confirm the client requests **expiring offline** tokens and stores the refresh token (§7). 8. Confirm HMAC verification on the callback and webhooks, and the three compliance webhooks (§8). 9. Pin and diary an **API version** — it expires in about a year (§9). 10. Capture client ID and secret; the secret is rotate-only, not re-readable (§10). 11. Submit for App Store review if public (§11), then verify end-to-end on a development store (§12). 12. Hand the credentials over — never commit them (§13). ## Platform state (verified 2026-09-20 — re-verify before trusting) Five changes land directly on this task. Check them at the source before following any older runbook: - **The Dev Dashboard (`dev.shopify.com/dashboard`) is where apps are now created and configured.** Announced 21 May 2025 as early access, it is now the hub for creating apps, managing organizations and development stores, and generating the credentials an app uses. The Partner Dashboard still exists and still owns commercial concerns (payouts, the App Store listing, and the API access requests in §6), so expect to work across both and to find older screenshots and click paths in the wrong one. **Verify which dashboard your organization is on before quoting a click path.** - **Expiring offline access tokens are mandatory for new public apps as of 1 April 2026**, and for *all* public apps calling the Admin API as of **1 January 2027**. Custom apps and merchant-created apps are unaffected. See §7. - **Legacy admin-created custom apps can no longer be created as of 1 January 2026.** Existing ones keep working and are still managed from the Shopify admin, but new custom apps must be built as Shopify apps with custom distribution selected. Any instruction that starts "in the Shopify admin, go to Settings → Apps and sales channels → Develop apps" is describing the legacy path. Private apps are older still: they were converted to custom apps on 20 January 2023 and do not exist as a type any more. - **The REST Admin API has been legacy since 1 October 2024**, and since **1 April 2025** new public apps submitted to the App Store must be built with the **GraphQL Admin API**. REST still answers, but a new public app built on it will not pass review. See §9. - **Distribution is chosen once and cannot be changed.** Public and custom are separate, permanent paths (§1). If the dashboard does not look like this, stop and report what you actually see rather than clicking on. ## 1. App types and distribution — the permanent choice Shopify's "app type" is really a **distribution method**, selected once per app and **not changeable afterwards**. | Type | Who can install | Review | Billing API | Notes | | --- | --- | --- | --- | --- | | **Public distribution** | Any merchant, unlimited stores | **Yes** — App Store review | Yes | Can be listed in the Shopify App Store. The only option for a multi-tenant connector. | | **Custom distribution** | **One store**, or the stores of **one Shopify Plus organization**, via a generated install link | No review | **No** | Fine for one customer; it cannot serve a second unrelated merchant, ever. | | **Shopify admin (legacy custom)** | One store | No | No | **Cannot be created since 1 Jan 2026.** Existing ones keep working. No App Bridge, no extensions. | | **Private apps** | — | — | — | Retired; converted to custom apps on 20 Jan 2023. | **A multi-tenant connector needs public distribution.** That is the answer, and it carries the cost: App Store review (§11), the mandatory compliance webhooks (§8), protected customer data approval (§6), the GraphQL requirement (§9) and the expiring-token deadline (§7). Choosing custom distribution to skip review works exactly until the second customer, at which point the app cannot be converted and a brand-new app — new client ID, every merchant re-authorizing — is the only way out. Two practical notes: - A public app does **not** have to be *listed*. Public distribution is what makes it installable by any merchant; publishing a listing in the App Store is a further step. An unlisted public app still goes through app review to become installable, so "unlisted" is not a way around review. - If the user's real requirement is "our own stores only", the **client credentials grant** exists for stores inside the app owner's own organization and skips merchant authorization entirely. Say so rather than registering a public app for it. ## 2. Decide: reuse the existing app, or register a new one A new app means a **new client ID and secret**, and **every existing per-shop access token is bound to the old client**. Every merchant would have to reinstall. Reuse the existing app for: adding a redirect URL, adding or removing scopes (§6), rotating a compromised secret (§10), requesting protected customer data access (§6), or diagnosing an install failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a separate product, or an unavoidable migration off a custom-distribution or legacy custom app (§1). Say which path you are taking before you touch anything. ## 3. The organization that owns the app - **Partner / developer organization** — the account that owns apps and development stores. Start from `https://www.shopify.com/partners`; app creation and configuration then happen in the Dev Dashboard at `dev.shopify.com/dashboard`. - **Development stores** — free test stores created from the dashboard. This is what you verify against (§12); you do not need a paid store. - **Plus sandbox stores** — a separate, Plus-only facility. If the user asks for a sandbox that behaves like a real Plus store, that is a Shopify Plus entitlement, not something you can provision. - **Staff permissions** — creating and managing apps requires the app-development permission on the organization. Hand control back for anything only a human can do: account signup and email verification, accepting the partner agreement, 2FA enrollment, and anything that asks for business or tax details. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the redirect URLs from §5 and the scope list from §6 — then continue once they report back with the client ID. ## 4. Create the app Two supported routes, and they converge on the same app record: - **Dev Dashboard** — create the app in the organization, then configure it from the app's settings. - **Shopify CLI** — scaffold or link an app and configure it from `shopify.app.toml`, then `shopify app deploy` to push configuration to all stores. Changes made only during `shopify app dev` apply to your development store and **do not reach production until you deploy** — a common reason a redirect URL "was added" and still fails. The choices that matter: 1. **Distribution** — §1. Permanent. 2. **Embedded or not.** A connector with no UI inside the Shopify admin is **not embedded**. Mark it so. Embedded apps are expected to use session tokens and the token exchange grant; a non-embedded app has no ID token to exchange and therefore **must** use the authorization code grant described here. 3. **App URL** — the app's own URL. Distinct from the redirect URLs in §5; merchants land here after install. 4. **Access scopes** — declared on the app (§6), not invented per authorize request. 5. **Compliance webhook endpoints** — §8. Configure them now; review will check them. ## 5. Redirect URLs Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes: - The `redirect_uri` you send must **exactly match** a registered value. No prefix matching, no trailing-slash forgiveness. A mismatch fails at install time with a "redirect_uri is not whitelisted" style error, not at save time. - These are **your platform's** hosts. They do not vary per merchant, even though the authorize host does (see “The per-shop model”) — that asymmetry is what trips people who expect the callback to be shop-scoped too. - Redirect URLs can be set in the Dev Dashboard **or** in the `[auth]` section of `shopify.app.toml`. If both exist, a deploy from the CLI overwrites the dashboard values. Pick one source of truth and say which. - Changes may take a few minutes to propagate. ## 6. Scopes and protected customer data Scopes are **declared on the app** and sent in the authorize URL. Shopify's docs specify a **comma-separated** list in the `scope` parameter; if your client joins them another way, confirm it against a real authorize round trip rather than assuming tolerance. The ones that matter for a commerce/accounting connector: | Scope | Covers | | --- | --- | | `read_products` / `write_products` | Products, variants, collections, selling plans | | `read_inventory` / `write_inventory` | Inventory items and levels | | `read_locations` | Locations | | `read_publications` / `write_publications` | Sales channels / publications | | `read_orders` / `write_orders` | Orders, fulfillments, transactions, abandoned checkouts — **last 60 days only** | | `read_all_orders` | Removes the 60-day window. **Requires Shopify approval.** | | `read_customers` / `write_customers` | Customers and companies — **protected customer data** | | `read_returns` / `write_returns` | Returns and reverse logistics | | `read_own_subscription_contracts` | Subscription contracts created by your app | | `read_users` | Staff users — **Shopify Plus stores only**, and must be enabled by Shopify Support | Three traps in that table, each of which looks like a bug rather than a permission: 1. **`read_orders` silently truncates to 60 days.** A historical sync returns a partial result with no error. `read_all_orders` fixes it and is approval-gated — request it early, with a written justification. 2. **`read_users` is Plus-only and support-gated.** On a non-Plus store the staff endpoint is simply unavailable, so a connector that maps employees from Shopify will come back empty for most merchants. That is expected, not broken. 3. **Changing the scope set forces merchants to re-approve.** Adding a scope means every installed merchant is prompted again on the next install/update cycle, and until they accept, the new scope is not granted. Scope churn is a merchant-visible event — batch it, do not drip it. ### Protected customer data Any access to customer-related data is gated by a **separate approval**, requested from the Partner Dashboard's API access section, and independent of App Store review: - **Level 1** — customer data *excluding* name, address, phone and email. Public apps must request it. - **Level 2** — data *including* name, address, phone or email fields. Requested field by field, with a heavier data protection review. Protected types include customers, orders, shipping rates, fulfillment data, gift cards, and webhooks and metafields relating to an individual customer. Shopify approves only "the minimum amount required by your app", so a request that asks for everything is a request that gets pushed back. **What unapproved access looks like at runtime is the trap:** requests for unapproved *fields* return **`null`**, and requests for unapproved *types* return **HTTP 200 with an error message in the body**. No 403. A connector will happily sync customers whose every name and email is null and report success. If a customer reports "all the PII is blank", check approval status before debugging the mapper. Level 1 requires minimum-necessary processing, merchant transparency, opt-out support, data-protection agreements, retention limits and encryption in transit and at rest. Level 2 adds encrypted backups, separated test and production environments, a data-loss-prevention strategy, limits and logging on staff access, and a security incident response policy. Custom and admin-created custom apps bypass the review — though for admin-created apps, Level 2 access depends on the store's plan. **The data-protection answers are business commitments, not facts you can derive.** Collect them from the user; do not draft attestations on their behalf. ## 7. Offline vs online access tokens | | Offline | Online | | --- | --- | --- | | Scoped to | **The store** | The staff member who authorized | | Lifetime | Non-expiring, or **expiring: 1 hour** with a refresh token | **24 hours, or until that user logs out** — whichever comes first | | Requested by | The default | `&grant_options[]=per-user` on the authorize URL | | Right for | Background sync, webhooks, scheduled jobs | Acting as a specific staff member in a UI | **A background connector needs offline tokens.** Online tokens die with the merchant's admin session — and logging out revokes *every* online token minted during that web session, not just the current one — so a nightly sync built on them fails as soon as the merchant closes their laptop. Offline is also the default, so the practical rule is: do **not** append `grant_options[]=per-user`. Offline no longer means permanent: - Add **`expiring=1`** to the token request to get an **expiring offline token**: `expires_in` is `3600` (1 hour), returned with a `refresh_token` whose `refresh_token_expires_in` is `7776000` (90 days). - **New public apps created on or after 1 April 2026 must use expiring offline tokens**; **all public apps must, from 1 January 2027.** New public apps already cannot use non-expiring tokens for GraphQL Admin API requests. - Shopify keeps **one current expiring offline token per app and store**. Acquiring a new one retires the older ones, though a retired token stays valid until its own `expires_in` elapses so in-flight requests finish. - If the refresh token's 90 days lapse without use, the merchant has to go through the install flow again. So the client-side question to ask before registering anything: **does the connector store a refresh token per shop and refresh proactively?** If the answer is no or unknown, say so plainly. The app will register fine and then fail after an hour. That is a finding to report, not a step to work around. ## 8. HMAC verification and the mandatory compliance webhooks Both use the **client secret** as the HMAC key, which is why the secret is an operational dependency and not just a login credential. **The OAuth callback.** Shopify redirects to your callback with `code`, `hmac`, `shop`, `state` and `timestamp`. Before exchanging the code you must: 1. Compare `state` to the nonce you stored; reject on mismatch. 2. Remove `hmac` from the query string, sort the remaining parameters alphabetically, compute HMAC-SHA256 with the client secret, and compare in constant time. 3. Validate the shop domain against `^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$`, anchored at both ends. Skipping this is how a connector gets pointed at an attacker-controlled host — the shop domain is attacker-supplied input in a flow where you then send it your credentials. **Webhooks.** The signature arrives in the `X-Shopify-Hmac-SHA256` header: base64 HMAC-SHA256 of the **raw request body**, keyed with the client secret. Reject mismatches. Return `200`; anything outside the 200 range, including 3xx, counts as an error. Shopify allows a 1-second connection timeout and a 5-second total timeout, retries 8 times over 4 hours, and **deletes the subscription after 8 consecutive failures** — a slow endpoint silently unsubscribes itself. **The three mandatory compliance webhooks.** Every app distributed through the App Store must implement them, *whether or not it collects personal data*: | Topic | Meaning | | --- | --- | | `customers/data_request` | A customer has requested the data you hold on them | | `customers/redact` | Delete that customer's data | | `shop/redact` | Delete the shop's data, sent after uninstall | Acknowledge with a 2xx and complete the action **within 30 days**. And a rule that is easy to get backwards: if a mandatory compliance webhook arrives with an **invalid** HMAC header, the app **must return `401 Unauthorized`** — returning 200 to be safe is itself a review failure. Missing or unverified compliance webhooks are one of the standard reasons an app is sent back from review. They are engineering work with a deadline, not a form field. ## 9. API versioning — a standing maintenance commitment - A new API version ships **every three months**, at 17:00 UTC on the first day of the quarter, named for its quarter (`2026-04`). - Each stable version is supported for a **minimum of 12 months**, with at least nine months of overlap between consecutive versions. - The release candidate for the next version appears at the same moment as the stable one, and may contain backwards-incompatible changes. Do not pin production to it. - The version goes **in the request URL**; responses echo `X-Shopify-API-Version`. - If you target a version that is no longer accessible, Shopify **falls forward** to the oldest accessible stable version. This is the quiet failure mode: the integration keeps working, on a version nobody chose, until a shape change breaks a mapper and the cause is a pin that expired months ago. Treat the version pin as a **recurring quarterly chore with a hard annual deadline**, owned by someone. Say so in the handoff; it is a commitment the user is taking on, not a setting. **Direction of travel.** REST Admin API has been legacy since 1 October 2024, and new public apps submitted to the App Store since 1 April 2025 must use the **GraphQL Admin API**. For a *new* public app this is not a preference — REST will not get through review. For an existing app, REST still answers, but new capability lands in GraphQL first. ## 10. Capture the credentials From the Dev Dashboard: the app → **Settings → Credentials**. Capture: - **Client ID** (the OAuth `client_id`) — permanent; it does not change. - **Client secret** (`client_secret`) — also the HMAC key for §8. - The URL templates, with `{shop}` left as a placeholder: authorize `https://{shop}.myshopify.com/admin/oauth/authorize`, token `https://{shop}.myshopify.com/admin/oauth/access_token`, API `https://{shop}.myshopify.com/admin/api/{version}`. - The API version you pinned (§9), and the distribution method (§1). **Rotation.** The secret is rotate-only; assume you cannot re-read it. Rotation is a sequence, not a click: 1. Generate a new secret — **both stay active** until you revoke the old one. 2. Update webhook validation to accept signatures from **both** secrets during the transition. Shopify signs webhooks with your app's **oldest unrevoked** client secret, so webhooks keep arriving signed with the *old* one. 3. Update the app configuration. 4. Replace stored access tokens — every token you hold is still tied to the old secret. A dashboard-issued rotation refresh token, valid for **one hour**, can re-pin non-expiring tokens to the new secret. 5. Only then revoke the old secret. Revoking early cuts merchants off. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 11. App Store review and distribution limits Public distribution means review by Shopify's app approval team. The app moves through **Draft → Submitted → Reviewed → Published**, with a **Paused** state when core requirements are unmet; you get an email listing the required changes. Shopify does not publish a turnaround time — do not quote one. What review reliably asks for, beyond the app working: - **OAuth immediately on install** — the app must authorize before anything else happens, even for a merchant who installed and uninstalled before. - **The mandatory compliance webhooks** (§8) and **protected customer data approval** (§6). - **Supported APIs only** — an app cannot be submitted on an API version within 90 days of deprecation. - **Shopify's billing system** for any charges, if the app charges merchants. - **Listing assets**: app icon (1200×1200), 3–6 screenshots (1600×900), feature media, demo store URL, a ~100 character introduction, ~500 character details, feature list, pricing, and a privacy policy URL. Install limits by distribution: **public is unlimited**; **custom distribution is one store, or the stores of one Plus organization**, via a link you generate, with no review and no Billing API. The legacy admin-created custom app is one store and cannot be created any more (§1). Pricing, listing copy, privacy policy content, data-protection attestations and support commitments are business decisions. Collect them; do not write them. ## Product fact — the Unified.to Shopify connector **As of 2026-09-20, Unified.to's Shopify connector requests the following access scopes**, as the union of what its supported objects declare — confirm the current set with the connector's owner before you declare scopes on a new app, since object coverage changes: ``` read_products write_products read_inventory write_inventory read_locations read_publications write_publications read_customers read_orders write_orders read_all_orders read_returns write_returns read_own_subscription_contracts read_users ``` For the sign-in/identity flow alone it requests only `read_products`. Auth quirks to carry into the app registration, all as of **2026-09-20**: - **The shop domain is collected from the merchant as a subdomain** at connect time — the store handle from their admin URL (`admin.shopify.com/store/{store_id}`) — and substituted into the host of the authorize URL, the token URL **and** the API base. There is no fixed authorize host. A merchant who pastes the wrong handle fails at authorize, not at save. - **Offline tokens, non-expiring.** The connector sends no per-user grant option, so it receives offline tokens — the right choice for background sync. But it also does **not** request expiring tokens and treats the token as having no expiry, which is the **pre-April-2026 model**. A public app registered today falls under §7's mandate. Treat this as the blocking question for the connector's owner: does it need expiring-token plus refresh support before a new public app is registered? - **No PKCE.** The connector does not use PKCE for Shopify; Shopify's authorization code grant for confidential apps does not require it. - **The token is sent as `X-Shopify-Access-Token`**, not as a bearer token, and the token exchange is a JSON POST carrying `client_id`, `client_secret` and `code`. - **Scopes are joined with a space** in the authorize URL, where Shopify's documentation specifies a comma-separated list. Confirm against a live authorize round trip. - **The API version is pinned to `2024-07`** across the connector's REST and GraphQL paths. On the quarterly cadence in §9, that version is past its supported window, which means Shopify is falling forward to the oldest accessible stable version rather than serving what the code asks for. Raise it — it is the most likely cause of unexplained shape drift, and it is a maintenance debt, not a setting. - **A non-OAuth path exists**: the connector also accepts an Admin API access token plus a store ID, the custom-app-style credential. Per §1, legacy admin-created custom apps can no longer be created, so that path now depends on a custom-distribution app. Everything in this section is a snapshot. **Confirm with the connector's owner before acting on it.** ## 12. Verify end-to-end Install on a **development store** created from your organization — not on a store that already has some other build of the app installed. 1. Run the real connect flow: collect the shop domain, build the shop-scoped authorize URL, approve as the merchant. 2. Confirm the callback's **HMAC and `state` both verify**, and that the shop domain passes the regex (§8). 3. Exchange the code. Confirm the token response's `scope` matches what you asked for — Shopify returns what was actually granted. 4. If you requested expiring tokens, confirm `expires_in`, `refresh_token` and `refresh_token_expires_in` are present, then **force a refresh and then a second refresh using the token from the first**. 5. Make one read call against `https://{shop}.myshopify.com/admin/api/{version}/…` with the token in `X-Shopify-Access-Token`, and check the `X-Shopify-API-Version` response header is the version you pinned (§9). 6. If you touch customers, read a customer and confirm **name, email, phone and address are populated, not `null`** (§6). 7. Fire a test webhook and confirm your handler verifies the HMAC and returns 200 inside 5 seconds (§8). 8. Uninstall, then reinstall. The flow must work for a merchant who has installed before. | Symptom | Cause | | --- | --- | | `redirect_uri is not whitelisted` / `invalid_request` at authorize | Redirect URL not registered exactly, or CLI config not deployed (§5) | | Authorize URL 404s or lands on a store login | Wrong shop domain — wrong handle, or a custom domain instead of `{shop}.myshopify.com` (see “The per-shop model”) | | HMAC never validates on the callback | Signing the wrong string: `hmac` not removed, params not sorted, or the wrong secret (§8) | | Every API call 401s after about an hour | Expiring offline token with no refresh implemented (§7) | | Connection dies after a quiet three months | Refresh token's 90 days lapsed unused (§7) | | Token stops working when the merchant logs out | Online token — `grant_options[]=per-user` was sent (§7) | | Customer names and emails all `null`, HTTP 200 | Protected customer data not approved for those fields (§6) | | Orders older than 60 days missing, no error | `read_orders` without `read_all_orders` (§6) | | Staff/user reads empty on most stores | `read_users` is Plus-only and support-gated (§6) | | Webhooks stopped arriving, no config change | 8 consecutive delivery failures deleted the subscription (§8) | | Webhook HMACs fail right after a secret rotation | Shopify still signs with the oldest unrevoked secret (§10) | | Response shapes changed with no deploy | Pinned API version expired; Shopify fell forward (§9) | | Merchants re-prompted for approval unexpectedly | The declared scope set changed (§6) | | Second merchant cannot install | App was created with custom distribution (§1) | ## 13. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. It is also the HMAC key, so a leak is a forgery capability, not just an access one. Values go to the user, for their secret store or console. - If a code change is needed (a redirect host, a scope, expiring-token support, an API version bump), keep it secret-free and say what the human must set out of band. - Close with: app name and owning organization; **distribution method** and that it is permanent; client ID; where the secret was delivered; the authorize/token/API URL templates with `{shop}` left in; the exact scope strings; protected customer data request status and level; offline-token mode and whether refresh is implemented; where the three compliance webhooks are handled; the pinned API version and who owns the quarterly bump; App Store review status; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the distribution choice is not clearly public and the user has not confirmed it (it is irreversible); the client does not implement refresh for expiring offline tokens and a new public app is being registered; the protected customer data request needs data-retention, encryption, staff-access or incident-response commitments; an App Store listing asks for pricing, legal, compliance or volume claims; someone proposes rotating the client secret on a live app without a cutover plan; the answer requires converting a custom app to public (it does not convert — it is a new app and a full re-install); the fix requires Shopify Support to enable something (`read_users`, `read_all_orders`); or the dashboard does not match the **Platform state** section. ## References - Authentication and authorization overview — https://shopify.dev/docs/apps/build/authentication-authorization - Access tokens — https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens - Offline access tokens — https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens - Online access tokens — https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/online-access-tokens - Implement authorization code grant manually — https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant - Authenticate a standalone or API-only app — https://shopify.dev/docs/apps/build/authentication-authorization/authenticate-standalone-apps - Client secrets and rotation — https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets - Session tokens (embedded apps) — https://shopify.dev/docs/apps/build/authentication-authorization/session-tokens - Expiring offline access tokens required for new public apps (1 Apr 2026) — https://shopify.dev/changelog/expiring-offline-access-tokens-required-for-public-apps-april-1-2026 - Expiring offline access tokens required for all public apps (1 Jan 2027) — https://shopify.dev/changelog/expiring-offline-access-tokens-required-for-all-public-apps-as-of-january-1-2027 - Offline access tokens now support expiry and refresh — https://shopify.dev/changelog/offline-access-tokens-now-support-expiry-and-refresh - App distribution — https://shopify.dev/docs/apps/launch/distribution - Select a distribution method — https://shopify.dev/docs/apps/launch/distribution/select-distribution-method - Access scopes — https://shopify.dev/docs/api/usage/access-scopes - Protected customer data — https://shopify.dev/docs/apps/launch/protected-customer-data - Privacy law compliance and mandatory webhooks — https://shopify.dev/docs/apps/build/privacy-law-compliance - Subscribe to webhooks over HTTPS (HMAC verification) — https://shopify.dev/docs/apps/build/webhooks/subscribe/https - Webhook best practices — https://shopify.dev/docs/apps/build/webhooks/best-practices - API versioning — https://shopify.dev/docs/api/usage/versioning - New public apps must use GraphQL (from 1 Apr 2025) — https://shopify.dev/changelog/starting-april-2025-new-public-apps-submitted-to-shopify-app-store-must-use-graphql - GraphQL Admin API reference — https://shopify.dev/docs/api/admin-graphql - REST Admin API reference (legacy) — https://shopify.dev/docs/api/admin-rest - Dev Dashboard — https://shopify.dev/docs/apps/build/dev-dashboard - Migrate from the Partner Dashboard — https://shopify.dev/docs/apps/build/dev-dashboard/migrate-from-partners - Early access: Dev Dashboard (21 May 2025) — https://shopify.dev/changelog/early-access-dev-dashboard - App configuration (`shopify.app.toml`) — https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration - App requirements checklist — https://shopify.dev/docs/apps/launch/app-requirements-checklist - App Store review — https://shopify.dev/docs/apps/launch/app-store-review - App review process and statuses — https://shopify.dev/docs/apps/launch/app-store-review/review-process - Billing — https://shopify.dev/docs/apps/launch/billing - Shopify Partners — https://www.shopify.com/partners - Help Center: about custom apps — https://help.shopify.com/en/manual/apps/app-types/custom-apps ## slack-oauth-app URL: https://docs.unified.to/skills/slack-oauth-app/SKILL.md # Slack OAuth2 App Registration Get a working Slack OAuth2 client — app, redirect URLs, scopes, client ID and secret, public distribution — for a platform that connects Slack workspaces on behalf of many customers. Slack has no separate developer account: any Slack account can create an app at `api.slack.com/apps`. The work is in three decisions that are easy to get wrong and expensive to discover later: **bot token vs user token** (they are different scope lists and different fields in the token response), **redirect URL matching** (Slack compares the `redirect_uri` you send against what is registered, and fails the exchange if the two calls disagree), and **token rotation** (turn it on and every token expires in 12 hours whether or not your client refreshes). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name** and short description | Shown on the install consent screen | | **Slack account + a workspace you can install into** | A free workspace you control, for testing (§2) | | **Redirect URLs** | Every callback host your platform serves (§4) | | **Bot token, user token, or both** | The decisive question — see §5 | | **Scope set** | The exact scopes your connector calls for (§5) | | **Distribution** | Single workspace, public distribution, or a Marketplace listing (§7) | | **Icon, privacy policy URL, support URL** | Needed for public distribution and a listing | ## Quick Start 1. Confirm a **new app** is needed — existing installs are bound to the current client ID (§1). 2. Sign in to Slack and pick or create a **test workspace** you can install into (§2). 3. Create the app at `api.slack.com/apps` — from an app manifest if you want it reproducible (§3). 4. Add **every** redirect URL under OAuth & Permissions (§4). 5. Set **User Token Scopes**, **Bot Token Scopes**, or both — matching what your authorize URL actually sends (§5). 6. Copy client ID, client secret and signing secret from Basic Information (§6). 7. Activate **public distribution** if customers outside your workspace will install it (§7). 8. Verify with a real authorize → `oauth.v2.access` round trip, checking the token field you actually use (§8). 9. Hand the credentials over — never commit them (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The docs moved.** `api.slack.com/*` documentation URLs now 302 to **`docs.slack.dev`**. The app dashboard itself is still at `api.slack.com/apps`. Older write-ups linking `api.slack.com/authentication/...` are not wrong, just redirected. - **Two ways to create an app**: the dashboard (from scratch or from an app manifest) and the **Slack CLI** (`slack create`), which is manifest-first and what Slack's own quickstart now leads with. Either produces the same kind of app; the manifest is the reproducible one. - **The App Directory is the Slack Marketplace.** A Marketplace listing requires the app to be installed on **5 or more active workspaces** (active = used in the past 28 days) plus a security review of your endpoints for TLS and request signing. Public distribution is separate and does not require review. - **Token rotation is opt-in.** With rotation on, access tokens expire in **12 hours (43,200s)** and are refreshed via `oauth.v2.access` with `grant_type=refresh_token`; rotated tokens carry an `xoxe`-prefixed format. If the dashboard does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or create a new one A new app means a new client ID, and **every existing workspace install is bound to the old one** — customers would have to reinstall. Reuse the existing app for: adding a scope (existing installs must re-authorize to gain it), adding a redirect URL, rotating a secret, or diagnosing an install failure. Create a **new** app only when the user explicitly wants one: a separate app for a different product surface, a replacement for a compromised app, or a Gov Slack (`slack-gov.com`) deployment. Say which path you are taking first. ## 2. Account and workspace There is no developer account to register. You need: - **A Slack account** — sign in at `https://slack.com/signin`, then go to `https://api.slack.com/apps`. - **A workspace you can install into.** Create a free one for testing rather than installing a half-configured app into a production workspace. The app is owned by the workspace you pick at creation time, and that choice is not easily changed. - **Permission to install.** Many workspaces restrict app installation to admins, or require admin approval for each app. If installs silently land in an "awaiting approval" state, that is the workspace's app-approval setting, not your app being broken — a customer-side setting you cannot fix from the portal. Hand control back to the user for anything only they can do: sign-in, email or SMS verification, accepting terms, approving the install as a workspace admin. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (§4 redirect URLs, §5 scope list), or hand them a ready-made app manifest (§3) to paste in one shot, then continue once they report back with the client ID. ## 3. Create the app At `https://api.slack.com/apps` → **Create New App**: - **From an app manifest** — paste YAML or JSON describing name, scopes and redirect URLs. This is the option to prefer: it is reviewable, reproducible, and puts the scope list where a diff can catch it. - **From scratch** — then set each field by hand in the dashboard. A minimal manifest for a user-token connector: ```yaml display_information: name: My App oauth_config: redirect_urls: - https://api.example.com/oauth/code scopes: user: # see §5 — user vs bot is the decisive choice - users:read - channels:read settings: token_rotation_enabled: false ``` Where things live in the dashboard afterwards: | What | Where | | --- | --- | | Client ID, client secret, signing secret | **Basic Information → App Credentials** | | Redirect URLs | **OAuth & Permissions → Redirect URLs** | | Bot and user scopes | **OAuth & Permissions → Scopes** | | Public distribution | **Manage Distribution** | | Token rotation toggle | **OAuth & Permissions** (treat as one-way — see §6) | ## 4. Redirect URLs Register **every** callback host your platform serves, under **OAuth & Permissions → Redirect URLs**. For Unified.to these are one per data center — confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Slack's matching rules, which cause most install failures: - **HTTPS required**, no URL anchors. Slack does not accept `http://` here, including for localhost. - The `redirect_uri` you send must **match or be a subdirectory of** a registered URL. - When more than one redirect URL is registered, you must send the **same `redirect_uri` in both** the authorize request and the `oauth.v2.access` exchange, or the exchange fails with `bad_redirect_uri`. A flow that works with one registered URL and breaks after you add a second is this rule, not a Slack outage. ## 5. Scopes — bot token or user token (the decisive choice) Slack issues two different tokens from one install, driven by two different parameters: | Parameter | Scope list in the app | Token in the response | Prefix | Acts as | | --- | --- | --- | --- | --- | | `scope` | **Bot Token Scopes** | `access_token` (top level) | `xoxb-` | The app itself | | `user_scope` | **User Token Scopes** | `authed_user.access_token` | `xoxp-` | The installing user | Configure the list that matches what your authorize URL sends. Configuring bot scopes for a connector that reads `authed_user.access_token` produces an install that "succeeds" and then has no usable token — a failure that surfaces on the first API call, not at install. **Unified.to's Slack connector sends `user_scope` only** — it reads the user token and never uses a bot token. So its app needs **User Token Scopes**, and its scope set as of 2026-09-20 is: ``` channels:history channels:read chat:write files:read files:write groups:history groups:read im:history im:read im:write im:write.topic mpim:history mpim:read mpim:write mpim:write.topic users:read users:read.email users.profile:read ``` Sign-in-only (identity) installs use `email`, `openid` and `profile` against Slack's OpenID Connect authorize URL instead. That list drifts as object coverage grows — confirm the current set with the connector's owner before you submit. Mechanics worth knowing: - **Scopes are comma-delimited** in the authorize URL, not space-delimited as in most OAuth2 implementations. - Authorize URL: `https://slack.com/oauth/v2/authorize` (Gov Slack: `https://slack-gov.com/oauth/v2/authorize`). - Adding a scope later does **not** upgrade existing installs — each workspace must re-authorize before the new scope works. Plan scope additions as a customer-visible re-consent, not a silent config change. - Request only what the connector calls. Over-broad scope sets are a standard Marketplace review rejection, and reviewers check data access against what the app demonstrably needs. ## 6. Capture the credentials From **Basic Information → App Credentials**: - **Client ID** and **client secret** — both re-readable and regenerable, unlike most portals - **Signing secret** — verifies inbound request signatures; treat it as a credential even though it is not OAuth - **App ID** and the workspace that owns the app Token exchange is `POST https://slack.com/api/oauth.v2.access` with `code`, `client_id`, `client_secret` and the same `redirect_uri` as the authorize call. The response carries the bot token at `access_token`, the user token at `authed_user.access_token`, the granted `scope` strings, and `team.id`. Two Slack-specific traps: - **Slack returns HTTP 200 on failure.** Errors arrive as `{"ok": false, "error": "..."}` in the body. A client that checks only the status code will store an empty token and fail later with no useful log line. - **Token rotation is a one-way door in practice.** Turning it on makes every access token expire in 12 hours; if the client does not implement the refresh exchange, every connection breaks within a day. Confirm the client refreshes *before* enabling it, and never enable it to "improve security" mid-run. Regenerating the client secret invalidates the old one immediately — existing *installs* keep working (they hold tokens), but every future code exchange and refresh fails until the new secret is deployed. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. ## 7. Distribution: public install vs Marketplace listing These are two different things, and only one needs a review: - **Public distribution** — **Manage Distribution → Share Your App with Other Workspaces**. Complete the dashboard's checklist (the recurring blocker is "remove hard-coded information": no workspace-specific IDs, tokens or URLs baked into the app), then **Activate Public Distribution**. You get a sharable install URL and an *Add to Slack* button. Any workspace can now install. No Slack review. - **Slack Marketplace listing** — optional, and a real review: the app must already be installed on **5+ active workspaces**, and Slack tests your endpoints for TLS and request-signature verification, reviews the listing content, installs the app, and checks that data access matches what the app needs. For a multi-tenant connector, public distribution is usually the requirement; a listing is a go-to-market decision. **Stop and ask** before answering anything you would have to invent on a listing submission: security questionnaires, compliance certifications, data-retention periods, subprocessors, support SLAs, customer counts, or demo videos. ## 8. Verify end-to-end A half-working registration surfaces later as a customer who cannot connect. Prove it: 1. Install the app into your **test workspace** through the real flow — the sharable URL or your platform's connect button, not the dashboard's "Install to Workspace" shortcut, which skips redirect-URL matching. 2. Complete the `oauth.v2.access` exchange and check the field you actually consume: `authed_user.access_token` for a user-token connector, top-level `access_token` for a bot-token one. 3. Confirm the granted `scope` string contains what you asked for, then make one real API call. | Symptom | Cause | | --- | --- | | `bad_redirect_uri` | `redirect_uri` missing or different between the authorize and exchange calls (§4) | | `invalid_scope_requested` | Scope not configured on the app, or bot scope sent as `user_scope` | | Install succeeds, no usable token | Configured bot scopes but the client reads the user token, or vice versa (§5) | | Everything dies ~12h after install | Token rotation enabled without a refresh implementation (§6) | | Install stuck pending | Workspace requires admin approval — customer-side (§2) | | `missing_scope` on one API call | Scope added after install; that workspace must re-authorize (§5) | ## 9. Hand off — never commit the secret - **Do not** write the client secret or signing secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a new callback host, a scope list), keep it secret-free and say what the human must set out of band. - Close with: app name and owning workspace; client ID; where the secret was delivered; the exact scope strings and whether they are bot or user scopes; distribution state; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: installing into a workspace you were not told to touch; enabling token rotation on an app with live installs; regenerating a secret that live connections depend on; removing a redirect URL; a Marketplace submission asks for compliance, legal or volume claims; or the dashboard does not match the **Platform state** section above. ## References - Installing with OAuth — https://docs.slack.dev/authentication/installing-with-oauth - Token types — https://docs.slack.dev/authentication/token-types - Using token rotation — https://docs.slack.dev/authentication/using-token-rotation/ - Scope reference — https://docs.slack.dev/reference/scopes - `oauth.v2.access` — https://docs.slack.dev/reference/methods/oauth.v2.access - App manifests — https://docs.slack.dev/app-manifests - App distribution — https://docs.slack.dev/distribution - Marketplace guidelines and requirements — https://docs.slack.dev/slack-marketplace/slack-marketplace-app-guidelines-and-requirements/ - App dashboard — https://api.slack.com/apps ## smartrecruiters-oauth-app URL: https://docs.unified.to/skills/smartrecruiters-oauth-app/SKILL.md # SmartRecruiters API Credentials **Read this first: SmartRecruiters has no self-serve developer portal where you register a redirect-based OAuth app and walk away with a client ID and secret.** There is no "create app" button for a multi-tenant integration. The registration page says it plainly: to build applications for SmartRecruiters customers and offer them in the Marketplace "you need to have a partner account," and "all integration requests should be submitted through this channel" — the Partner Hub. Credentials are issued by hand, by the SmartRecruiters Partner & API Team, after they review your requested scopes. **And the flow most people arrive here looking for is closed to newcomers.** SmartRecruiters' Authorization Code page carries a sunset notice: *"As of January 30, 2026, the Authorization Code flow is deprecated. We are no longer onboarding new partners with this method. All new integrations must use the OAuth 2.0 General Partner Integration."* If a runbook, a blog post or an existing connector tells you to send a customer to `https://www.smartrecruiters.com/identity/oauth/allow`, that is the deprecated path (§ Platform state). What *is* self-serve is different, and it is what almost every SmartRecruiters integration actually runs on today: **each customer generates their own credential inside their own SmartRecruiters account**, in Credential Manager, as an Administrator. Two kinds: a **SmartToken API key** (32 characters, no scopes, no expiry, full access to the company) or an **OAuth Client Credentials** pair (scoped, no expiry, no consent screen, no refresh token). A multi-tenant platform can run entirely on these — you ask each customer for a credential and store it per connection. That makes onboarding an **admin runbook**, not an OAuth button, and you should price that into the plan before promising anyone a one-click connect (§1, §4). Three more things that cost real time: the **General Partner Integration is not a redirect OAuth flow at all** — you register a `consentUrl`, not a `redirect_uri`, and the "authorization" is a credential-exchange API call (§4 Path C, §5); **scopes are necessary but not sufficient** because every OAuth credential also carries a SmartRecruiters System role and optional Access Group that cap it independently (§6); and the **public Posting API needs no authentication at all**, which is why a test that "works" against postings tells you nothing about whether your credential can read a single candidate (§7). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Credential model** | Per-customer SmartToken, per-customer OAuth client credentials, General Partner Integration, or legacy Authorization Code? (§1) | | **Partner status** | Do you have a SmartRecruiters partner account and an Application ID, or are you collecting per-customer credentials? (§3) | | **Which API surface** | Customer API, Marketplace/Assessment partner APIs, Posting API, Job Board API — they do not share credentials (§1) | | **Scope set** | The exact scope strings the connector calls, per object and direction (§6) | | **Integration name**, logo, short + full description | Shown to the customer on the Apps & Integrations page; the Partner & API Team asks for all three (§4) | | **consentUrl** and **consentDisplayMode** | `REDIRECT` or `POPUP`; required for General Partner Integration, and neither is a `redirect_uri` (§5) | | **oauthTokenUrl / callbackUrl** | Only if the integration is bi-directional or you want disable notifications (§4) | | **Redirect URI(s)** | Every callback host, final, up front — but only if you are an existing Authorization Code partner (§5) | | **Visibility** | Public to all customers, or private to named ones — you confirm this with the Partner & API Team (§4) | | **A SmartRecruiters tenant to test against** | SmartRecruiters publishes no self-serve developer tenant (§3) | ## Quick Start 1. Establish which **credential model** this actually needs (§1). Nearly every wrong turn here starts at this step. 2. Read the **Platform state** section before quoting anyone a flow — the Authorization Code path is deprecated. 3. Check whether existing credentials are worth reusing; re-issuing partner credentials orphans every connection (§2). 4. If you need the General Partner Integration: apply through the Partner Hub. This gates everything else (§3). 5. Obtain the credential — the customer's Credential Manager, or the Partner & API Team (§4). 6. Register `consentUrl` / `consentDisplayMode` (partner) or every callback host (legacy auth code) in that same request; you cannot self-serve either later (§5). 7. Pin down scopes **and** the System role on the credential — both are required, neither substitutes (§6). 8. Check rate limits and the concurrency limiter before you promise a sync cadence (§7). 9. Verify against a **second** company, with a real read of a scoped resource — not the Posting API (§8). 10. Hand the secret to a human, never to source control (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The Authorization Code flow is deprecated.** SmartRecruiters' own page, last updated 2026-01-30, states: *"As of January 30, 2026, the Authorization Code flow is deprecated. We are no longer onboarding new partners with this method. All new integrations must use the OAuth 2.0 General Partner Integration. We also strongly encourage existing partners to migrate."* The page is still published and still documents the full flow, and the Authorization Code page **no longer appears in the documentation index** — it is reachable only by direct URL. SmartRecruiters has published **no sunset date** for it; their deprecation policy promises at least **10 months** of lead time before any public endpoint is sunset. Treat existing Authorization Code connections as supported but on borrowed time, and do not plan a new integration on it. - **The General Partner Integration is the current multi-tenant path, and it is Client Credentials, not a redirect flow.** Two credential levels: **Partner Level (Master Credentials)** issued once to you, and **Customer Level** credentials that SmartRecruiters mints per company. There is no consent screen you host at a `redirect_uri`; SmartRecruiters sends the user to a **`consentUrl`** you register, with `companyId` and (in `REDIRECT` mode) a `redirect` query parameter. - **Both partner paths are gated.** Master Credentials and an **Application ID** come from the Partner & API Team, after they review your requested scopes against the Principle of Least Privilege, and they are shared "in an encrypted way." Registration requests go through the Partner Hub at `https://www.smartrecruiters.com/partners/`, which currently 301-redirects to `partnerhub.smartrecruiters.com`. `partners.smartrecruiters.com` is a separate, older Partner Portal tied to the legacy **Partner API Key**. - **Customers are fully self-serve, in their own account.** Credential Manager lives at `https://www.smartrecruiters.com/settings/administration/app-management/custom-applications` (login required). **New Credential → API Key** gives a 32-character SmartToken; **New Credential → OAuth client ID** gives a scoped client ID + secret. Both are Administrator actions, both are shown **once**, and **neither expires** — they are revoked, not rotated on a clock. - **Token endpoints, all paths:** `https://api.smartrecruiters.com/identity/oauth/token`, `POST`, form-encoded, with either `client_secret_post` (credentials in the body) or `client_secret_basic` (`Basic base64(clientID:clientSecret)`). API base is `https://api.smartrecruiters.com/`. Access tokens are documented at **`expires_in: 1799`** — under 30 minutes — on every grant. - **Refresh tokens exist only on the Authorization Code flow**, live **28 days**, and are **single-use and rotating**. Client Credentials returns no refresh token at all; you simply mint a new access token. - **PKCE is not documented on any SmartRecruiters flow.** Do not assume it is supported or required. - **`developers.smartrecruiters.com` is the live documentation site.** `dev.smartrecruiters.com` still answers but 302-redirects there; treat any `dev.` link as an alias, not a second source. Appending `.md` to any docs URL returns the markdown source, and `https://developers.smartrecruiters.com/llms.txt` is the full page index. - **SmartRecruiters publishes no self-serve developer sandbox.** The word "sandbox" does not appear in the developer documentation index; a **sandbox environment does exist**, because SmartRecruiters publishes its outbound IP addresses separately for production and sandbox. Getting into one is a conversation with SmartRecruiters or with a customer, not a signup form (§3). If Credential Manager or the developer docs do not look like this, stop and report what you actually see. ## 1. Which credential, and who issues it A reader arriving here is usually holding the wrong one. SmartRecruiters runs several API surfaces and the credentials do not interchange: | Credential | Who issues it | How it is sent | Reach | | --- | --- | --- | --- | | **API Key (SmartToken)** | The customer, self-serve, **Administrator only**, in Credential Manager | `X-SmartToken: ` | **All company data.** No scopes, no expiry. SmartRecruiters recommends it "for testing purposes only" | | **OAuth 2.0 Client Credentials** | The customer, self-serve, in Credential Manager — or SmartRecruiters, per company, under the General Partner Integration | Exchange at `/identity/oauth/token`, then `Authorization: Bearer ` | Limited by the credential's **scopes**, its **System role**, and its optional **Access Group**. No refresh token | | **General Partner Integration** | SmartRecruiters Partner & API Team: an **Application ID** + **Master Credentials**; customer-level pairs are then minted per company | Master token calls the partner integrations endpoint; per-company pairs then behave as Client Credentials | The scopes approved for your application, per company | | **OAuth 2.0 Authorization Code** | SmartRecruiters, to partners — **closed to new partners since 2026-01-30** | Authorize at `www.smartrecruiters.com/identity/oauth/allow`, token at `api.smartrecruiters.com/identity/oauth/token` | Acts as the **authorizing user** — capped by that person's own SmartRecruiters permissions | | **Partner API Key** | Auto-generated on Partner Portal signup (`partners.smartrecruiters.com`) | `X-SmartToken: <64-char key>` or an `apiKey` URL parameter | Legacy Marketplace APIs only. SmartRecruiters: "There are no new APIs planned to support this method" | Four traps follow directly from this table: - **`X-SmartToken` carries two different, non-interchangeable credentials.** The customer API Key is 32 characters; the legacy Partner API Key is 64. SmartRecruiters calls this out by name: *"please don't be confused, these are not the same tokens and cannot be used interchangeably."* Ask for the credential by its full name and note its length. - **"Partner" means two unrelated things.** The General Partner Integration (current, Client Credentials, an Application ID) and the Partner Portal's Partner API Key (legacy, Marketplace APIs) share a word and nothing else. - **A customer's own OAuth client credentials are a perfectly legitimate multi-tenant strategy**, and the one available today without a partnership. The cost is onboarding: an Administrator at each customer must create the credential, tick the right scopes, assign a System role, and paste a secret they can never see again. Write that runbook before you sell the connector, not after. - **Marketplace/Assessment partner APIs are a separate integration with separate credentials.** They are not a mode of the Customer API and cannot call it. Do not try to make one registration cover both (§3). > **Product fact — dated.** As of **2026-09-20**, this platform ships **two separate SmartRecruiters connectors**, > and the distinction matters at registration time. > > The **ATS/HRIS connector** calls the Customer API at `https://api.smartrecruiters.com/` and supports **three** > credential paths. (a) A **company API-key mode**: it asks the customer for one value, sends it verbatim in the > `X-SmartToken` header, and points them at SmartRecruiters' own API-key documentation page to generate it. > (b) An **OAuth 2.0 Authorization Code mode** against `https://www.smartrecruiters.com/identity/oauth/allow` and > `https://api.smartrecruiters.com/identity/oauth/token` — the deprecated flow (§ Platform state). Its authorize > call sends only `redirect_uri`, `client_id`, a **space-delimited** `scope` and `state`; it sends **no > `response_type` and no PKCE parameters**. The exchange is a form-encoded POST carrying `grant_type`, `code`, > `client_id` and `client_secret` **in the body** (`client_secret_post`, not Basic), and refresh is the same shape > with `refresh_token`. It records the access-token lifetime as **1799 seconds** and sends `Authorization: Bearer`. > (c) A **General Partner Integration path**, exposed as its own endpoint rather than the normal connect flow: it > mints a master token by `client_credentials` from stored partner credentials, POSTs an application ID plus the > customer's `companyId` to `https://api.smartrecruiters.com/apps-integrations/partner-api/integrations`, receives > the per-company `clientId`/`clientSecret`, stores them, and — since Client Credentials issues no refresh token — > re-mints the access token from that stored pair whenever it expires, keyed on the SmartRecruiters company ID. > > Its requested scope set is grouped per unified object and direction and is drawn from `candidates_read`, > `candidates_create`, `candidates_manage`, `candidate_status_read`, `candidates_offers_read`, > `candidates_offers_write`, `jobs_read`, `jobs_manage`, `candidate_applications_manage`, `interviews_read`, > `interviews_write`, `interview_types_read`, `users_read`, `users_manage`, `reviews_read`, `reviews_write`, > `reviews_delete`, `assessment_orders_read`, `audit_events_read`, `messages_write`, `messages_manage` and > `webhooks_manage`. Two things to check against §6 before requesting them: **`candidates_offers_write` appears in > neither SmartRecruiters' Access Scopes catalogue nor its Permission Reference**, and **`user_me_read` is absent > from the set** even though the connector reads the current-user endpoint immediately after authorization — the > endpoint SmartRecruiters documents as requiring exactly that scope. List paging uses SmartRecruiters' cursor > (`pageId` / `nextPageId`), a maximum page of 100, `updatedAfter` for incremental sync and `q` for search. > > The **Assessment connector** is the *partner* side of the Marketplace Assessment API and **cannot call the normal > SmartRecruiters API**. It holds two credential pairs per connection — one SmartRecruiters-issued pair it uses to > PATCH results back with the `assessment_result_manage` scope, and one pair it issues to SmartRecruiters so > SmartRecruiters can call it. Registering it is a different track with a different team ask (§3). > > **Confirm all of this with the connector's owner before acting on it** — connector configuration changes > independently of this skill. ## 2. Reuse the existing credentials, or request new ones Re-issued **Master Credentials or a new Application ID** mean every customer-level pair minted under the old application is orphaned, and every customer re-runs the consent and exchange. Reuse what exists for: adding or changing a scope, updating `consentUrl`, rotating a compromised master secret, or diagnosing a failure. All of those are handled on the existing application by the Partner & API Team (§4). Request a **new** application only when the user explicitly wants one: a separate product, a replacement for a compromised application, or a deliberate move off the deprecated Authorization Code flow — which is a migration project, not a config change, because the credential model underneath is different. For **per-customer** credentials the calculus is much gentler: each customer's key or pair is independent, so revoking one affects exactly one company. There is no rotation clock — SmartRecruiters states plainly that neither an API Key nor a Client Credential has an expiration date. Rotation is a deliberate revoke-and-regenerate, and it is **immediate and total** for that customer. Say which path you are taking before you start. ## 3. Accounts: the partner programme, and a tenant to test against **The partner programme.** SmartRecruiters routes every integration request through the **Partner Hub** at `https://www.smartrecruiters.com/partners/` (which redirects to `partnerhub.smartrecruiters.com`), for new and existing partners alike. The alternative channel SmartRecruiters names for Marketplace distribution is its **Partner Success Team** at `partners@smartrecruiters.com`; the same address handles requests to add a standard candidate **source** for a productised integration, which is worth batching into the same conversation if you create candidates. What the run needs to know: - **SmartRecruiters publishes no partner tiers, no mutual-customer minimum, no review timeline, no cost and no SLA** on its developer documentation. Do not invent any of them, and do not carry over numbers from another ATS vendor's programme. Plan in months and say that you are estimating. - **The scope review is real and is a gate.** The Partner & API Team will either approve your requested scopes or "ask you to adjust requested access scopes based on the Principle of Least Privilege." Arrive with a scope-by-capability justification, not a wish list (§6). - **Visibility is decided with them, not by you.** An application on the General Partner Integration appears on the customer's **Apps & Integrations** page and "can be made available to all customers or, if you're building a private integration, only to selected ones. You should confirm your application's visibility with the SmartRecruiters Partner & API Team." That confirmation is the Marketplace-listing decision; treat it as an explicit agenda item rather than something that happens automatically when the app works. - **The Assessment/Marketplace partner track is separate.** It has its own partner app specification, its own configuration registration, and a bilateral credential exchange where SmartRecruiters calls *you*. If the ask covers both an ATS integration and an assessment integration, that is **two registrations**. - The agreement is a **business and legal decision**. Do not fill in revenue, volume, security or compliance claims on the user's behalf — collect the questions and hand them back. **A tenant to test against.** There is no documented self-serve developer tenant, and "sandbox" appears nowhere in the developer documentation index — although a sandbox environment demonstrably exists, since SmartRecruiters publishes separate outbound IP ranges for production and sandbox. In practice that leaves three options, in descending order of safety: a sandbox obtained through the partner relationship; a customer's own non-production company; or a real production company, which means **testing writes against live hiring data**. Say that out loud before anyone runs a create. Note also that SmartRecruiters' own "make a test API call" walkthrough has you create a **real user** in the company — do not run it against a customer's production tenant as a smoke test. Anything requiring a human — Partner Hub registration, email verification, being made an Administrator, requesting a sandbox — hand back rather than looping. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the scope strings from §6, the `consentUrl` or the redirect URIs from §5 — or a ready-to-send request body for the Partner & API Team, then continue once they report back with the Application ID or the credential. ## 4. Obtaining the credentials ### Path A — Customer-generated API Key (SmartToken), self-serve, available today The fastest route to a working call, and the one to be most careful with: 1. The customer must have **Administrator access**. 2. Credential Manager → **New Credential** → **API Key**. 3. Give it a **Credential name** and **Description** that say which integration it is for — an admin will later have to decide whether revoking it breaks anything. 4. **Generate.** The 32-character key appears in a pop-up **once**: *"admins will not be able to retrieve or see it after the initial pop-up is closed."* 5. Send it as `X-SmartToken: `. **It has no scopes and no expiry, and it grants full access to the organization's resources.** SmartRecruiters recommends it for testing, for a partner trying the API out, or for a customer building their own solution — not for a third-party platform holding many customers' keys. If you use it in production anyway, that is a deliberate security decision the customer should make knowingly, and the honest framing is "you are handing us an unscoped master key to your ATS." Revocation is a **Revoke** button in Credential Manager and takes effect immediately. ### Path B — Customer-generated OAuth Client Credentials, self-serve, available today The right self-serve default for a multi-tenant platform: 1. The customer must have **Administrator access**. 2. Credential Manager → **New Credential** → **OAuth client ID**. 3. Define **Credential Name**, **Description**, and **Scope** (§6). There is no limit on how many scopes you select. 4. Assign a **System role** — required, either a predefined role or a custom one. An **Access Group** is optional. SmartRecruiters warns directly: *"even if the required OAuth scopes are selected, access to certain functionalities may still be restricted unless an appropriate System role is assigned."* This is §6's rule, and it is where most "my scopes are perfect and I still get 403" tickets end. 5. **Generate.** The **Client ID** and **Client secret** appear together in a pop-up **once**. 6. Exchange at `POST https://api.smartrecruiters.com/identity/oauth/token`, `Content-Type: application/x-www-form-urlencoded`, with `grant_type=client_credentials` plus either `client_id` and `client_secret` in the body, or `Authorization: Basic base64(clientID:clientSecret)`. 7. Call the API with `Authorization: Bearer `. `expires_in` is **1799**; there is **no refresh token** — when it expires, repeat step 6 with the same credential. The pair does not expire. Revoking it in Credential Manager cuts access immediately. ### Path C — General Partner Integration, for a multi-tenant connector (partner-gated) The current path for an application distributed to many companies. It is Client Credentials end to end; the only browser step is a consent page **you** host. **Step 1 — get Master Credentials.** Give the Partner & API Team, via the Partner Hub: - **scopes** — the exact strings (§6); they review these before approving. - **consentUrl** — the URL SmartRecruiters sends the user to during setup. SmartRecruiters appends `companyId` and, in `REDIRECT` mode, `redirect`. - **consentDisplayMode** — `REDIRECT` (the user leaves SmartRecruiters) or `POPUP` (an iFrame widget stays on the SmartRecruiters page). SmartRecruiters' partner guidance specifies the widget as **600 px wide by 45% of screen height**, with a scrollbar beyond that — design the consent page to fit before you pick `POPUP`. - **oauthTokenUrl** (optional) — your token endpoint, so SmartRecruiters can call *your* API on the customer's behalf. - **callbackUrl** (optional) — receives `POST {"eventType": "INTEGRATION_DISABLED"}` when a customer turns the integration off. **It is never called unless `oauthTokenUrl` is also defined.** Register both or neither; half of this pair is silence. - **logo** and **description** — displayed on the customer's Apps & Integrations page. They return an **Application ID** (a UUID) and a **Master `clientId`/`clientSecret`**, shared encrypted. **Step 2 — mint a master token.** ``` POST https://api.smartrecruiters.com/identity/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials&client_id=&client_secret= ``` **Step 3 — exchange for customer-level credentials.** When a customer enables your app, SmartRecruiters `GET`s your `consentUrl?companyId=...&redirect=...`. After they authenticate on your side, you call: ``` POST https://api.smartrecruiters.com/apps-integrations/partner-api/integrations Authorization: Bearer Content-Type: application/json { "appId": "", "companyId": "", "clientId": "", "clientSecret": "" } ``` The published OpenAPI specification for this endpoint declares the required scope as **`partner_integrations_manage`** — a partner-level scope that is not in the customer scope catalogue. If your master token cannot call this endpoint, that scope is the first thing to raise with the Partner & API Team. `appId` and `companyId` are required; `clientId` and `clientSecret` are yours to generate, are required for bi-directional integrations, and **must be unique across all of your integrations**. SmartRecruiters returns `200` with `{ clientId, clientSecret, integrationId }` — a unique pair **per company**. **Step 4 — finish the transition.** In `REDIRECT` mode, send the user back using the `redirect` parameter; in `POPUP` mode SmartRecruiters closes the widget and refreshes. From then on, treat the per-company pair exactly as Path B: client-credentials exchange, `expires_in` 1799, no refresh token, re-mint on expiry. **Store `integrationId` and `companyId` alongside the pair** — `companyId` is the tenant key, and a disable notification identifies the customer by the `clientId` associated with the token. ### Path D — Authorization Code (existing partners only; deprecated) Documented here so you can *recognise* and *maintain* it, not so you can choose it. New partners are not onboarded. - **Authorize:** `https://www.smartrecruiters.com/identity/oauth/allow` with `client_id`, `redirect_uri`, `scope` (space-separated, `%20`-encoded) and optional `state`. SmartRecruiters' own example sends **no `response_type`**. Treat `state` as mandatory regardless — it is your only CSRF defence. An **empty `scope=` is invalid**: omit the parameter or send a non-empty list. If you omit it, the **default scopes fixed at app registration** are used. - **Callback:** `?code=...`, or `?error=access_denied&error_description=...` on decline. **The code expires in 30 seconds.** Not 30 minutes — thirty seconds. Any human step, queue hop or retry between callback and exchange kills it, and this is the single most common failure on this flow. - **Exchange:** `POST https://api.smartrecruiters.com/identity/oauth/token`, `Content-Type: application/x-www-form-urlencoded`, either `client_secret_post` (`client_id`, `client_secret`, `code`, `grant_type=authorization_code` in the body) or `client_secret_basic`. Note that SmartRecruiters' published examples set a form content type but show a **JSON-shaped `-d` body** — the two examples disagree with each other. Send genuine form encoding, and if you are debugging someone else's client, check which shape it actually puts on the wire before blaming the credentials. - **Response:** `{ token_type: "bearer", access_token, expires_in: 1799, refresh_token }`. - **Refresh:** same endpoint, `grant_type=refresh_token`. **The refresh token lives 28 days and can be used only once** — every refresh returns a new one. Store the new value from every response, or the second refresh fails. A connection idle for 29 days is dead and the user must re-authorize from scratch, so refresh on a schedule rather than lazily. - **The token acts as the authorizing user**, so that person's own SmartRecruiters permissions cap it (§6). ## 5. Redirect URLs — and the consentUrl that replaces them **These are two different things and the difference is the whole point of §4 Path C.** For the **General Partner Integration** there is no `redirect_uri`. You register a **`consentUrl`**; SmartRecruiters appends `companyId` and, in `REDIRECT` mode, a `redirect` URL to send the user back with. If a multi-region platform routes customers to per-region hosts, that is a conversation about what `consentUrl` should be — SmartRecruiters documents a single value, so a platform with several regions needs either one entry host that routes internally, or a separate application per region. **Raise this with the Partner & API Team before registration**, because it shapes the whole setup. For the **legacy Authorization Code flow**, register every callback host up front — you do not edit them in a console. For this platform these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` SmartRecruiters does not publish its `redirect_uri` matching rules. Assume exact string matching with no trailing-slash forgiveness, and register the whole set on day one — discovering a missing region later is an email and a wait, not a config change. Neither the SmartToken nor the customer Client Credentials path has a redirect at all. ## 6. Scopes — and the two permission rules that outrank them Scopes are lowercase, underscore-separated, and **space-delimited** on the authorize URL (`%20` when encoded) or selected as checkboxes in Credential Manager. SmartRecruiters publishes two lists and **they do not agree**, so check both: the **Access Scopes** catalogue (the user-facing list, with the prompt each scope shows the customer) and the **Permission Reference** (endpoint-by-endpoint, the authoritative one when you are asking "what does this call need?"). `reviews_delete`, for example, is in the Permission Reference but not the catalogue. Mapping the scopes to the surfaces a hiring connector actually touches: | Surface | Read | Write | | --- | --- | --- | | **Candidates** | `candidates_read`, `candidate_status_read` | `candidates_create`, `candidates_manage` | | **Applications** (candidate-side apply) | `job_applications_read` | `job_applications_manage`, `candidate_applications_manage` | | **Jobs** | `jobs_read` | `jobs_manage` | | **Job publications / postings** | `internal_postings_read` (internal postings only — §7) | `jobs_publications_manage` | | **Offers** | `candidates_offers_read` | *(no documented write scope — see below)* | | **Interviews** | `interviews_read`, `interview_types_read`, `interview_templates_read`, `self_schedules_read`, `schedule_preferences_read` | `interviews_write`, `interview_types_write`, `interview_templates_manage`, `self_schedules_manage` | | **Reviews / scorecards** | `reviews_read` | `reviews_write`, `reviews_delete` | | **Assessments** | `assessment_orders_read` | *(results are written on the separate partner track — §1)* | | **Users** | `users_read`, `user_me_read` | `users_manage` | | **Company / configuration** | `company_read`, `configuration_read` | `configuration_manage` | | **Audit** | `audit_events_read` | — | | **Webhooks** | `webhooks_read` | `webhooks_write`, `webhooks_delete`, or `webhooks_manage` for all three | | **Messages** | `messages_read` | `messages_write`, `messages_manage` | | **Reporting** | `reporting_read` | `reporting_write` | Two catalogue details that trip integrators specifically: - **There is no documented offer-write scope.** Both SmartRecruiters lists contain `candidates_offers_read` and nothing else for offers; the Permission Reference describes the Offer API as letting developers *retrieve* offers. A connector requesting a write variant is requesting a string SmartRecruiters does not publish. If you need to write offers, ask the Partner & API Team whether the capability exists at all before treating it as a registration detail — it may be a product answer, not a scope you forgot. - **`user_me_read` is its own scope.** Reading the current user after authorization — the obvious way to identify whose connection this is — requires `user_me_read`, and it is *not* implied by `users_read`. A connect flow that calls it without asking for it will authorize successfully and then fail on the identity lookup. **Rule one: the System role outranks the scopes.** Every OAuth credential is "subject to the same authorization rules as all users on the SmartRecruiters platform." A System role is **required**, and SmartRecruiters states that access may still be restricted "even if the required OAuth scopes are selected." Several scopes carry an explicit role note in the catalogue: `users_read`, `users_manage`, `configuration_read` and `configuration_manage` all say **Requires an Admin user role**; the Audit API reference says **the `ADMINISTRATOR` role is required**; and the webhook scopes require an Admin role *or* the **Manage webhooks** toggle enabled on a custom role. Tell customers which role to assign at the same moment you tell them which scopes to tick, or you will get a credential that authorizes cleanly and 403s on users, config, audit and webhooks. **Rule two: the Access Group silently narrows the data.** An Access Group is optional on a credential and restricts which jobs and candidates it can see. That produces a **200 with a short list**, not an error — the same partial-data failure shape as a wrong permission anywhere else. And webhooks make it worse in an instructive way: SmartRecruiters states that the webhook system **does not factor in access groups when sending notifications**, and that notifications carry only resource IDs. So a credential with an Access Group will be told about a candidate it then cannot fetch. Expect 403s on notification follow-up reads and treat them as configuration, not as a bug. **On the Authorization Code flow a third cap applies:** the token acts as the authorizing *individual*, and returns only "the data the individual user has access to." Whole-organization access needs an **Admin** authorizer. Client Credentials is the opposite — it acts at organizational level, bounded by the role on the credential. Ask for the narrow set. Adding a scope later means a customer regenerates a credential (self-serve paths) or another round with the Partner & API Team (partner paths). SmartRecruiters' documented failure mode for calling outside your granted scopes is an **`OAuthPermissionsException`**. ## 7. Rate limits, the Posting API trap, IPs, listing - **Two limiters, both per credential.** A **rate limiter** of **10 requests per second** on most endpoints, and a **concurrency limiter** of **8 concurrent requests**. SmartRecruiters is explicit that "an API user is defined by the credential it uses" — so per-customer credentials each get their own budget, while a shared credential shares one. - **The exceptions are the ones that hurt.** `GET /candidates` allows **1 concurrent request**. A parallel candidate backfill is therefore not just throttled, it is serialised by design, and this is usually the binding constraint on initial sync time. `POST` and `DELETE /jobs/{id}/publication` are capped at **2 requests per second**; so is `GET /offers/{id}/documents/{id}` per the rate-limiting page. - **Read the headers, do not hardcode.** `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Concurrent-Limit` and `X-RateLimit-Concurrent-Remaining` are returned on **every** response, violation or not. `Retry-After` is documented only for the Analytics response-size policy, so do not depend on it generally — back off exponentially from the headers. Over-limit is **HTTP 429**. - **Set client timeouts to at least 128 seconds.** SmartRecruiters asks for this explicitly and promises a response (success or error) within it. A 30-second default HTTP timeout will manufacture failures that look like outages. - **For bulk analysis, use the Reporting API**, which streams and is designed for volume, rather than paginating the Customer API — SmartRecruiters recommends this directly. - **The Posting API trap.** Published job postings are readable with **no authentication at all**. That means a "connection test" against postings proves nothing about your credential. Note the nuance: SmartRecruiters documents the *public* Posting API as requiring no auth, while **internal** postings need an API key or an OAuth token carrying `internal_postings_read` — and warns that for that API, "the system role and access group configuration assigned to the credentials are not enforced," so anyone with a valid credential can read all internal postings. Test authentication against a scoped resource such as candidates or users instead. - **IP allowlisting.** SmartRecruiters publishes its own outbound IPs (separately for production and sandbox) for customers to allowlist *inbound* webhook traffic. They are explicitly "subject to change," and the changelog RSS feed is the notification channel. This is about traffic **from** SmartRecruiters to you; it is not a list of addresses you call. - **Listing.** Appearing on customers' Apps & Integrations page comes with the General Partner Integration, and whether that is public to all customers or private to named ones is confirmed with the Partner & API Team (§3). SmartRecruiters publishes no self-serve certification checklist on its developer site — ask, rather than assuming a process exists that you can pre-satisfy. ## 8. Verify end-to-end Do not stop at "the token came back." Test what a customer's tenant actually does: 1. Mint a token and make one **scoped** read — candidates, jobs or users. **Not** a Posting API call (§7). 2. Read the **current user** endpoint if your connect flow identifies the connection that way. It needs `user_me_read` specifically (§6), and it is the most common scope missing from an otherwise complete set. 3. Read a **users** or **audit** endpoint if either is in scope. These exercise the System role rule, and they fail differently from a scope problem — this is the two-minute test that separates the two. 4. **Count.** Compare a candidate or job count against what the customer says they have. A short list with a 200 is the Access Group narrowing your view, not an empty company (§6). 5. On the General Partner Integration: run the full **consentUrl → exchange → per-company token** round trip against a **second** company, and confirm you stored `companyId` and `integrationId`. Then let the access token expire and confirm you re-mint from the stored pair rather than looking for a refresh token that does not exist. 6. On the legacy Authorization Code flow: exchange the code **immediately** — you have 30 seconds. Then **refresh, and refresh again using the token the first refresh returned.** That catches a client that ignores rotation, and it is a two-minute test for a failure that otherwise surfaces weeks later. 7. Watch `X-RateLimit-Concurrent-Remaining` during a real candidate sync, not a single call. The concurrency limit of 1 on `GET /candidates` will find you. 8. If you subscribe to webhooks, confirm you can actually **fetch** a resource you were notified about. Access groups are not applied to notifications but are applied to the follow-up read (§6). | Symptom | Cause | | --- | --- | | 401 on every call, credential looks correct | Wrong header for the credential type — SmartToken goes in `X-SmartToken`, an OAuth token in `Authorization: Bearer` (§1) | | 401 with a key that "looks right" | A 32-character customer API Key and a 64-character Partner API Key both use `X-SmartToken` and are not interchangeable (§1) | | **403 on users, configuration, audit or webhooks; 200 elsewhere** | The credential's **System role**, not its scopes — several of these require Admin / `ADMINISTRATOR` (§6) | | `OAuthPermissionsException` | The token was not granted the scope that endpoint requires; check the **Permission Reference**, not the catalogue (§6) | | 403 on the current-user read right after a successful authorization | `user_me_read` was not requested; it is not implied by `users_read` (§6) | | **200 with a short list of candidates or jobs** | An **Access Group** on the credential is narrowing visibility. Not an error, not an empty tenant (§6) | | Webhook fires, the follow-up fetch 403s | Notifications ignore access groups; the read does not (§6) | | "Authorization code expired" on the legacy flow | The code lives **30 seconds** — any queue hop or human step kills it (§4 Path D) | | Second refresh fails, first succeeded | The 28-day refresh token is **single-use**; store the new one from every response (§4 Path D) | | Connection dies after a quiet month | 28-day refresh-token lifetime on the legacy flow; refresh on a schedule (§4 Path D) | | Looking for a refresh token on a client-credentials connection | There isn't one. Re-mint from the stored pair (§4 Paths B, C) | | Token exchange rejected although the credentials are right | Form encoding vs the JSON-shaped body in SmartRecruiters' own example; send real `x-www-form-urlencoded` (§4 Path D) | | `invalid` / rejected authorize request with no scopes | An empty `scope=` is invalid — omit it to use registration defaults, or send a non-empty list (§4 Path D) | | Partner master token 403s on the integrations endpoint | The master credential lacks `partner_integrations_manage` — a Partner & API Team question (§4 Path C) | | `409` from the integrations endpoint | That company is already integrated with your application, or the `clientId` you generated is not unique across your integrations (§4 Path C) | | Customer never receives a disable notification | `callbackUrl` is not called unless `oauthTokenUrl` is also registered (§4 Path C) | | `429` under light load | 10 req/s, **8 concurrent — and 1 concurrent on `GET /candidates`** (§7) | | Timeouts that look like an outage | Client timeout below the 128 s SmartRecruiters asks you to allow (§7) | | "It works!" on a posting read, nothing else works | The public Posting API needs no authentication; that test proves nothing (§7) | | Worked yesterday, 401 today, nothing changed | An admin clicked **Revoke** in Credential Manager. Revocation is immediate and total; credentials never expire on their own (§4) | | Customer says Credential Manager has no such option | They are not an Administrator (§4) | ## 9. Hand off — never commit the key - **Do not** write an API key, client secret, master secret, partner key or refresh token into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the human running this, for their secret store or console. A **`companyId`**, an **`integrationId`**, an Application ID and a client ID are not secrets and may be recorded; the secret that arrives in the same message is — separate them before you paste anything. - **Watch what gets logged.** The General Partner Integration's setup call returns a customer's `clientSecret` in a response body. Anything that logs whole request/response objects or whole credential records at info level will put live customer secrets into a log aggregator, where they are retained, indexed and widely readable. Review the logging on that code path specifically, not just the storage. - If a code change is needed (a scope string, a `consentUrl`, a token-endpoint shape), keep it secret-free and say plainly what the human must set out of band. - Report a secret once so it can be pasted into the secret store, say clearly that it is now in the transcript and can be revoked, then move on. - Close with: which credential model you ended up on; the Application ID and `companyId` if partner; where the secret was delivered; the token endpoint and the API base; the exact scope strings requested and any SmartRecruiters refused; **the System role the customer must assign** and whether an Access Group is in play; the re-mint or refresh cadence the 1799-second token demands; the visibility decision (public vs private on the Apps & Integrations page); and whatever is still waiting on SmartRecruiters. ## Stop and ask Hand back to a human rather than guessing when: **the ask is a new Authorization Code registration** — say in the first reply that SmartRecruiters stopped onboarding partners onto it on 2026-01-30, rather than starting a registration that cannot complete; there is no partner account and the ask is Master Credentials; the Partner & API Team comes back asking to narrow the scope set, or refuses a capability outright (that is a product answer, not a registration mistake); a multi-region platform needs more than one `consentUrl` and SmartRecruiters documents one; someone proposes re-issuing Master Credentials or a new Application ID on a live integration (every customer re-consents); the only available test tenant is production and the task involves writes; a customer will not make anyone an Administrator, so no credential can be created; the plan depends on customers pasting unscoped SmartTokens and nobody has accepted that security trade-off explicitly; the 1-concurrent limit on candidate reads makes a promised sync window impossible; an offer-write or other capability has no documented scope; or Credential Manager and the developer documentation do not match the **Platform state** section. ## References Verified to resolve on 2026-09-20. - Authentication overview and method comparison — https://developers.smartrecruiters.com/docs/authentication - Get Started (customer vs partner credential routing) — https://developers.smartrecruiters.com/docs/get-started - API Key / SmartToken — https://developers.smartrecruiters.com/docs/authentication-api-key - OAuth 2.0 Client Credentials grant — https://developers.smartrecruiters.com/docs/authentication-oauth-client-credential - OAuth 2.0 General Partner Integration — https://developers.smartrecruiters.com/docs/oauth-20-general-partner-integration - OAuth 2.0 Authorization Code grant (deprecated) — https://developers.smartrecruiters.com/docs/authentication-oauth-authorization-code - OAuth 2.0 Register an app (Partner Hub) — https://developers.smartrecruiters.com/docs/register-an-app - Partner API Key (legacy) — https://developers.smartrecruiters.com/docs/partner-api-key - Access Scopes catalogue — https://developers.smartrecruiters.com/docs/access-scopes - Permission Reference (endpoint → scope) — https://developers.smartrecruiters.com/docs/permission-reference - Apps Integrations API overview — https://developers.smartrecruiters.com/reference/apps-integrations-api - Apps Integrations OpenAPI specification — https://api.smartrecruiters.com/apps-integrations/api-docs - Changelog: General Partner Integration introduction — https://developers.smartrecruiters.com/changelog/general-partner-integration-introduction - Rate Limiting — https://developers.smartrecruiters.com/docs/rate-limiting - Throttling Policies — https://developers.smartrecruiters.com/docs/throttling-policies - Error Handling — https://developers.smartrecruiters.com/docs/error-handling - Deprecation and Sunset Policies — https://developers.smartrecruiters.com/docs/deprecation-and-sunset-policies - Public IP addresses (production and sandbox) — https://developers.smartrecruiters.com/docs/public-ip-addresses - Posting API (no-auth vs internal postings) — https://developers.smartrecruiters.com/docs/posting-api - Webhooks (Admin role and Manage webhooks toggle) — https://developers.smartrecruiters.com/docs/webhooks - Creating webhook subscriptions (access groups) — https://developers.smartrecruiters.com/docs/creating-webhook-subscriptions - Pagination — https://developers.smartrecruiters.com/docs/pagination - Marketplace API overview and integration options — https://developers.smartrecruiters.com/docs/partners-overview - Customer Experience (Apps & Integrations page, consent widget) — https://developers.smartrecruiters.com/docs/customer-experience - Assessment partner app overview — https://developers.smartrecruiters.com/reference/assessment-partner-app-overview - Audit API (ADMINISTRATOR role required) — https://developers.smartrecruiters.com/reference/auditget-1 - Full documentation index — https://developers.smartrecruiters.com/llms.txt - Developer changelog — https://developers.smartrecruiters.com/changelog - Partner Hub (registration channel) — https://www.smartrecruiters.com/partners/ - Partner Portal (legacy Partner API Key) — https://partners.smartrecruiters.com ## stripe-oauth-app URL: https://docs.unified.to/skills/stripe-oauth-app/SKILL.md # Stripe OAuth2 App Registration Get a working Stripe integration credential for a platform that reads and writes data in *other people's* Stripe accounts — a client ID, a client secret, the right permissions, redirect URIs, and a route to production. Stripe is not a conventional OAuth vendor and the usual instinct is wrong here. Stripe has no general "OAuth app" product. What looks like one is **Stripe Connect OAuth**, built for marketplaces that route payments between sellers — and the variant of it that data integrations used, **Connect extensions**, is closed to new registration. The current model is **Stripe Apps**: an app manifest, granular permissions, an install through the Stripe App Marketplace, and (optionally) OAuth as one of three authentication methods *inside* that model. Read §1 before doing anything else. Choosing the wrong model here is not a setting you change later — the API authentication method is frozen once the app is uploaded, and a Connect extension cannot be created at all. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, icon (300x300 PNG), company name | Naming rules are enforced at review (§8) | | **Which Stripe account owns the app** | One published app per account — this is a hard limit (§3) | | **Redirect URIs** | Every callback host your platform serves (§5) | | **Which Stripe objects you read and write** | Drives the permission list (§6) | | **Authentication method**: platform key, OAuth, or restricted API key | Frozen after upload (§4) | | **Public or private distribution** | Public means app review; private means your own team only (§8) | | **Privacy policy URL, support channel, pricing page** | Required fields on the listing (§8) | | **Test Stripe account credentials for reviewers** | Required, must not need 2FA (§8) | | **Is there an existing Connect extension or OAuth app?** | Changes the whole run — see §2 | ## Quick Start 1. Decide the model: Stripe App, legacy Connect OAuth, or a customer-pasted API key (§1). 2. Confirm whether a **new** app is needed — existing connections are bound to the current credentials (§2). 3. Sign in to, or create, the Stripe account that will own the app (§3). 4. Create the app with the Stripe CLI and set `stripe_api_access_type` before uploading (§4). 5. Add every redirect URI to `allowed_redirect_uris` (§5). 6. Request the narrowest permission set that covers what you actually call (§6). 7. Capture the client ID, the developer secret key, and the install links (§7). 8. Submit for app review and publish, if the app is public (§8). 9. Handle the things that break connectors after launch: API version pinning, rate allocations, livemode (§9). 10. Verify with a **second** Stripe account, not the one that owns the app (§10). 11. Hand the credentials over — never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) Four facts decide this task. Check the linked pages before following any older runbook: - **Connect extensions — the read-only OAuth pattern that data integrations used — can no longer be created.** Stripe's own page for them is titled "[Deprecated] Legacy Connect extensions" and states: "You can no longer build new Connect extensions. Stripe Apps replaces Connect extensions for developing on Stripe." Stripe Apps replaced them as the preferred integration model **as of 2022**. Existing extensions keep working "for now", with no guaranteed ongoing support and no new features. - **OAuth is explicitly not recommended for new Connect platforms.** The Connect OAuth guide opens with "OAuth isn't recommended for new Connect platforms. We recommend using Connect Onboarding for Standard accounts instead." Stripe's app-publishing guide is blunter: "Stripe deprecated the Connect Stripe authentication method, so use this OAuth method instead" — meaning Stripe Apps OAuth. - **`read_only` scope is Extensions-only.** "Only Extensions can use `read_only`." Since extensions can no longer be created, a *new* integrator cannot obtain read-only Connect OAuth at all. Registering as a Connect *platform* instead buys you `read_write`, which since June 2021 **cannot connect to a Standard account already controlled by another platform** — it forces the user to create a second, separate Stripe account. - **The legacy Connect token response's `access_token`, `refresh_token` and `stripe_publishable_key` are marked Deprecated in the reference.** Stripe directs you to use the `Stripe-Account` header with your platform's own secret key instead. The field you actually need from that response is `stripe_user_id`. The current API version is `2026-08-26.dahlia` (major releases are named — Acacia, … , Dahlia; monthly releases are backward-compatible and reuse the last major name). If the Dashboard or docs do not look like this, stop and report what you actually see rather than clicking on. ## 1. The model decision — read this first **For a new multi-tenant integration that reads and writes many customers' Stripe accounts today, build a Stripe App.** That is Stripe's stated current model, it is the only one open to new registration, and it is the only one that gets granular permissions, Marketplace discoverability and continued feature work. Stripe App is not a synonym for "Dashboard UI widget". An app with no UI is a first-class case: Stripe calls it `data_integration`, and the publishing guide tells you to leave `ui_extension` empty for it. A headless connector is exactly that. Inside the Stripe App model you then pick **one** of three API authentication methods (`stripe_api_access_type`): | Method | What your backend holds | Fits | | --- | --- | --- | | `platform` (default) | **Your own** account's secret key, plus the customer's account ID; you call with the `Stripe-Account` header | Fewest keys to manage; the drop-in replacement for a legacy Connect extension | | `oauth` | Per-account access token (1 hour) and refresh token (1 year, rotated on every exchange) | You already speak OAuth; the user manages the integration from your product. **This is the option that yields an OAuth client ID and secret.** | | `restricted_api_key` | A permissioned `rk_` key the customer copies out of Stripe and pastes into your product | Your software cannot do redirect-based onboarding, or runs on-premise | Stripe says `platform` and `oauth` are the two that offer legacy extensions a drop-in replacement. For a Unified.to-style connector doing browser-based connect flows, `oauth` is the natural fit and the one this skill assumes unless the user says otherwise. **The decision is one-way.** `stripe_api_access_type` cannot be changed after the app is uploaded (Stripe states this outright for the restricted-key case, and app IDs are globally unique, so "just re-upload" means a new app and a re-onboarding of every customer). Confirm the choice with the user before the first `stripe apps upload`. When **not** to build a Stripe App: - **You already operate a working Connect extension or Connect OAuth integration.** It keeps working. Migrating is a customer-visible event: every existing user must re-authorize the new permission set in their Dashboard before they can use the migrated app. That is a project, not a config change (§2). - **You are actually building a marketplace** that creates accounts and routes payments. That is Connect proper — use Connect Onboarding for Standard accounts, not OAuth, and not this skill. - **The customer just wants to paste a key.** A restricted API key created by the customer in their own Dashboard needs no app at all. It is the lowest-friction path and a legitimate answer for a handful of accounts; it does not scale to self-serve onboarding, and the key's permissions are whatever the customer happened to tick. ## 2. Decide: reuse the existing integration, or register a new one New credentials orphan existing connections. Every customer connection is bound to the client ID (or to the platform secret key) that created it. Reuse the existing registration for: adding a redirect URI, adding a permission, rotating a compromised secret, or diagnosing a failing authorization. Create a **new** app only when the user explicitly wants one: a deliberate migration off a legacy Connect extension, a separate product, or a replacement for a compromised app. Two Stripe-specific constraints shape that migration: - **Upload the new app to the same Stripe account that owns the extension** if you want existing users to migrate in place. They then see a prompt in their extension settings to re-authorize the app's permissions, and accepting it **overwrites** the unrestricted read/write grant the extension had. So the manifest must already request every permission the integration needs *before* anyone migrates, or working customers break. - **Upload it to a different Stripe account** if you do not want to prompt existing users — but one account cannot hold both an existing Connect extension and an unrelated public Stripe App. Say which path you are taking, out loud, before touching anything. ## 3. The Stripe account that owns the app - **A Stripe account you control** owns the app. You need an **activated** account to publish (Stripe requires activation, and the business purpose must not be on the Prohibited and Restricted Businesses list). - **One published app per account.** This is a hard limit, and it interacts with §2: plan which account owns what before you upload anything. - **A separate account for development.** Stripe recommends building and testing the app under a different or new Stripe account, with a different app ID (`com.example.myapp.test`), because **app IDs are globally unique**. - **Sandboxes** are the isolated test environments (`sk_test_`, `rk_test_`, `pk_test_` keys). Set `sandbox_install_compatible: true` in the manifest to let the app be installed into one. Hand control back to the user for anything only they can do: account signup and activation, business details, identity verification when revealing a secret key, MFA, accepting terms. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. The CLI does most of this work — hand the user the exact commands and the literal values to paste (the redirect URIs from §5, the permission list from §6), then continue once they report back with the client ID. ## 4. Create the app Stripe Apps are created and uploaded with the Stripe CLI, not through a web form. ```bash stripe apps create stripe apps set api-access-type oauth # or platform / restricted_api_key — frozen after upload stripe apps set distribution public # private is the default stripe apps upload ``` The manifest (`stripe-app.json`; v2 apps use `stripe-app.yaml`, and `stripe apps migrate` converts) carries everything that matters: ```json { "id": "com.example.myapp", "version": "0.0.1", "name": "Example Sync", "icon": "./icon.png", "distribution_type": "public", "stripe_api_access_type": "oauth", "sandbox_install_compatible": true, "allowed_redirect_uris": [], "permissions": [], "ui_extension": [] } ``` Fields that carry more weight than they look: - **`id`** is globally unique and validated on first submission. Choosing it badly is permanent. - **`ui_extension: []`** is the correct value for a headless data integration. Stripe's publishing guide says so explicitly; shipping an empty app drawer instead is a review finding. - **`name`** cannot contain "Stripe", "app", "free" or "paid" for public apps (since September 2024), and review also rejects "RAK", "Generator", "API Key" and "Authenticator". It must match the listing name exactly. - **`permissions[].purpose`** is shown to the installing user; **`permissions[].name`** is the explanation shown to Stripe's reviewers. Thin justifications are among the most common review failures. **Legacy path, for reference only.** A pre-existing Connect OAuth integration is configured in the Dashboard at **Settings → Connect → Onboarding options → OAuth**, which is where its `client_id` (prefix `ca_`) and redirect URIs live. Its authorize endpoint is `https://connect.stripe.com/oauth/authorize` with `response_type=code&client_id=…&scope=read_only|read_write`, the exchange is `POST https://connect.stripe.com/oauth/token` authenticated with your platform secret key as the HTTP Basic username, and deauthorization is `POST https://connect.stripe.com/oauth/deauthorize` with `client_id` and `stripe_user_id`. Do not build something new on this. ## 5. Redirect URIs Register **every** callback host your platform serves in `allowed_redirect_uris`. The first entry is the default used when the install link omits `redirect_uri`. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes: - A `redirect_uri` passed on the install link must **exactly match** one of the registered values. Live mode requires HTTPS. - **`localhost` and placeholder URLs are a documented review rejection.** Stripe names them as a security risk and tells you to remove them before uploading. Strip development entries before submitting. - The redirect URI is your platform's, not Stripe's, and does not vary per customer account. ## 6. Permissions (and what they replaced) A Stripe App declares granular, per-resource permissions in the manifest, each in the form `_read` or `_write`, added with `stripe apps grant permission "" ""`. The installing account administrator must accept them. Calling an API the app lacks permission for raises an invalid request error. Names you will need for a payments/billing/commerce connector, as Stripe spells them: | Data | Permissions | | --- | --- | | Customers | `customer_read`, `customer_write` | | Charges and Refunds | `charge_read`, `charge_write` | | Payment Intents | `payment_intent_read`, `payment_intent_write` | | Invoices | `invoice_read`, `invoice_write` | | Credit Notes | `credit_note_read`, `credit_note_write` | | Products | `product_read`, `product_write` | | Prices | `plan_read`, `plan_write` — note the legacy name | | Subscriptions | `subscription_read`, `subscription_write` | | Payment Links | `payment_links_read`, `payment_links_write` — note the plural | | Payouts | `payout_read`, `payout_write` | | Balance | `balance_read` | | Tax Rates | `tax_rate_read`, `tax_rate_write` | | Account | `connected_account_read` | | Events (webhooks) | `event_read`; endpoints themselves need `webhook_read` / `webhook_write` | Four traps worth more than the table: 1. **Permission names do not track object names.** Prices are `plan_*`; payment links are plural; the account object is `connected_account_read`. Guessing from the API resource name produces permissions that do not exist. 2. **Expansion needs its own permission.** If you `expand` a nested object in a response, you must hold a permission for the expanded object too. `balance_transaction_source_read` exists specifically to expand a balance transaction's `source`, and it implies a cluster of others. A connector that expands to enrich a payment will fail on permissions it never explicitly reads. 3. **Not every resource appears in the reference.** Financial Connections accounts and transactions, for example, are not listed among the object permissions as of 2026-09-20. If your integration touches a resource that has no permission listed, do not assume it is covered — confirm with Stripe before designing around it. 4. **`webhook_write` is flagged sensitive** because it allows subscribing to events across the entire account. Expect it to draw reviewer attention, and justify it properly. Request the narrowest set that matches what the connector actually calls. Over-broad permissions are a review finding and a customer security objection, and after migration the permission list is what *replaces* a legacy extension's unrestricted read/write grant. **Legacy scopes, for reference.** Connect OAuth has exactly two: `read_only` and `read_write`, space-free, passed as `scope` on the authorize URL, defaulting to `read_only`. `read_only` is Extensions-only (§Platform state). There is no middle ground and no per-object control — which is precisely the gap Stripe Apps permissions fill. ## 7. Capture the credentials For a Stripe App using `oauth`: - **Client ID** — generated for the app; the install link is built from it. - **Install links** — the Dashboard generates separate links for live mode, test mode and sandbox. The **External test** tab's links are for testing only; the **Settings** tab holds the **public** install links, which are what app review requires and what customers eventually use. Submitting the test link is a listed common review failure. Public links do not work until the app is published (reviewers can still use them). - **The install link shape** is `https://marketplace.stripe.com/oauth/v2/authorize?client_id=…&redirect_uri=…&state=…`. Always send `state`, and verify it on the callback. - **Your app developer account's secret key** is the `client_secret` for the token exchange — it is your own account's key, not a separate OAuth secret. Which key depends on which link the user installed from: live-mode link → live key; test-mode link → test key; sandbox link → managed sandbox key. Stripe's own advice is to carry the link type through the `state` parameter so your callback knows which key to use. - **Token endpoint**: `POST https://api.stripe.com/v1/oauth/token` with the secret key as HTTP Basic username, `grant_type=authorization_code` and `code`. The authorization code is **single-use and valid for 5 minutes**. - **The response** carries `access_token`, `refresh_token`, `livemode`, `scope: "stripe_apps"`, `stripe_publishable_key`, and `stripe_user_id` — the connected account ID (`acct_…`). **Store the account ID.** It is how you identify the customer forever after, and how you scope calls with the `Stripe-Account` header if you ever move to platform-key auth. - **Token lifetimes**: access tokens expire in **1 hour**; refresh tokens expire in **1 year** and are **rotated on every exchange**, with the previous one expiring immediately. Persist the new refresh token from every refresh. If you refresh at least once a year you never hit the expiry. Secret keys are shown once in live mode and cannot be re-read; you can only reveal live keys that Stripe created for you. If you create one yourself, capture it immediately or rotate. Rotating in the Dashboard gives a **7-day grace period** during which both keys work — use it rather than a hard cutover. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 8. Review, listing and limits Private apps (the default) are installable only by **team members of the owning account** and go through **no review**. Public distribution requires app review and Marketplace publication. - **Turnaround: 4 business days** for an approval or feedback email after submission. Any change to the app or the listing means resubmitting and passing review again. - **Listing requirements** include: name ≤35 chars matching the manifest, a ≥300×300 square PNG/JPG icon matching the manifest, "Built by", category, subtitle (≤80), About (≤1000), one to three key features with 1600px+ screenshots that show no real customer data, pricing and a pricing page if paid, a support channel with a response-time estimate, a privacy policy URL, and English (the only supported language). - **Test credentials are mandatory**, must reach the highest role in your product, must not require 2FA, and **must not be real accounts** — Stripe does not permit real accounts for review. The test path must connect through app installation, not through Connect onboarding. - **Common rejections** Stripe names: localhost or broken `allowed_redirect_uris`; thin permission justifications; hard-coded API keys in the app; the External-test OAuth link instead of the public one; testing guidance that does not cover the onboarding flow. - **One published app per account**, and once an account has a public app you cannot make a second one public. Anything the review form asks that is a business claim — compliance attestations, data-handling commitments, pricing, volume projections, partnership terms — is for a human. Do not invent an answer (§Stop and ask). ## 9. What breaks connectors after launch None of this is fixed by re-registering the app. - **API version is pinned per account, not per app.** By default every request uses **the Stripe account's own default API version** (set in Workbench), and webhook events use the account's version at the time the event occurred unless the endpoint was created with an explicit version. For a connector reading many customer accounts that means **the same code can receive differently-shaped payloads from different customers**. The fix is to send an explicit `Stripe-Version` header on every request and to pin the version on every webhook endpoint, so the connector — not the customer — decides the schema. Note that a pinned version also freezes you out of fields added later, and that Event objects are immutable: raising the account version does not reshape events already created. - **Read allocations, not just rate limits.** Beyond 100 req/s live (25 in a sandbox) and 25 req/s per endpoint, Stripe enforces a read *allocation*: an average of **500 GET requests per transaction** over a rolling 30 days, minimum 10,000 reads/month. A Connect platform has its own allocation separate from its connected accounts', computed on aggregate transaction count. A connector that polls low-volume accounts hard is the exact shape that trips this. Writes have no allocation. - **429s are not all rate limits.** A `429` carries a `Stripe-Rate-Limited-Reason` header (`global-rate`, `endpoint-rate`, `global-concurrency`, `endpoint-concurrency`, `resource-specific`). A `429` *without* that header with code `lock_timeout` is object lock contention — retry with backoff, and serialize writes to the same object. List calls and expansions are the usual cause of concurrency limiting. - **The webhook signing secret is a separate credential** (`whsec_`, per endpoint, different for test and live) and is **not** an API key. Verify the `Stripe-Signature` header against the **raw** body; any middleware that reparses the body breaks verification. Events are **not ordered**, can be **delivered more than once**, and retry for up to three days — dedupe on event ID, never on `created`. Roll signing secrets periodically; Stripe lets both be valid for up to 24 hours during a roll. - **Idempotency** applies to POSTs via the `Idempotency-Key` header (≤255 chars, use a v4 UUID). Stripe replays the saved status and body — including 500s — and errors if you reuse a key with different parameters. Keys are pruned after ~24 hours. GET and DELETE ignore it. - **Livemode and test mode are separate universes.** Separate keys, separate objects, separate install links, separate webhook secrets, and the token response's `livemode` flag tells you which one a connection is in. A test-mode key against a live-mode code returns `invalid_grant`. ## 10. Verify end-to-end Installing into the account that owns the app proves almost nothing. Test the path a customer takes: 1. Install from a **second Stripe account** (a sandbox or a separate test account) through the real install link, and confirm the permission screen lists exactly what you expect. 2. Confirm the token response carries `stripe_user_id`, and that you stored it. 3. Make a real read call with the access token and confirm the object shape matches the `Stripe-Version` you intend to pin (§9), not whatever that account happens to default to. 4. Force a refresh, then force a **second** refresh using the token returned by the first — that is what catches a client that ignores refresh-token rotation. 5. Exercise one permission you almost use (an `expand`, a nested object) and confirm it does not 400. 6. Deliver one webhook and verify the signature against the raw body. | Symptom | Cause | | --- | --- | | `invalid_grant` on code exchange | Code already used, older than 5 minutes, or a key/mode mismatch (test key vs live-mode link) — §7, §9 | | `invalid_client` on deauthorize | `client_id` not yours, account not connected, or key mode mismatch | | Second refresh fails | Client is not storing the rotated refresh token — §7 | | Connection dies after a long idle period | Refresh token passed its 1-year expiry — §7 | | "Missing permissions" when refreshing | Using an access token or restricted key instead of the account secret key — §7 | | Invalid request error on a call that "should work" | Missing permission, often for an **expanded** object — §6 | | Same code, different field shapes per customer | API version is the customer account's default; pin `Stripe-Version` — §9 | | 429 with `Stripe-Rate-Limited-Reason` | Rate or concurrency limit — back off, reduce expansions — §9 | | 429, no reason header, `lock_timeout` | Object lock contention — serialize writes to that object — §9 | | Webhook signature verification fails | Body was reparsed before verification, or wrong endpoint's secret — §9 | | Duplicate or out-of-order events | Expected behaviour — dedupe on event ID — §9 | | Valid token, empty or wrong data | Test mode vs live mode; check `livemode` on the connection — §9 | | `read_write` connect flow forces a new customer account | Standard account is controlled by another platform (June 2021 rule) — §Platform state | | Review rejected on redirect URIs | `localhost` or placeholder left in `allowed_redirect_uris` — §5 | ## 11. Hand off — never commit the secret - **Do not** write the secret key, a restricted key, an access or refresh token, or a webhook signing secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. Stripe explicitly warns that hard-coded keys in an app fail review. - **Stripe keys are self-identifying.** A key beginning `sk_live_` is an unrestricted live secret key and is the most dangerous string in this workflow; `rk_live_` is a live restricted key; `sk_test_` / `rk_test_` / `pk_test_` are sandbox keys and `pk_` publishable keys are safe to expose. If you ever see an `sk_live_` value in a diff, a log, or a paste, treat it as compromised, say so, and tell the user to rotate it — Stripe's Dashboard rotation gives a 7-day grace window so rotating is cheap. - Consider recommending **access policies** on live keys (IP/CIDR, or ASN + country) so a leaked key is unusable from outside your infrastructure, and Stripe notifies you on blocked attempts. - If a code change is needed (a redirect host, a permission, `Stripe-Version` pinning), keep it secret-free and say what the human must set out of band. - Close with: app name and app ID; the owning Stripe account; the model and authentication method chosen (and that it is frozen); client ID; where the secret was delivered; the install links (public vs test); the exact permission strings; the redirect URIs registered; the API version you pinned; review status and what is still outstanding; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the choice between Stripe App, Connect OAuth and a customer-supplied restricted key has not been made by someone who owns the integration (§1 — it is irreversible); someone proposes migrating a live Connect extension, because every existing customer must re-authorize and an incomplete permission list breaks them (§2); the owning-account question collides with the one-published-app-per- account limit (§3); a required resource has no permission listed in Stripe's reference (§6); the review form asks for compliance, legal, pricing or volume claims (§8); rotating a live secret key is on the table without a cutover plan (§11); or the Dashboard and docs do not match the **Platform state** section above. Never run browser automation against a Stripe Dashboard session, and never attempt to create or reveal keys on the user's behalf through a logged-in browser. Reveal flows require identity verification that only the user can complete. Give the exact CLI commands and click paths, and let them report back. ## References - Stripe Apps overview — https://docs.stripe.com/stripe-apps - API authentication methods (platform / oauth / restricted_api_key) — https://docs.stripe.com/stripe-apps/api-authentication - Stripe Apps OAuth 2.0 — https://docs.stripe.com/stripe-apps/api-authentication/oauth - Restricted API key authentication — https://docs.stripe.com/stripe-apps/api-authentication/rak - Permissions reference — https://docs.stripe.com/stripe-apps/reference/permissions - App manifest reference — https://docs.stripe.com/stripe-apps/reference/app-manifest - Stripe Apps CLI reference — https://docs.stripe.com/stripe-apps/reference/cli - Create an app — https://docs.stripe.com/stripe-apps/create-app - Upload and install an app — https://docs.stripe.com/stripe-apps/upload-install-app - Distribution options — https://docs.stripe.com/stripe-apps/distribution-options - Publish an app (review, listing, 4 business days) — https://docs.stripe.com/stripe-apps/publish-app - App review requirements — https://docs.stripe.com/stripe-apps/review-requirements - Test your app / external testing — https://docs.stripe.com/stripe-apps/test-app - Enable sandbox support — https://docs.stripe.com/stripe-apps/enable-sandbox-support - Secret Store API — https://docs.stripe.com/stripe-apps/store-secrets - Migrate a Connect extension to Stripe Apps — https://docs.stripe.com/stripe-apps/migrate-connect-extension - [Deprecated] Legacy Connect extensions — https://docs.stripe.com/building-extensions - Connect OAuth reference — https://docs.stripe.com/connect/oauth-reference - Using OAuth with Standard accounts — https://docs.stripe.com/connect/oauth-standard-accounts - OAuth changes for platform-controlled Standard accounts — https://docs.stripe.com/connect/oauth-changes-for-standard-platforms - Make API calls for connected accounts (`Stripe-Account`) — https://docs.stripe.com/connect/authentication - Connect webhooks — https://docs.stripe.com/connect/webhooks - API versioning — https://docs.stripe.com/api/versioning - Upgrades and backward compatibility — https://docs.stripe.com/upgrades - API changelog — https://docs.stripe.com/changelog - Rate limits and read allocations — https://docs.stripe.com/rate-limits - Idempotent requests — https://docs.stripe.com/api/idempotent_requests - Webhooks (signing secret, signature verification, retries) — https://docs.stripe.com/webhooks - API keys and access policies — https://docs.stripe.com/keys - Restricted API keys — https://docs.stripe.com/keys/restricted-api-keys - Best practices for managing secret API keys — https://docs.stripe.com/keys-best-practices - Sandboxes — https://docs.stripe.com/sandboxes - Stripe CLI install — https://docs.stripe.com/cli/install - Stripe App Marketplace — https://marketplace.stripe.com/ - Dashboard: Apps — https://dashboard.stripe.com/apps - Dashboard: Connect OAuth onboarding options (legacy) — https://dashboard.stripe.com/settings/connect/onboarding-options/oauth ## Product fact: the Unified.to Stripe connector As of 2026-09-20, Unified.to's Stripe connector uses the **legacy Connect/extension-style OAuth** model, not Stripe Apps: it authorizes against `https://connect.stripe.com/oauth/authorize` and exchanges the code against Stripe's `/v1/oauth/token` endpoint, sending the client secret as the HTTP Basic username. It also supports a second, non-OAuth mode where the customer supplies a Stripe secret or restricted API key directly, presented to the API as the Basic-auth username. Its OAuth scope set is the legacy pair only: `read_only` for every object it reads, and `read_write` requested for the objects where it supports create or update (contacts, invoices, tax rates, items, payment links, payments, subscriptions, credit memos). Collections, payouts, refunds, accounts, transactions and the bank-feed objects are requested read-only. Because `read_only` is Extensions-only and extensions can no longer be created, this scope set is only obtainable under the connector's existing registration — it could not be reproduced by registering a new Connect application today. The connector identifies the connected account by the Stripe account ID returned in the token exchange, and uses it to look up that account's details after authorization. It **pins the API version explicitly**, sending a `Stripe-Version` header of `2024-04-10` on every read, write and list call rather than inheriting each customer account's default — which is the correct shape for a multi-account connector (§9), but is several major releases behind the current `2026-08-26.dahlia`. Writes are sent form-encoded, updates use POST, and lists page by cursor. Treat all of the above as a snapshot, not as authority: **confirm the current model, scope set, pinned API version and migration plans with the connector's owner before acting on them.** ## tiktok-oauth-app URL: https://docs.unified.to/skills/tiktok-oauth-app/SKILL.md # TikTok OAuth2 App Registration Get a working TikTok OAuth2 client — a developer account, an app, redirect URIs, an approved scope set, a client key and a client secret — for a platform that connects many customers' TikTok accounts. Three things about TikTok are expensive to get wrong, and all three cost a review cycle rather than a retry: 1. **There are three unrelated TikTok developer portals.** Registering in the wrong one produces credentials that will never authenticate against the API you meant. Read §0 before you click anything. 2. **Scopes are approved one by one by human reviewers**, and the app review asks for a demo *video* showing each requested scope in an end-to-end flow. Adding a scope later means another review. Decide the whole scope set up front (§5). 3. **Redirect URIs are exact-match, HTTPS-only, and must carry no query string or fragment**, with a hard cap of 10 per app — and after the app is live, any change goes back through review (§4, §6). A fourth thing that is not registration at all but breaks connectors anyway: TikTok calls the client ID a **`client_key`**, scopes in the authorize URL are **comma-separated** rather than space-separated, and refresh tokens **rotate**. §7 covers those. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Which TikTok product** | Consumer app (this skill), ads/marketing, or Shop — settle this first (§0) | | **App name, icon, category, description** | Icon 1024×1024 px JPEG/JPG/PNG, max 5 MB; shown on the consent screen | | **Website URL, Terms of Service URL, Privacy Policy URL** | Mandatory for apps created after 2024-09-09; must be live, public pages (§3) | | **Organization** | Who owns the app — strongly recommended over a personal account (§2) | | **Redirect URIs** | Every callback host, exact (§4) | | **Scope set** | The complete list, including anything wanted within the next few months (§5) | | **Test TikTok accounts** | Up to 10 sandbox target users, with someone able to log into each (§2, §6) | | **Demo recording owner** | Who will record the end-to-end walkthrough video for review (§6) | | **New app or edit to an existing one** | Determines whether customers must re-authorize (§1) | ## Quick Start 1. Confirm you want the **consumer TikTok for Developers app**, not ads or Shop (§0). 2. Confirm a **new app** is needed — a new client key orphans every existing connection (§1). 3. Sign in or sign up at the developer portal, and create/join an organization (§2). 4. Register the app, fill in the basics, add products, verify the URL properties (§3). 5. Add **every** redirect URI, exactly (§4). 6. Add the complete scope set, matching what the connector actually calls (§5). 7. Build a **sandbox**, add target users, and prove the flow works before submitting (§6). 8. Submit for **app review** with a demo video per product/scope, and wait (§6). 9. Capture the client key and client secret; note the token lifetimes (§7, §8). 10. Verify a real authorize → callback → refresh round trip (§9), then hand off (§10). ## Platform state (verified 2026-09-20 — re-verify before trusting) **The portal split is the single most important fact here.** TikTok runs three separate developer platforms with separate accounts, separate apps, separate credentials, separate review queues and different credential names. They are not interchangeable, and credentials from one will not work against another: | Product | Portal | Credential names | What it covers | | --- | --- | --- | --- | | **Consumer TikTok app** — *this skill* | `https://developers.tiktok.com/` | **client key** / **client secret** | Login Kit, Display API (profile + videos), Content Posting API, Research API, Data Portability | | **Ads / marketing** | `https://business-api.tiktok.com/portal` | **App ID** / **Secret** | Marketing API: campaigns, ad groups, ads, reporting against TikTok Ads Manager | | **Commerce** | `https://partner.tiktokshop.com/` (US: `https://partner.us.tiktokshop.com/`) | **App Key** / **App Secret** (+ Service ID) | TikTok Shop orders, products, fulfilment | If someone asks for "TikTok OAuth credentials" without saying which, **ask**. The tell: if the connector reads campaigns, ad accounts or ad reporting, it is the Business portal; if it reads orders, products or shops, it is Partner Center; if it reads a creator's profile, videos, or posts videos, it is this one. The Business portal also requires a **verified company-domain email** to register — personal and temporary email addresses are rejected — so a run that starts with a Gmail address is already in the wrong place if the target was ads. Current state of the consumer portal: - **Apps are registered through "Connect an app"** on the Manage apps page, owned by an **organization** you select at creation. Client key and client secret are generated automatically at that point. - **Apps created after 2024-09-09 must supply Terms of Service, Privacy Policy and a Web or Desktop URL**, and must pass **URL ownership verification** (DNS record for a domain, or a URL-prefix property) before submission. - **Sandbox mode exists and is mandatory for first-time approval.** Up to **5 sandboxes per app**, each shareable with up to **10 target user accounts**. Sandboxes do not cover the Content Posting API for public videos, nor the Data Portability API. - **App review takes "several days to two weeks"** after submission, per TikTok's own FAQ. While the app is **In review** no further changes can be made; you can **Recall** to withdraw. Statuses are Draft → In review → Live / Not approved. - **Once live, any subsequent change must be re-submitted and re-approved** before it appears in the live release. That includes adding a scope, adding a redirect URI, or changing the app name or icon. - **`user.info.basic` is added by default for every app with Login Kit.** Everything else is requested and reviewed. - **Token lifetimes**: access token 86,400 s (24 h); refresh token 31,536,000 s (365 days) from initial issuance. Refresh tokens **may rotate** on every refresh. - **Rate limit**: `/v2/user/info/`, `/v2/video/list/` and `/v2/video/query/` are each limited to **600 requests** on a **one-minute sliding window**; over the limit returns HTTP `429` with error code `rate_limit_exceeded`. If the portal does not look like this, stop and report what you actually see rather than clicking on. ## 0. Pick the right portal first Do this before anything else, because every later step is portal-specific. - **This skill covers the consumer TikTok for Developers app** at `https://developers.tiktok.com/` — Login Kit for authorization, the Display API for a creator's profile and videos, and the Content Posting API for publishing. - **Ads / marketing** → `https://business-api.tiktok.com/portal`. Register as a developer first (company-domain email required), then create the app under My Apps. Different auth flow, different credential names, different review. - **TikTok Shop** → `https://partner.tiktokshop.com/` (separate US instance). You choose Custom App (your own or named sellers) vs Public App (listed on the TikTok Shop App Store), and apply for API permissions per app. State plainly in your opening summary which portal you are working in. If the user turns out to want a different one, stop — do not half-register in this portal "to have something". ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client key**, and every existing customer connection is bound to the old one — every customer would have to re-authorize. On top of that a new app starts at zero approved scopes and must go through app review again, which is days to weeks with no credentials usable against real accounts in the meantime. Reuse the existing app for: adding a redirect URI, adding a scope, rotating a compromised secret, or diagnosing an authorization failure. Note that the first two still require a re-review (§6) — reuse is cheaper, not free. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a separate product or brand, or a deliberate split between environments. Say which path you are taking first. ## 2. Account: sign in or sign up - **Sign up**: `https://developers.tiktok.com/signup` — sign-in is with a TikTok account (or email/phone). - **Sign in**: `https://developers.tiktok.com/login` - **Create or join an organization**, then select it as the app owner at creation. TikTok describes an organization as highly recommended but not strictly required; for anything a company ships it is the right answer — a personal account means one individual's TikTok login is the single point of failure for the credentials. - **Test accounts**: you will need real TikTok accounts to add as sandbox target users (§6). Whoever owns each account must log in themselves and accept the Developer Terms of Service; you cannot add an account you do not control. Results can take up to an hour to appear. Hand control back to the user for anything only they can do: email/phone verification, CAPTCHA, 2FA, accepting the Developer Terms of Service and Developer Data Sharing Agreement, and logging in each target user. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click and do not invent what a screen said. Hand the user an exact, ordered click path with the literal values to paste — the redirect URIs from §4 and the scope strings from §5 — and continue once they report back with the client key. ## 3. Create the app Portal path: **profile icon → Manage apps → Connect an app → select the owning organization → confirm.** The client key and client secret are generated at this point. Then fill in, in this order: 1. **Basic information** — app icon (1024×1024 px, JPEG/JPG/PNG, ≤ 5 MB), app name, category, and description. The description is shown to users on the authorization page, so write it for them, not for the reviewer. Reviewers reject generic or placeholder names and icons, names that imply TikTok is the publisher, and anything that reads as "internal tool" or "still in development". 2. **URLs** — Terms of Service, Privacy Policy, and Web or Desktop URL. These must be live, public, fully built pages on the app's real website. A landing page or a login wall is a documented rejection reason, and the ToS and Privacy Policy links must be visibly reachable from the site itself. 3. **Platforms** — declare Web, Android, iOS and/or Desktop. Each carries its own requirements (package name and signature for Android, Bundle ID for iOS, store URLs for published mobile apps). A server-side integration is **Web**. 4. **Add products** — click *Add products* and add what you need: **Login Kit** (required for OAuth at all), plus **Display API** and/or **Content Posting API**. Redirect URIs are configured per-product under Login Kit. 5. **URL properties** — verify domain ownership (recommended: a signature string in the domain's DNS records) or a URL prefix. Verifying a domain covers every path under it and its subdomains; a URL prefix covers only URLs with that exact prefix. This is a mandatory config step for apps created after the cutoff, and the Content Posting API's pull-from-URL transfer will only accept media from a verified property. ## 4. Redirect URIs Register **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` The documented rules, all of which bite at connect time rather than at save time: - **Maximum 10 redirect URIs per app.** Four data centers plus a couple of test hosts already uses most of that. - **Each URI must be under 512 characters.** - **HTTPS only.** Plain HTTP is rejected, including for localhost. - **Absolute paths with no query string and no fragment.** `…/callback/?id=1` and `…/callback/#100` are both non-compliant. Anything your platform needs to carry through the flow goes in the `state` parameter instead. - **The `redirect_uri` you send must match a registered value exactly.** There is no subdirectory matching and no trailing-slash forgiveness — `…/oauth/code` and `…/oauth/code/` are different URIs. TikTok's own examples carry a trailing slash; Unified.to's callbacks do not. Register the form your platform actually sends. - Mobile platforms have extra format requirements: Android needs an App Link or Deep Link, iOS a Universal Link with the Associated Domain capability. Irrelevant for a server-side connector, but the form will ask if you declared those platforms. Adding a redirect URI to a **live** app is a change that goes back through review (§6). Get the full list in before the first submission. ## 5. Scopes TikTok scopes are **comma-separated** in the authorize URL — not space-separated like most OAuth2 providers. A space-delimited scope string is a silent, confusing failure mode. `user.info.basic` is added by default for any app with Login Kit. Everything else is added under **Add Scopes** on the app page and **reviewed individually**. The scopes relevant to a creator-data connector: | Scope | Grants | | --- | --- | | `user.info.basic` | Open ID, avatar, display name. Default with Login Kit. | | `user.info.profile` | Bio, profile link, username, verification status | | `user.info.stats` | Follower / following / likes / video counts | | `video.list` | Read the user's public videos | | `video.upload` | Upload a video to the user's drafts for them to finish posting | | `video.publish` | Post content directly to the user's profile | Other scope families exist and are not what a general connector wants: `research.*` (vetted-researcher programme), `portability.*` (EEA/UK data-export flows), and `local.*` (local services / vouchers). Do not request them to "cover future needs" — an unjustifiable scope on the form is a rejection for the whole submission. Three things about scopes that are easy to get wrong: - **Approval is not access.** Being approved for a scope only lets you *ask*. Each user must also grant it, and TikTok makes some scopes individually toggleable on the consent screen — a user can grant one and deny another. Your code must tolerate a token with fewer scopes than you requested, and the APIs reject fields whose scope was not granted rather than returning them empty. - **Request only what the product demonstrably uses.** "Request only necessary permissions" is an explicit review criterion, and every requested scope must be visible in the demo video. - **Direct posting is separately gated.** Even with `video.publish` approved, content posted by an **unaudited** client is restricted to private viewing. Lifting that requires a further audit of your compliance with TikTok's terms. Treat public posting as a second project, not a checkbox. ### Product fact — Unified.to's TikTok connector **As of 2026-09-20, Unified.to's TikTok connector requests:** - **Login / identity**: `user.info.basic` only. The Display API exposes no email address, so the connection identity is TikTok's `open_id` with no email attached. - **Profile reads**: `user.info.basic`, `user.info.profile`, `user.info.stats`. - **Post reads**: `video.list`. - **No publishing scopes** — `video.publish` and `video.upload` are not requested; the connector is read-only for TikTok, and creating posts is out of scope for it. Auth quirks the same configuration records: the client ID parameter is named **`client_key`** (not `client_id`) on both the authorize and token requests; the token exchange is an **`application/x-www-form-urlencoded` POST** with `client_key` and `client_secret` in the body; the access token is sent as a **bearer** header; and the connector has **no shared Unified.to-provided TikTok credentials** — every workspace must supply its own client key and secret, which is exactly why this registration has to happen per customer or per partner. Confirm the current scope set and these quirks with the connector's owner before submitting for review — a scope added to the connector after this date will not be on an app approved against this list. ## 6. App review — plan for it, do not discover it This is the section that decides the timeline. Nothing below can be skipped by being clever. **Sandbox first, and for a first-time app, sandbox is required.** Toggle to Sandbox mode next to the app name → *Create Sandbox* → name it (optionally cloning an existing config) → configure products and apply. Up to **5 sandboxes per app**. Share it with up to **10 target users**: each person goes to Sandbox settings, clicks *Add account*, logs into their own TikTok account and accepts the Developer Terms of Service; allow up to an hour for it to show up. Sandboxes do **not** cover the Content Posting API for public videos or the Data Portability API. When the sandbox works, switch to Production mode → *Draft* → *Import* and pick the sandbox — this **overwrites** the draft's existing configuration, so import before hand-editing the draft, not after. **What the submission asks for:** - A **detailed written explanation of how each product and each scope is used** in your app or website. Write one paragraph per scope, naming the screen or feature it powers. - **1 to 5 demo videos, max 50 MB each**, showing the complete end-to-end flow with *every* selected product and scope clearly demonstrated. For a first-time app the video must show the integration running in the **sandbox**. The domain shown in the video must match the website URL you submitted. Mobile apps must include the app opening. - If a product or scope is not actually used, **remove it before submitting** rather than leaving it on the form. **Timeline and statuses.** Review takes **several days to two weeks**. While **In review** the app is frozen — no config changes; use **Recall** to withdraw if you need to fix something. Outcomes are **Live** or **Not approved** (with reviewer comments; fix and resubmit). TikTok's guidance is that beta, incomplete, internal or test versions will generally not be approved; an unreleased product can request an integration assessment, with no guarantee. **After it goes live, every change is another review.** Adding a scope, adding a redirect URI, renaming the app, changing the icon — the change does not appear in the live release until re-approved. This is the reason §4 and §5 insist on getting the complete list in before the first submission. **Stop and ask rather than answering for the user**: anything in the review form that is a business or legal claim — what the company does, how data is retained, compliance attestations, expected volumes, partnership status. Those are the user's words to write, not yours. ## 7. Capture the credentials From the app page in the developer portal, on the app's basic information: **Client key** and **Client secret**. Capture and record: - **Client key** — TikTok's name for the OAuth2 `client_id`. It is sent as the parameter `client_key`; a client library that hardcodes `client_id` will fail with an unhelpful error. - **Client secret** — sent in the token request body, not as a Basic auth header. - **Endpoints**: authorize `https://www.tiktok.com/v2/auth/authorize/`, token `https://open.tiktokapis.com/v2/oauth/token/`, API base `https://open.tiktokapis.com/v2`. The **trailing slashes are part of the paths** on TikTok's v2 endpoints — keep them. - **Authorize parameters**: `client_key`, `scope` (comma-separated), `response_type=code`, `redirect_uri`, `state`. Optional `disable_auto_auth` (0 = skip the consent page for a valid session, 1 = always show it). - **Token exchange**: `POST` with `Content-Type: application/x-www-form-urlencoded` carrying `client_key`, `client_secret`, `code`, `grant_type=authorization_code`, `redirect_uri`. `code_verifier` is required for the mobile and desktop flows (PKCE), not for the web flow. - **Whether this is the sandbox or the production app**, and the app's status (Draft / In review / Live). **On `state`.** TikTok's own guidance is to generate a random alphanumeric anti-CSRF token, store it in a cookie with a ~60 second expiry, and verify it on the callback. Because redirect URIs cannot carry a query string (§4), `state` is also the only place to put per-connection context. Rotating the client secret invalidates the old one. Existing access tokens survive until they expire (up to 24 h), but every new authorization and every refresh fails until the new secret is deployed. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be rotated, then move on. ## 8. Token lifetimes and refresh | | Value | Consequence | | --- | --- | --- | | **Access token** | 86,400 s — **24 hours** | Every connection refreshes daily. There is no long-lived access token. | | **Refresh token** | 31,536,000 s — **365 days**, documented as "after the initial issuance" | A connection that is never re-authorized eventually dies. | | **Rotation** | The refresh response **may return a different `refresh_token`** | You must persist the new value whenever it differs. | Refresh is a `POST` to the token endpoint with `client_key`, `client_secret`, `grant_type=refresh_token` and `refresh_token`, form-encoded. Two failure modes fall out of this table: - **A client that ignores the returned refresh token** works on the first refresh and fails on a later one. Test the *second* refresh, using the token the first refresh returned (§9). - **A client that assumes the 365-day window slides** may be wrong. TikTok documents the refresh token as valid for 365 days **after initial issuance**; the docs do not state whether refreshing resets that clock. Treat annual re-authorization as the safe assumption, make sure a connection can be re-authorized without data loss, and confirm the behaviour with TikTok support if the answer matters to the product. **Rate limits** to design against: `/v2/user/info/`, `/v2/video/list/` and `/v2/video/query/` are each capped at **600 requests on a one-minute sliding window**, returning HTTP `429` with `rate_limit_exceeded` above that. The documentation does not state whether that ceiling is per app or per user access token — assume the stricter reading (per app) until you have measured it. Higher limits are requested through TikTok's support page. ## 9. Verify end-to-end Sandbox success is necessary, not sufficient — a sandbox has target users and relaxed gating that production does not. Verify in this order: 1. **In the sandbox**, with a target user account, run the real authorize → callback → token exchange through your platform's connect flow. Confirm the scopes actually granted match the scopes requested. 2. **After the app is Live**, repeat with a TikTok account that is *not* a target user and not the developer. 3. Confirm the token response carries a **refresh token** alongside the access token. 4. Force a **refresh**, then force a **second** refresh using the refresh token returned by the first. That is what catches a client that ignores rotation. 5. Make one real read call per approved scope, to confirm the scope grant does what you expect — a granted-but- unused scope on the review form is also a rejection risk on the next submission. | Symptom | Cause | | --- | --- | | Authorization page errors immediately, or callback carries `error` / `error_description` | `redirect_uri` does not match a registered value exactly, or the user is not eligible for third-party authorization (§4) | | "Invalid client" / unrecognised client at token exchange | Sent `client_id` instead of `client_key`, or sandbox credentials against production (§7) | | Scope errors, or the authorize page shows fewer permissions than asked for | Scope string was space-separated instead of comma-separated, or the scope is not approved for this app (§5) | | Authorize works but an API field comes back missing | The user toggled that scope off at consent; the API rejects fields whose scope was not granted (§5) | | Works for the developer's own account, fails for everyone else | Still in sandbox — target users only; the app is not Live (§6) | | Everything worked yesterday, all calls 401 today | Access token expired at 24 h and the refresh did not run (§8) | | First refresh fine, a later one fails | Client is not persisting the rotated refresh token (§8) | | Connection dies after a long quiet period | Refresh token hit the 365-day limit; customer must re-authorize (§8) | | HTTP `429` with `rate_limit_exceeded` | 600 requests/minute per Display endpoint (§8) | | Posted videos are visible only to the poster | Unaudited client restriction on the Content Posting API (§5) | | Credentials simply do not authenticate against the API you are calling | Wrong portal — ads or Shop credentials against the consumer API, or vice versa (§0) | ## 10. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for their secret store or console. - If a code change is needed (a callback host, a scope, comma-delimited scope joining, rotation-aware refresh storage), keep it secret-free and say what the human must set out of band. - Close with: which **portal** the app lives in and the app name; the owning **organization**; the **client key**; where the secret was delivered; the authorize, token and API base URLs; the **exact scope strings** requested and which are approved; the app's **status** (Draft / In review / Live) and, if in review, when it was submitted; the sandbox name and target users used for testing; the token lifetimes and the rotation requirement; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: - It is unclear **which TikTok portal** the user actually needs (§0) — registering in the wrong one wastes a review cycle and produces credentials that never work. - The review form asks for **business, legal or compliance claims**: what the company does, data retention, attestations, volume projections, partnership status. - The product needs a **website, Terms of Service or Privacy Policy that does not exist yet** — review rejects landing pages and login walls, and you cannot write the company's legal pages. - A **demo video** is needed: someone has to record a real end-to-end walkthrough, and it must match the submitted domain. - The request is to add a scope or redirect URI to a **live** app — that is a re-review, with days to weeks of lead time, and the user needs to know before you submit. - **Public direct posting** is wanted: that needs the separate unaudited-client audit (§5). - The app is **In review** and someone wants a change — recalling the submission restarts the clock. - Registering the app would mean **logging in as a person who is not in this conversation**, or adding a target user account you do not control. - The portal does not match the **Platform state** section above. ## References Verified 2026-09-20; every link resolved. - TikTok for Developers home — https://developers.tiktok.com/ - Sign up — https://developers.tiktok.com/signup - Sign in — https://developers.tiktok.com/login - Register your app — https://developers.tiktok.com/doc/getting-started-create-an-app - App review guidelines — https://developers.tiktok.com/docs/en/app-review-guidelines - App review FAQ (turnaround, re-review after going live) — https://developers.tiktok.com/docs/en/getting-started-faq - Add a sandbox — https://developers.tiktok.com/docs/en/add-a-sandbox - Introducing sandbox mode (blog) — https://developers.tiktok.com/blog/introducing-sandbox - Scopes overview — https://developers.tiktok.com/docs/en/scopes-overview - Scopes reference (exact strings) — https://developers.tiktok.com/doc/tiktok-api-scopes - Login Kit overview — https://developers.tiktok.com/doc/login-kit-overview - Login Kit for Web (authorize URL, redirect URI rules, state) — https://developers.tiktok.com/doc/login-kit-web - Login Kit for Desktop (PKCE flow) — https://developers.tiktok.com/doc/login-kit-desktop - Manage user access tokens — https://developers.tiktok.com/doc/login-kit-manage-user-access-tokens - User access token management (lifetimes, refresh, rotation) — https://developers.tiktok.com/doc/oauth-user-access-token-management - Display API overview — https://developers.tiktok.com/doc/display-api-overview - Content Posting API — get started (unaudited client restriction) — https://developers.tiktok.com/doc/content-posting-api-get-started - Content Posting API — media transfer / URL ownership verification — https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide - Rate limits — https://developers.tiktok.com/doc/tiktok-api-v2-rate-limit - Developer guidelines — https://developers.tiktok.com/docs/en/our-guidelines-developer-guidelines - Developer changelog — https://developers.tiktok.com/docs/en/changelog - Products overview — https://developers.tiktok.com/doc/overview - **Ads/marketing portal** — https://business-api.tiktok.com/portal - Register as a developer (Business API) — https://business-api.tiktok.com/portal/docs/register-as-a-developer/v1.3 - Create a developer app (Business API) — https://business-api.tiktok.com/portal/docs/create-a-developer-app/v1.3 - **TikTok Shop Partner Center** — https://partner.tiktokshop.com/ - Create an app (TikTok Shop) — https://partner.tiktokshop.com/docv2/page/create-your-app ## trackerrms-oauth-app URL: https://docs.unified.to/skills/trackerrms-oauth-app/SKILL.md # TrackerRMS API Credentials **Read this first, because it decides the shape of everything else: TrackerRMS has both a customer-generated bearer token and a real OAuth 2.0 authorization-code flow, and neither of them is what you send on an API call.** Both are inputs to a third step. Tracker's own API reference is unambiguous: "**All calls to this API require an authorised JWT token**, which is obtained by exchanging a valid OAuth2 access token for a JWT token using the **/api/auth/exchangetoken** endpoint." Whatever credential you obtain, the connector exchanges it for a short-lived JWT and sends *that* as the bearer. A client that sends the customer's token straight at the API gets 401 on everything and looks like a credential problem when it is an architecture problem (§2). **Neither credential is fully self-serve, but only one of them needs TrackerRMS.** The bearer token *is* self-serve: the customer generates it themselves in **Tools & Settings → Security Settings → API**, and Tracker says plainly that it "bypasses the need for the OAuth2 step of our authentication process." The OAuth client secret is not: Tracker's help centre says the `client_secret` is "issued on request," and points at `clientsupport@tracker-rms.com`. There is no developer portal, no app-registration form and no console. Decide which model you are building against before doing anything else (§1). Three more things cost real time. **There is exactly one bearer token per Tracker database** — "If you generate a new token the previous token will become invalidated" — so a customer setting up a second integration silently kills your connection (§5). **The OAuth host and the API host are different domains**, and there are three regional pairs; get either wrong and everything 404s (§3). And **the REST API is still described by Tracker as a beta** — "Beta version—stable but evolving," with a JWT lifetime quoted as "valid for 7 days during beta" (§ Platform state). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Credential model** | Customer-generated bearer token, or OAuth 2.0? (§1) | | **Region** | US, Canada, or UK/Rest of World — sets *two* hostnames, not one (§3) | | **Who generates the token** | It must be someone with access to Tools & Settings in the customer's database (§5) | | **Does any other integration already use that database's token?** | Blocking — regenerating breaks whatever holds the old one (§5) | | **Client ID and secret**, if OAuth | Issued by TrackerRMS on request; there is no portal (§5) | | **Redirect URIs** | Every callback host, final, up front (§4) | | **Scope set** | Tracker documents exactly two: `read` and `write` (§6) | | **A sandbox** | Created by TrackerRMS on request; there is no self-serve sandbox (§5) | | **New credentials or an edit to existing ones?** | New OAuth credentials orphan every existing authorization (§2) | ## Quick Start 1. Establish which **credential model** this actually needs (§1). Most wrong turns start here. 2. Understand the **token → JWT exchange**; it is mandatory and it is not optional plumbing (§2). 3. Pin the **region** and both of its hostnames (§3). 4. If OAuth: give TrackerRMS every callback host when you request the secret (§4). 5. Obtain the credential — the customer's Security Settings screen, or a request to TrackerRMS (§5). 6. Send `read` and/or `write` as the scope; there is nothing finer (§6). 7. Check the rate limit before promising a sync cadence — and remember the exchange call counts (§7). 8. Verify with a real round trip against the right regional pair (§8). 9. Hand the credential to a human, never to source control (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **There is no developer portal.** Nothing on `tracker-rms.com` registers an application, lists apps, or shows a client ID. The OAuth `client_secret` is "issued on request," and the help centre's route for it is `clientsupport@tracker-rms.com`. For anything larger, Tracker's REST API overview asks for "your company and integration goals, the specific use case or workflow, rough API call volumes" by email to `trackerapi@tracker-rms.com`. - **OAuth 2.0 authorization code is genuinely documented**, with its own OpenAPI definition ("OAuth2 API") published alongside the main reference. Authorize and token live at `/oAuth2/Authorize` and `/oAuth2/Token` on the `evoapi*` hosts — **a different domain from the REST API** (§3). - **The documented `client_id` is a fixed literal, `EvoApi_1.0`.** Both the OpenAPI summary ("For client\_id use EvoApi\_1.0") and the help-centre authentication topic print it. The token-endpoint prose in the same OpenAPI document contradicts this, describing `client_id` as "Your app's `client_id`". **Resolve this with TrackerRMS before building** — it is the difference between a per-partner client and a shared one, and it changes what a client secret even identifies. - **The two documents also disagree on the token response shape.** The OAuth2 description shows a standard `{ access_token, token_type: "Bearer", expires_in: 3600, refresh_token }`; the machine-readable schema on the same endpoint shows a single `{ "Token": "..." }`, and the help centre shows a bare UUID-shaped `Token`. A client written against one will not parse the other. Confirm with a real exchange before trusting either. - **Scopes are just `read` and `write`**, comma-separated, and `scope` is a **required** parameter on the authorize call even though the prose walkthrough omits it (§6). - **The REST API is beta.** "Beta version—stable but evolving." The JWT is quoted as "valid for 7 days during beta," and Tracker warns that JWTs expire and clients should re-exchange on a 401. - **Rate limits are published twice and do not match**: the API reference says "Presently 600 requests per minute"; the help centre's REST API overview says "Rate limit is ~100 requests/min." **Plan against 100** and confirm (§7). - **The old API still exists.** Tracker's July 2025 notice says the new REST API has shipped and "The old API connections will still function but there will be no updates." The old surface is a different thing — a widget API taking username/password or an OAuth token directly, with a separate "unique client API key." Do not mix its instructions into a new build (§1). - **Sandbox is by request**: "Please reach out via Live Chat, ticket or email clientsupport@tracker-rms.com to have an API Sandbox account created to start testing the new version." If the docs or the settings screens do not look like this, stop and report what you actually see. ## 1. Which credential, and who issues it | Credential | Who issues it | How the customer gets it | Notes | | --- | --- | --- | --- | | **Bearer token** (Tracker calls it the API token) | The customer, self-serve, inside their own database | Tools & Settings → Security Settings → API → copy the existing token or generate a new one | **One per database.** Generating a new one invalidates the previous one. No expiry documented | | **OAuth 2.0 access token** | TrackerRMS issues the `client_secret`; the customer then grants access through the flow | Authorize → code → token exchange | Refresh token supported; `expires_in: 3600` in the documented example. Client secret is not self-serve | | **Partner Integration API Key** | Already present in the customer's account | Profile menu → Tools & Settings → Customization → **Display Settings** | **A different thing.** Not the REST API bearer token, and not an OAuth credential — see the trap below | | **Username and password** | The customer | — | Legacy. Tracker: passed "in clear text over SSL however oAuth is recommended." Belongs to the old API surface | Three traps follow directly: - **"API key" means two different things in Tracker's own help centre.** The **Partner Integration API Key** on the Display Settings screen is a separate identifier associated with the older widget API, where it is used alongside a username to check a logon. It is **not** what `/api/auth/exchangetoken` accepts. A customer who has been sent to Display Settings and comes back with a key will be holding the wrong string, and it will fail with a generic 401. Send them to **Security Settings → API** instead, by name. - **The bearer token deliberately skips OAuth.** Tracker documents it as bypassing the OAuth step entirely, which makes it the pragmatic model for a platform connecting many customers: ask each customer for their token and their region, and store the pair. That is a supportable design, and it needs nothing from TrackerRMS. - **But it is a single shared secret for the whole database.** See §5 — this is the operational cost of choosing it. > **Product fact — dated.** As of **2026-09-20**, Unified.to's TrackerRMS connector supports **both** models. In token > mode it asks the customer for one value it labels "Access Token" and instructs them to generate it at > "Tracker RMS → setting → Security → Security Settings → API → Access Token → Generate Token" — Tracker's own wording > is Tools & Settings → Security Settings → API, which is worth using verbatim when talking to a customer. In OAuth > mode it runs an authorization-code flow sending `client_id`, `redirect_uri`, `response_type`, `scope` and `state` on > the authorize call, with scopes **comma-delimited**, and exchanges and refreshes with form-encoded POSTs carrying the > client id, client secret and redirect URI; **PKCE is not used**. Its scope set is per unified object and per > direction, drawn from exactly `read` and `write`. It offers three regional choices, each pairing an API host with its > own OAuth host. **In both modes it performs the token-for-JWT exchange before calls** and sends the resulting JWT as > the bearer — see §2 for the caveat. It records the sandbox route as a request to Tracker's client-support address. > Its list operations are POSTs with page-number paging and an updated-after filter. Confirm all of this with the > connector's owner before acting on it. ## 2. The JWT exchange — the step that is easy to miss Whatever credential you hold, the call sequence is the same: 1. Obtain a credential — the customer's bearer token, or an OAuth access token from the flow. 2. `POST /api/Auth/ExchangeToken` on the **REST API host** for that region, with a JSON body whose single field is `bearerToken` set to the credential. 3. Take the returned JWT and send it as `Authorization: Bearer ` on every subsequent call. Tracker's own step-by-step confirms the customer-generated token goes into exactly the same endpoint: "Navigate to the Auth section and utilize the /ExchangeToken endpoint to obtain your JWT token after pasting in the token from Tracker into the request body." So one exchange endpoint serves both credential models, which is why a connector can support both with one code path. What the docs say about the JWT's life: it **expires**, the quoted figure is **7 days during beta**, and Tracker's instruction is behavioural rather than numeric — "you should always check for an authentication failure (401) response and obtain a new JWT token by calling the /api/auth/exchangetoken endpoint, if necessary." Build the 401-and- re-exchange path; do not hardcode seven days. Two things worth settling before you ship: - **Cache the JWT.** It is valid for days. Exchanging on every API call doubles your request count against a limit that may be as low as 100/minute (§7), and adds a full round trip of latency to every operation. - **The exchange call itself is an API call.** Whatever the real rate limit turns out to be, budget the exchanges into it. > **Product fact — dated.** As of 2026-09-20, Unified.to's TrackerRMS connector performs the exchange in a shared > setup step wired into list, get, create, update, delete and passthrough, and there is **no caching of the returned > JWT visible in that helper** — so each operation appears to spend an extra exchange request. At a documented ceiling > of roughly 100 requests per minute that halves effective throughput. Worth confirming with the connector's owner > before promising a sync cadence, and worth raising as a fix. ## 3. Region: two hostnames, not one Tracker is regional, and the **OAuth endpoints and the REST API live on different domains**. The OAuth2 definition says so explicitly: the authorize and token endpoints "are published on a separate domain to this API." | Region | REST API host (data + JWT exchange) | OAuth host (authorize + token) | | --- | --- | --- | | US | `https://evousapi.tracker-rms.com` | `https://evoapius.tracker-rms.com` | | Canada | `https://evocaapi.tracker-rms.com` | `https://evoapica.tracker-rms.com` | | UK and Rest of World | `https://evoglapi.tracker-rms.com` | `https://evoapi.tracker-rms.com` | Notes: - **There is no per-customer subdomain.** Unlike many ATS vendors, Tracker does not put the customer's account in the hostname — the region does that, and the credential identifies the account. So the connect flow needs a **region**, not a subdomain, and the customer finds it by looking at the server location in the URL when they are logged in (Tracker's own step 1). - Guessing the region is not safe: the wrong pair returns 404s and auth failures that read like a bad credential. - The main API reference's machine-readable definition *also* lists the OAuth paths, contradicting the "separate domain" statement. Use the separate OAuth hosts above, which is what both the OAuth definition's server list and the help centre print. - A sandbox host `apisandbox.tracker-rms.com` appears in Tracker's Visual Studio quick-start; it is not one of the three production regions and should only be used with a sandbox account Tracker has created for you (§5). ## 4. Redirect URIs For **OAuth only** — the bearer-token model has no redirect. `redirect_uri` is required on the authorize call, and the token exchange must send the "Same `redirect_uri` used above." Register **every** callback host when you request the client secret. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` What TrackerRMS does **not** document anywhere: whether redirect URIs are registered at all, how many are allowed, the matching rule, whether wildcards or `http://localhost` are accepted, or how to add one later. **Do not assert any of it.** Assume exact string matching and no trailing-slash forgiveness, put the full list in the same email that asks for the secret, and ask TrackerRMS to confirm how a future addition is handled. Note also that the Unified.to hosts above are one-per-region for *your platform's* data centers, which is a separate axis from Tracker's US/CA/UK regions (§3). A customer in Tracker's Canada region can still be served by any of your data centers; do not conflate the two lists. ## 5. Obtaining the credentials ### Path A — Customer-generated bearer token (self-serve, available today) 1. The customer logs in to their Tracker database and **notes the server location in the URL** — that is the region (§3). 2. **Tools & Settings → Security Settings → API.** They copy the existing token, or generate a new one. 3. They give you the token **and** the region. Two facts to put in front of the customer at the same time, because they are the ones that cause support tickets: - **There is one token per database, and generating a new one invalidates the old one.** Tracker states it directly. So: if anything else already uses that database's API token — another vendor, an internal script, a partner integration — **do not let them press Generate.** Have them copy the existing token instead. And warn them that if they later generate a fresh token for someone else, your connection dies at that moment, with no notice. - **Who may see that screen is not documented.** Tracker's guidance on an adjacent settings screen is "If you don't have access to this please speak to your internal database administrator," and the same is likely true here — but it does not name a role or permission. If a customer contact reports they cannot see Security Settings, escalate to their Tracker administrator rather than guessing at a permission name. Tracker does not publish an expiry for this token. Absence of a documented expiry is not a guarantee of immortality — monitor for 401s on the exchange call, not just on data calls. ### Path B — OAuth 2.0 authorization code Only after TrackerRMS has issued a client secret. The documented flow, against the OAuth host for the region: - **Authorize** — `GET /oAuth2/Authorize` with `client_id`, `response_type=code`, `scope`, `redirect_uri` and `state`. All five are marked required in the machine-readable definition. The user authenticates in Tracker's UI and the code comes back to your redirect URI with the `state` you sent. - **Exchange** — `POST /oAuth2/Token`, **form URL-encoded**, with `grant_type=authorization_code`, `code`, `redirect_uri`, `client_id` and `client_secret`. - **Refresh** — same endpoint, `grant_type=refresh_token` with `refresh_token`. `refresh_token` is one of the two enumerated grant types. - **Then exchange again.** Tracker's own step 7: "Use this token and exchange the token at the auth end point of the api to get your JWT for making your calls" (§2). What Tracker does **not** document: refresh-token lifetime, whether the refresh token rotates, whether there is a revocation endpoint, and whether PKCE is supported. **Say so rather than filling the gaps.** The prudent build stores whatever the refresh response returns, refreshes on a schedule rather than lazily, and treats a failed refresh as "send the customer through the flow again." ### Path C — asking TrackerRMS for what you need One email usually covers all three of these; send it once rather than three times: - **A client secret** (and a definitive answer on `client_id`, §Platform state) — `clientsupport@tracker-rms.com`. - **A sandbox account** — same address, or live chat or a ticket. There is no self-serve sandbox, which means that without one, the only test tenant is a customer's production database. Say that out loud before anyone runs a create. - **A deeper integration conversation** — `trackerapi@tracker-rms.com`, with company, integration goals, the specific workflow and rough API call volumes. TrackerRMS publishes **no review timeline, no cost, no partner tiers and no SLA** for any of this. Do not invent one. Anything only a human can do — being given access to Tools & Settings, signing anything, replying to Tracker — hand back rather than looping. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the redirect URIs from §4, the scope strings from §6 — or a ready-to-send email for Path C, then continue once they report back with the token or the client secret. ## 6. Scopes Tracker documents exactly two, and the security definition on the OAuth endpoints lists nothing else: | Scope | Grants | | --- | --- | | `read` | Read access | | `write` | Write access | Rules and caveats: - **Comma-separated**, as Tracker's own example spells it: `read,write`. Not space-separated. - **`scope` is required** on the authorize call in the machine-readable definition, even though Tracker's prose walkthrough lists only `response_type`, `client_id`, `redirect_uri` and `state`. Send it. - **There is no per-object or per-module scope.** `read` is read across the API surface. A connector cannot express "candidates only" through scopes; if that granularity matters to a customer, it has to come from Tracker-side permissions, and Tracker does not document how those interact with API access. - **A read-only integration should ask for `read` alone.** It is the only least-privilege lever available here, so use it. - The bearer-token model has **no scope selection at all** — the token carries whatever the database's API access allows. Do not tell a customer they can scope it down; they cannot. ## 7. Rate limits, beta status, and the old API - **The two published figures disagree.** The API reference: "Tracker implements rate limiting on the API. Presently 600 requests per minute." The help centre's REST API overview: "Rate limit is ~100 requests/min." **Plan against 100**, confirm with TrackerRMS, and instrument for both. - Exceeding it returns **429 Too Many Requests**. Tracker does not document a `Retry-After` header or any rate-limit headers — do not rely on one; back off on your own schedule. - **Budget the JWT exchanges into the limit** (§2). An uncached exchange per operation roughly doubles your request count. - **Errors follow RFC 9457 (Problem Details).** Tracker also warns that "your client must be prepared to gracefully handle additional members of the response" — a beta API adding fields is expected, not exceptional. - **Beta means the surface moves.** "Beta version—stable but evolving," and the JWT lifetime is explicitly framed as a beta figure. Re-read the reference before a release rather than trusting a cached note. - **The old API is still up.** Tracker's July 2025 notice: the old connections "will still function but there will be no updates." If you inherit code or documentation referencing widget endpoints, `checkLogon`, a username/password credential or the "unique client API key," it belongs to that surface, not this one. - Tracker publishes a searchable rendering of the reference at the `/redoc` path on each API host, and per-area OpenAPI definitions behind the Swagger UI's definition selector — useful when you need a machine-readable schema rather than a page. ## 8. Verify end-to-end Do not stop at "the token came back." 1. **Exchange first.** `POST /api/Auth/ExchangeToken` on the region's API host with the credential, and confirm a JWT comes back. If this fails, nothing else will, and the cause is the credential or the host — not your query. 2. Make one real read with the JWT — not with the original token — and confirm a 200. 3. **Re-exchange and confirm the new JWT also works.** That is the path your connector will take on every 401. 4. Deliberately call the **wrong regional host** once, so you know what that failure looks like in your logs before a customer produces it (§3). 5. On OAuth: run authorize → callback → token → exchange → read, end to end, and **record what the token response actually looked like** (§ Platform state — the docs disagree). Then refresh and read again. 6. Confirm your connect flow captures the **region** as a first-class input, not an inference. 7. Watch request volume during a real sync, counting exchanges (§7). | Symptom | Cause | | --- | --- | | 401 on every data call, credential is definitely correct | Sending the customer's token or the OAuth access token directly instead of the exchanged JWT (§2) | | 401 after working for days | JWT expired — re-exchange; Tracker tells you to do this on any 401 (§2) | | 401 on the exchange call itself | Wrong credential (the Display Settings "API Key" rather than the Security Settings token), or the token was regenerated (§1, §5) | | Everything broke at once for one customer, nothing changed on your side | Someone generated a new token in that database; the old one was invalidated (§5) | | 404 on every call, credential is valid | Wrong regional host, or the OAuth host used for data calls (§3) | | Authorize URL rejected or looping | `scope` omitted — it is required in the machine-readable definition even though the prose omits it (§6) | | Token exchange returns something your client cannot parse | Vendor docs disagree on the response shape; log the raw body and compare (§ Platform state) | | `client_id` rejected, or accepted but unexpected | The fixed `EvoApi_1.0` literal versus a per-app client id — settle it with TrackerRMS (§ Platform state) | | Customer says there is no API section in settings | They lack access to Tools & Settings; escalate to their Tracker administrator (§5) | | 429 under light load | The real ceiling may be ~100/min, and uncached JWT exchanges double your call count (§2, §7) | | Responses contain fields your schema rejects | Beta API adding members; Tracker warns to handle them gracefully (§7) | | Instructions in an old runbook do not match anything | It describes the pre-July-2025 API surface (§7) | ## 9. Hand off — never commit the token - **Do not** write the bearer token, client secret, OAuth access token, refresh token or a JWT into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the human running this, for their secret store or console. The **region** is not a secret and may be recorded; the token is, and they usually arrive in the same message — separate them before you paste anything. - The bearer token is **the database's only API token** (§5). Treat a leak as an incident that also breaks every other integration on that database when it is rotated, and say so when you report it. - If a code change is needed (a callback host, a regional host, JWT caching), keep it secret-free and say plainly what the human must set out of band. - Report a secret once so it can be pasted into the secret store, say clearly that it is now in the transcript and can be regenerated — with the caveat above — then move on. - Close with: which credential model you ended up on; the **region** and both of its hostnames; the client ID if OAuth, and what TrackerRMS said about whether it is per-app; where the secret was delivered; the authorize, token and exchange endpoints; the scope string; whether the JWT is cached; whether anything else uses that database's token; the rate limit you are planning against; and whatever is still waiting on TrackerRMS. ## Stop and ask Hand back to a human rather than guessing when: **no client secret has been issued** and the ask is OAuth credentials (say so in the first reply — do not start a registration that cannot complete, because there is nowhere to register); TrackerRMS has not confirmed whether `client_id` is per-app or the shared `EvoApi_1.0` literal; the customer's database already has an API token in use by someone else and generating a new one would break it (§5); the only available test tenant is production and the task involves writes (§5); the connector sends the raw token rather than the exchanged JWT, or re-exchanges on every call (§2); a customer needs finer-grained permissions than `read`/`write` (§6); a promised sync cadence depends on which of the two published rate limits is real (§7); anything in scope depends on the pre-July-2025 API surface (§7); or the settings screens and the API reference do not match the **Platform state** section. ## References Verified to resolve on 2026-09-20. - Tracker API reference — UK and Rest of World (authentication, errors, rate limits, response codes) — https://evoglapi.tracker-rms.com/swagger/index.html - Tracker API reference — US — https://evousapi.tracker-rms.com/swagger/index.html - Tracker API reference — Canada — https://evocaapi.tracker-rms.com/swagger/index.html - Searchable rendering of the reference — https://evoglapi.tracker-rms.com/redoc - OAuth2 API definition (authorize/token parameters, scopes, regional servers) — https://evoglapi.tracker-rms.com/swagger/evoapi.oauth2/swagger.json - Authentication API definition (the token-for-JWT exchange) — https://evoglapi.tracker-rms.com/swagger/auth/swagger.json - Main API definition — https://evoglapi.tracker-rms.com/swagger/v1/swagger.json - REST API Information (regional docs, token location, JWT lifetime, rate limit, contact) — https://academy.tracker-rms.com/Home/Lesson/1272 - REST API Authorization (generating the bearer token; one token per database) — https://academy.tracker-rms.com/Home/Lesson/1273 - Connect your Project to the Tracker REST API (sandbox host, client generation) — https://academy.tracker-rms.com/Home/Lesson/1274 - Get Partner Integration API Key (the *other* key, in Display Settings) — https://academy.tracker-rms.com/Home/Lesson/1323 - Authentication (OAuth2 parameters, regional endpoints, sandbox request, old API notice) — https://academy.tracker-rms.com/Home/Lesson/355 - REST API help chapter index — https://academy.tracker-rms.com/Home/Search/?ChapterId=248 - TrackerRMS help centre — https://academy.tracker-rms.com/ - Sandbox API reference host — https://apisandbox.tracker-rms.com/swagger/index.html - TrackerRMS website — https://www.tracker-rms.com/ - TrackerRMS contact — https://www.tracker-rms.com/contact/ ## unified-accounting-invoicing URL: https://docs.unified.to/skills/unified-accounting-invoicing/SKILL.md # Build an invoicing system with the Unified.to Accounting API Unified.to normalizes many accounting platforms behind one REST API. Build your invoicing/bookkeeping integration once and it works across every supported platform. This skill teaches an agent how to build against the Unified Accounting API. ## When to use this skill Use this skill when the task is to: - Create and send invoices, and sync their status - Manage contacts (customers/vendors) and chart-of-accounts data - Record bills, expenses, credit memos, and payments - Pull financial reports (balance sheet, P&L, aged receivables) - Support more than one accounting platform without platform-specific code ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID**. 2. An activated accounting integration (e.g. QuickBooks) — start in the Sandbox. 3. A **connection ID** from the embedded Authorization component (https://docs.unified.to/concepts/embedded-components). Keep the API key server-side only. ## Core request pattern ``` https://api.unified.to/accounting/{connection_id}/{object} ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `api.unified.to` (NA), `api-eu.unified.to` (EU), `api-au.unified.to` (AU). ## Data objects (subset) | Object | Purpose | | --------------- | ----------------------------------------------- | | `invoice` | A customer invoice | | `bill` | A vendor bill | | `contact` | A customer or vendor | | `account` | A chart-of-accounts account | | `transaction` | A financial transaction | | `creditmemo` | A credit memo | | `expense` | An expense | | `taxrate` | A tax rate | | `journal` | A journal entry | Read-only report objects include `balancesheet`, `profitloss`, `cashflow`, `agedreceivable`, `agedpayable`, and `trialbalance`. ### Supported operations (invoice shown; most objects follow the same shape) | Method | Path | Description | | -------- | ------------------------------------ | ------------------ | | `GET` | `/accounting/{cid}/invoice` | List invoices | | `GET` | `/accounting/{cid}/invoice/{id}` | Get one invoice | | `POST` | `/accounting/{cid}/invoice` | Create an invoice | | `PATCH` | `/accounting/{cid}/invoice/{id}` | Update an invoice | | `DELETE` | `/accounting/{cid}/invoice/{id}` | Delete an invoice | The same verbs apply to `bill`, `contact`, `account`, `transaction`, `creditmemo`, `expense`, `taxrate`, and `journal`. Report objects are `GET`-only. Not every platform supports every object or field — check the **Feature Support** tab in `app.unified.to`. A `501` response means unsupported. ## Pagination, filtering & sorting - `limit` (max 100) and `offset` (zero-based). - `updated_gte=YYYY-MM-DDTHH:MM:SSZ` for incremental sync. - `sort` (`created_at`, `updated_at`, `name`) + `order` (`asc` / `desc`). ## Example: create and list invoices (fetch) ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }; // Ensure a customer contact exists const contactRes = await fetch(`${BASE}/accounting/${connectionId}/contact`, { method: 'POST', headers, body: JSON.stringify({ name: 'Globex Corp', emails: [{ email: 'ap@globex.com' }], is_customer: true, }), }); const contact = await contactRes.json(); // Create an invoice for that contact await fetch(`${BASE}/accounting/${connectionId}/invoice`, { method: 'POST', headers, body: JSON.stringify({ contact_id: contact.id, currency: 'USD', invoice_number: 'INV-1001', lineitems: [ { description: 'Consulting', quantity: 10, unit_amount: 150 }, ], }), }); // List recently updated invoices for reconciliation const invoices = await ( await fetch( `${BASE}/accounting/${connectionId}/invoice?updated_gte=2025-01-01T00:00:00Z&limit=100`, { headers } ) ).json(); ``` Field names differ slightly by platform; consult the invoice data model at https://docs.unified.to/accounting/invoice/model for the canonical fields. ## Example: with the official SDK ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { accountingInvoices } = await unified.accounting.listAccountingInvoices({ connectionId, limit: 100, }); ``` SDKs for Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. ## Keeping data in sync (recommended) - Register **webhooks** for `invoice`, `bill`, `contact`, and `transaction` events, and upsert by `id`. See https://docs.unified.to/reference/webhooks. - Sync into your own database for reporting and fast, consistent queries. ## Error handling | Code | Meaning & action | | ---- | ----------------------------------------------------------- | | 401 | Connection broken — re-authorize the end-user. | | 403 | Missing scopes — fix provider app + Unified.to scopes. | | 429 | Provider rate limit — back off; prefer webhook sync. | | 501 | Operation unsupported by platform — check Feature Support. | ## References - Accounting API overview & data model: https://docs.unified.to/accounting/overview - Invoice data model: https://docs.unified.to/accounting/invoice/model - REST basics: https://docs.unified.to/reference/rest - Pagination & filtering: https://docs.unified.to/reference/pagination - Source guide: https://docs.unified.to/guides/how_to_build_an_invoicing_system_with_unified ## unified-api URL: https://docs.unified.to/skills/unified-api/SKILL.md # Build against the Unified.to API Unified.to puts one REST API and one data model in front of more than 1,000 integrations. Address a specific customer's connected app with a `connection_id`; Unified.to maps the unified request to that provider's native API. Learn the shape once and it works for every provider in a category. This skill teaches an agent the fundamentals that every category API shares. ## When to use this skill Use this skill when the task is to: - Call the Unified.to REST API directly for any category - Authenticate requests and resolve a `connection_id` - List, retrieve, create, update, or delete unified records - Page, filter, sort, or trim responses across providers - Handle errors and per-provider capability differences For category-specific object shapes, also load the matching skill (e.g. `unified-crm`, `unified-ats-jobboard`, `unified-accounting-invoicing`). ## Prefer an SDK For most code, use an official SDK over hand-rolled HTTP — it is typed, handles auth, and matches this data model exactly. ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { crmContacts } = await unified.crm.listCrmContacts({ connectionId, limit: 20 }); ``` SDKs for Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. Fall back to raw REST (below) when no SDK is available. ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID** (`app.unified.to` → Settings → API Keys). Keep the API key server-side only. 2. An activated integration — start in the Sandbox. 3. A **connection ID** from the embedded Authorization component (https://docs.unified.to/concepts/embedded-components). ## Core concepts - **Workspace API key** — authenticates every call. Sent as a Bearer token. - **Connection** — one authorized link between a workspace and a customer's app instance. Its `id` is the `connection_id` in every data path. List connections with `GET /unified/connection`. - **Category** — the object family: `crm`, `ats`, `hris`, `accounting`, `commerce`, `messaging`, `ticketing`, `lms`, `calendar`, and more. - **Object** — the resource within a category: `contact`, `deal`, `candidate`, `invoice`, `message`, … ## Base URL Pick the data center the workspace lives in: - NA (default) — `https://api.unified.to` - EU — `https://api-eu.unified.to` - AU — `https://api-au.unified.to` ## Request shape ``` https://api.unified.to/{category}/{connection_id}/{object} ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` | Operation | Method & path | | --------- | ------------------------------------------------- | | List | `GET /{category}/{connection_id}/{object}` | | Retrieve | `GET /{category}/{connection_id}/{object}/{id}` | | Create | `POST /{category}/{connection_id}/{object}` | | Update | `PATCH /{category}/{connection_id}/{object}/{id}` | | Delete | `DELETE /{category}/{connection_id}/{object}/{id}` | Update is idempotent — safe to retry. When creating with `POST`, don't blindly retry a timeout without first listing to check whether the record landed. ```bash curl https://api.unified.to/crm/CONNECTION_ID/contact?limit=20 \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` ## Discovering data (start here) You cannot guess IDs. Always begin from a **list** endpoint to find records, then use the returned `id` for retrieve/update/delete. ## Listing: filter first, then page Filtering narrows the result set before pagination; reach for filters before paging through everything. - **Per-resource filters** — self-documenting, e.g. `?company_id=...`, `?contact_id=...`, `?type=...`. The available filters are listed per endpoint in the API reference. - `query=` — free-text search (e.g. an email or name) where the provider supports it. - `updated_gte=YYYY-MM-DDTHH:MM:SSZ` — return only records updated at/after this time (ideal for incremental sync). - `limit` (max 100) and `offset` (zero-based) — page through results. - `sort` (`name`, `created_at`, `updated_at`) and `order` (`asc` / `desc`). A returned count `< limit` means you are on the last page. List responses are a JSON array of objects. See https://docs.unified.to/reference/pagination. ## Field selection & the `raw` field Responses omit the bulky provider-specific `raw` blob by default. Request only the fields you need with `fields=` (a comma-delimited allow-list) to cut response size and token cost. To include the provider payload, request `fields=raw`, or pull nested values with `fields=raw.some_field`. ## Not every provider supports every operation An object, field, or operation valid in the unified model may be unsupported by a given provider. Check the **Feature Support** tab on the integration page in `app.unified.to`; a `501` response means the operation is unsupported. Treat capability as per-connection, not per-category. ## Error handling | Code | Meaning & action | | ---- | ------------------------------------------------------ | | 400 | Validation error — check the message for the field. | | 401 | Connection broken or bad key — re-authorize the user. | | 403 | Missing scopes / IP restriction — fix app + scopes. | | 404 | Record not found. | | 429 | Provider rate limit — back off; prefer webhook sync. | | 501 | Operation unsupported by provider — check Feature Support. | ## Keeping data in sync (recommended) - Register **webhooks** and upsert by `id`. See https://docs.unified.to/reference/webhooks and https://docs.unified.to/concepts/virtual_webhooks. - Sync into your own database for fast, provider-independent queries. ## References - REST basics: https://docs.unified.to/reference/rest - Pagination & filtering: https://docs.unified.to/reference/pagination - SDKs: https://docs.unified.to/reference/sdks - Webhooks: https://docs.unified.to/reference/webhooks - Embedded components (connections): https://docs.unified.to/concepts/embedded-components ## unified-assessment URL: https://docs.unified.to/skills/unified-assessment/SKILL.md # Build a candidate assessment product with the Unified.to Assessment API The Unified Assessment API lets assessment and background-check providers plug into ATS platforms that expose an assessment API. Recruiters request assessments without leaving their ATS; your product receives the order, delivers the assessment, and pushes results back — all through one API. This skill teaches an agent how to build against the Unified Assessment API. ## When to use this skill Use this skill when the task is to build an assessment provider that: - Publishes assessment packages that recruiters can pick inside their ATS - Receives assessment **orders** when a recruiter requests an assessment - Delivers the assessment to the candidate and computes results - Submits scores/status back to the ATS This is provider-side: the ATS initiates orders. (For reading candidates and jobs from an ATS, use the `unified-ats-jobboard` skill instead.) ## How the flow works 1. Your product exposes assessment **packages** (name, price, configuration) that the ATS displays to recruiters. 2. A recruiter selects a package for a candidate inside their ATS. This creates an **order** containing candidate, job, and application details. 3. Unified.to delivers that order to you via a **webhook** (no polling). 4. You deliver the assessment to the candidate and score it. 5. You **update the order** with results (score, status, completion time), and they appear back inside the recruiter's ATS. ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID**. 2. An assessment-only connection to a supported ATS (separate from a standard ATS integration) — start in the Sandbox. 3. A **connection ID** for that connection. 4. A **webhook** subscribed to assessment `order` events. Keep the API key server-side only. ## Core request pattern ``` https://api.unified.to/assessment/{connection_id}/{object} ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `api.unified.to` (NA), `api-eu.unified.to` (EU), `api-au.unified.to` (AU). ## Data objects | Object | Purpose | | --------- | -------------------------------------------------------------- | | `package` | An assessment package you offer (name, price, configuration) | | `order` | An order placed by a recruiter for a candidate + its results | ### Supported operations | Method | Path | Description | | -------- | ----------------------------------- | ---------------------------------- | | `GET` | `/assessment/{cid}/package` | List packages | | `GET` | `/assessment/{cid}/package/{id}` | Get one package | | `POST` | `/assessment/{cid}/package` | Create a package | | `PATCH` | `/assessment/{cid}/package/{id}` | Update a package | | `DELETE` | `/assessment/{cid}/package/{id}` | Delete a package | | `PATCH` | `/assessment/{cid}/order/{id}` | Update an order with results | | `PUT` | `/assessment/{cid}/order/{id}` | Replace an order with results | Orders are created by the recruiter in the ATS and delivered to you by webhook — you update them; you do not create them. ## Example: publish a package, then submit results (fetch) ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }; // 1. Publish an assessment package the ATS can show to recruiters await fetch(`${BASE}/assessment/${connectionId}/package`, { method: 'POST', headers, body: JSON.stringify({ name: 'Frontend Skills Assessment', description: 'HTML/CSS/JS take-home, ~45 min', }), }); // 2. When your webhook receives an order, deliver the assessment, then // submit the result back against that order id. async function submitResult(orderId, { score, status }) { await fetch(`${BASE}/assessment/${connectionId}/order/${orderId}`, { method: 'PATCH', headers, body: JSON.stringify({ status, // e.g. 'COMPLETED' score, // provider-defined score completed_at: new Date().toISOString(), }), }); } ``` Consult the order and package data models for exact fields: https://docs.unified.to/assessment/order/model and https://docs.unified.to/assessment/package/model. ## Receiving orders via webhook Register a webhook for the assessment `order` object so orders reach you in real time. See https://docs.unified.to/reference/webhooks and https://docs.unified.to/concepts/virtual_webhooks. On receipt: 1. Verify and parse the order payload (candidate, job, application, package). 2. Kick off your assessment delivery flow. 3. When done, `PATCH` the order with results (see example above). ## Official SDKs ```bash npm install @unified-api/typescript-sdk ``` SDKs for TypeScript, Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. ## Error handling | Code | Meaning & action | | ---- | ----------------------------------------------------------- | | 401 | Connection broken — re-authorize. | | 403 | Missing scopes — fix provider app + Unified.to scopes. | | 429 | Provider rate limit — back off and retry. | | 501 | Operation unsupported by the ATS — check Feature Support. | ## References - Assessment API overview & data model: https://docs.unified.to/assessment/overview - Webhooks: https://docs.unified.to/reference/webhooks - REST basics: https://docs.unified.to/reference/rest - Source guide: https://docs.unified.to/guides/how_to_build_a_candidate_assessment_product_with_unified ## unified-ats-jobboard URL: https://docs.unified.to/skills/unified-ats-jobboard/SKILL.md # Build a candidate sourcing or job board app with the Unified.to ATS API Unified.to normalizes many Applicant Tracking Systems behind one REST API. Build your recruiting/job-board integration once and it works across every supported ATS. This skill teaches an agent how to build against the Unified ATS API. ## When to use this skill Use this skill when the task is to: - Display open jobs on a careers page or job board - Source candidates and push them into a customer's ATS - Create applications and track application status - Upload resumes/documents or read interview and scorecard data - Support more than one ATS without platform-specific code ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID**. 2. An activated ATS integration (e.g. Greenhouse) — start in the Sandbox. 3. A **connection ID** from the embedded Authorization component (https://docs.unified.to/concepts/embedded-components). Keep the API key server-side only. ## Core request pattern ``` https://api.unified.to/ats/{connection_id}/{object} ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `api.unified.to` (NA), `api-eu.unified.to` (EU), `api-au.unified.to` (AU). ## Data objects | Object | Purpose | | ------------------- | ------------------------------------------------ | | `job` | An open position / job posting | | `candidate` | A candidate / applicant | | `application` | A candidate's application to a job | | `applicationstatus` | The set of application statuses (read-only) | | `interview` | An interview | | `scorecard` | Interview feedback / scorecard | | `activity` | An activity/note on a candidate | | `document` | A document (e.g. resume) attached to a candidate | | `company` | A company/organization | ### Supported operations (candidate shown; others follow the same shape) | Method | Path | Description | | -------- | -------------------------------- | ---------------------- | | `GET` | `/ats/{cid}/candidate` | List candidates | | `GET` | `/ats/{cid}/candidate/{id}` | Get one candidate | | `POST` | `/ats/{cid}/candidate` | Create a candidate | | `PATCH` | `/ats/{cid}/candidate/{id}` | Update a candidate | | `DELETE` | `/ats/{cid}/candidate/{id}` | Delete a candidate | The same verbs apply to `job`, `application`, `interview`, `scorecard`, `activity`, `document`, and `company`. `applicationstatus` is read-only. Not every ATS supports every object or field. Check the **Feature Support** tab in `app.unified.to`; a `501` response means the operation is unsupported. ## Pagination, filtering & sorting - `limit` (max 100) and `offset` (zero-based). - `updated_gte=YYYY-MM-DDTHH:MM:SSZ` for incremental sync. - `query=` filters by name or email (integration-specific). - `sort` (`created_at`, `updated_at`, `name`) + `order` (`asc` / `desc`). ## Example: list jobs and submit a candidate (fetch) ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }; // List open jobs for a job board const jobsRes = await fetch( `${BASE}/ats/${connectionId}/job?limit=50&sort=created_at&order=desc`, { headers } ); const jobs = await jobsRes.json(); // Create a candidate, then an application to a job const candRes = await fetch(`${BASE}/ats/${connectionId}/candidate`, { method: 'POST', headers, body: JSON.stringify({ name: 'Ada Lovelace', emails: [{ email: 'ada@example.com' }], }), }); const candidate = await candRes.json(); await fetch(`${BASE}/ats/${connectionId}/application`, { method: 'POST', headers, body: JSON.stringify({ candidate_id: candidate.id, job_id: 'JOB_ID', }), }); ``` ## Example: with the official SDK ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { atsJobs } = await unified.ats.listAtsJobs({ connectionId, limit: 50 }); ``` SDKs for Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. ## Keeping data in sync (recommended) - Register **webhooks** for `job`, `candidate`, and `application` events so your job board and pipeline stay current without polling. See https://docs.unified.to/reference/webhooks. - Upsert by `id` on each event. ## Error handling | Code | Meaning & action | | ---- | ----------------------------------------------------------- | | 401 | Connection broken — re-authorize the end-user. | | 403 | Missing scopes — fix provider app + Unified.to scopes. | | 429 | Provider rate limit — back off; prefer webhook sync. | | 501 | Operation unsupported by ATS — check Feature Support. | ## References - ATS API overview & data model: https://docs.unified.to/ats/overview - REST basics: https://docs.unified.to/reference/rest - Pagination & filtering: https://docs.unified.to/reference/pagination - Source guide: https://docs.unified.to/guides/how_to_build_a_candidate_sourcing_or_job_board_app_with_unified ## unified-auth-signin URL: https://docs.unified.to/skills/unified-auth-signin/SKILL.md # Add "Sign in with Unified" to your app with the Unified.to Authentication API The Unified Authentication API lets your users sign in to your application with OAuth2 or SAML providers (Google, Microsoft, and more). Unlike the rest of the Unified APIs, this flow is about **identity, not data connections** — you get a signed JWT with the user's verified name and emails. > Authentication-only integrations are for signing in users. Do **not** use them > to create data connections. To authorize customers and create connections, > use the standard authorization flow instead > (https://docs.unified.to/tutorials/customize-auth-flow). ## When to use this skill Use this skill when the task is to: - Add social / OAuth2 login (e.g. "Sign in with Google") - Add SAML single sign-on (SSO) - Verify a user's identity through a third-party provider ## Prerequisites 1. A Unified.to workspace with a **Workspace ID** and a **Workspace Secret** (`app.unified.to` → Settings → API). The **secret** is used to verify the JWT — keep it server-side only, never in the browser. 2. Activated auth/SAML integrations at https://app.unified.to/integrations?tab=auth (or `tab=saml`). ## The flow 1. **Show sign-in links.** Either configure the embedded sign-in widget at https://app.unified.to/settings/embed, or fetch the activated auth integrations and build a sign-in URL per provider. 2. **Redirect the user** to the sign-in URL. 3. **Verify the returned JWT** on your server using your Workspace Secret, then log the user in. ### 1. List activated auth integrations (optional) ``` GET https://api.unified.to/unified/integration?categories=auth Authorization: Bearer YOUR_API_KEY ``` Each result has at least `name`, `type`, and `logo_url`: ```json [{ "name": "Google", "type": "google", "logo_url": "https://api.unified.to/docs/images/google.png" }] ``` ### 2. Build the sign-in URL **OAuth2 login:** ``` https://api.unified.to/unified/integration/login/{workspace_id}/{integration_type}?redirect=true ``` **SAML SSO login:** ``` https://api.unified.to/unified/integration/saml/{workspace_id}/{integration_type}?redirect=true ``` Do **not** use `/unified/integration/auth/...` for sign-in. That path authorizes a data connection and returns a connection `id`, not a login JWT. Optional query parameters: | Parameter | Purpose | | ------------------ | ------------------------------------------------------------------------------------------------ | | `redirect=true` | Redirect the user. Omit to get the URL back as a string instead. | | `success_redirect` | Where to send the user after a successful sign-in. Host must be in `trusted_domains` when set. | | `failure_redirect` | Where to send the user on error. Same host restriction as `success_redirect`. | | `state` | An opaque string echoed back to `success_redirect` (e.g. a nonce). | Send the user to this URL (a link or a redirect) to start sign-in. ### Restrict redirect hosts (`trusted_domains`) Set workspace `trusted_domains` to the hostnames allowed for sign-in `success_redirect` and `failure_redirect` (for example `app.yourcompany.com`). When the list is set, Unified rejects a redirect to any other host (`400`). Redirects must be absolute URLs. An empty or unset list does not restrict hosts. Without `success_redirect`, Unified falls back to the page the user came from, so always pass it explicitly. Update the workspace with a user API key that belongs to a workspace admin, and include the workspace `name`: ```http PUT https://api.unified.to/unified/workspace Authorization: Bearer YOUR_API_KEY { "name": "Your workspace", "trusted_domains": ["app.yourcompany.com"] } ``` Entries are saved as lowercase hosts. Wildcards (`*.yourcompany.com`) are rejected; list each host. The same list also applies to connection authorization (`/unified/integration/auth/...`). ### 3. Verify the login (server-side) After a successful sign-in the user is redirected back (to `success_redirect`) with a `jwt` query parameter — a JWT signed with your **Workspace Secret**. It expires 10 minutes after it is issued, so exchange it for your own session immediately. Verify it on your server, then check that `redirect` is your callback and `state` matches the value you stored for this browser session: ```javascript import jwt from 'jsonwebtoken'; const EXPECTED_CALLBACK = new URL('https://app.yourcompany.com/login/callback'); // req.query.jwt came back on your success_redirect URL try { const user = jwt.verify(req.query.jwt, process.env.UNIFIED_WORKSPACE_SECRET); // also checks exp const landed = new URL(user.redirect); if (landed.origin !== EXPECTED_CALLBACK.origin || landed.pathname !== EXPECTED_CALLBACK.pathname) { throw new Error('Unexpected redirect'); } if (!user.state || user.state !== req.session.signinState) { throw new Error('Unexpected state'); } // user => { workspace_id, name, emails, state, redirect, iat, exp, jti } // Use a verified email to sign the user into your application. } catch (err) { // Invalid, expired or replayed token — reject the sign-in. console.error(err); } ``` Never verify the JWT in the browser — the Workspace Secret must stay on your server. ## Notes - The decoded JWT contains `workspace_id`, `name`, `emails`, `state`, `redirect` (the `success_redirect` URL used), and `iat` / `exp` / `jti`. Use a verified email to match or create the user account in your system. - For a customized authorization/connection flow (not sign-in), see https://docs.unified.to/tutorials/customize-auth-flow. - For OAuth2 background, see https://docs.unified.to/guides/understanding_oauth2_authorization_flows. ## References - Auth API overview: https://docs.unified.to/auth/overview - Embedded sign-in / authorization components: https://docs.unified.to/concepts/embedded-components - Source guide: https://docs.unified.to/guides/use_unified_to_sign_in_your_users_into_your_application ## unified-calendar URL: https://docs.unified.to/skills/unified-calendar/SKILL.md # Build a scheduling / calendar app with the Unified.to Calendar API Unified.to normalizes many calendar providers behind one REST API. Write your scheduling integration once and it works across every supported provider. This skill teaches an agent how to build against the Unified Calendar API. ## When to use this skill Use this skill when the task is to: - Read and create calendar events / meetings - Check a user's availability (busy times) before booking - Generate scheduling links - Manage webinars and access recordings ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID**. 2. An activated calendar integration (e.g. Google Calendar) — start in the Sandbox. 3. A **connection ID** from the embedded Authorization component (https://docs.unified.to/concepts/embedded-components). Keep the API key server-side only. ## Core request pattern ``` https://api.unified.to/calendar/{connection_id}/{object} ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `api.unified.to` (NA), `api-eu.unified.to` (EU), `api-au.unified.to` (AU). ## Data objects | Object | Purpose | | ----------- | --------------------------------------------- | | `calendar` | A calendar | | `event` | An event / meeting | | `busy` | Busy time blocks for availability (read-only) | | `link` | A scheduling link | | `webinar` | A webinar | | `recording` | A meeting/webinar recording (read-only) | ### Supported operations (event shown; calendar / link / webinar follow the same shape) | Method | Path | Description | | -------- | -------------------------------- | ------------------ | | `GET` | `/calendar/{cid}/event` | List events | | `GET` | `/calendar/{cid}/event/{id}` | Get one event | | `POST` | `/calendar/{cid}/event` | Create an event | | `PATCH` | `/calendar/{cid}/event/{id}` | Update an event | | `DELETE` | `/calendar/{cid}/event/{id}` | Delete an event | | `GET` | `/calendar/{cid}/busy` | Get busy times | The same verbs apply to `calendar`, `link`, and `webinar`. `busy` and `recording` are read-only. Not every provider supports every object or field. Check the **Feature Support** tab in `app.unified.to`; a `501` response means unsupported. ## Pagination & filtering - `limit` (max 100) and `offset` (zero-based). - Filter events by time window with `start_gte` and `end_le` (`YYYY-MM-DDTHH:MM:SSZ`), and use `updated_gte` for incremental sync. ## Example: check availability, then book (fetch) ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }; // Check busy times for a window const busy = await ( await fetch( `${BASE}/calendar/${connectionId}/busy?start_gte=2025-06-01T09:00:00Z&end_le=2025-06-01T17:00:00Z`, { headers } ) ).json(); // If the slot is free, create the event await fetch(`${BASE}/calendar/${connectionId}/event`, { method: 'POST', headers, body: JSON.stringify({ title: 'Intro call', start_at: '2025-06-01T15:00:00Z', end_at: '2025-06-01T15:30:00Z', attendees: [{ emails: [{ email: 'guest@example.com' }] }], }), }); ``` Consult the event data model at https://docs.unified.to/calendar/event/model for canonical fields. ## Example: with the official SDK ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { calendarEvents } = await unified.calendar.listCalendarEvents({ connectionId, limit: 100, }); ``` SDKs for Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. ## Keeping data in sync (recommended) - Register **webhooks** for `event` create/update/delete so bookings stay current without polling. See https://docs.unified.to/reference/webhooks and https://docs.unified.to/concepts/virtual_webhooks. ## Error handling | Code | Meaning & action | | ---- | ----------------------------------------------------------- | | 401 | Connection broken — re-authorize the end-user. | | 403 | Missing scopes — fix provider app + Unified.to scopes. | | 429 | Provider rate limit — back off; prefer webhook sync. | | 501 | Operation unsupported by provider — check Feature Support. | ## References - Calendar API overview & data model: https://docs.unified.to/calendar/overview - Event data model: https://docs.unified.to/calendar/event/model - REST basics: https://docs.unified.to/reference/rest - Pagination & filtering: https://docs.unified.to/reference/pagination ## unified-crm URL: https://docs.unified.to/skills/unified-crm/SKILL.md # Build a CRM / sales-sync app with the Unified.to CRM API Unified.to normalizes many CRMs behind one REST API. Write your CRM integration once and it works across every supported platform. This skill teaches an agent how to build against the Unified CRM API. ## When to use this skill Use this skill when the task is to: - Read or write contacts and companies - Manage deals and pipelines (create, move stage, close) - Capture and route leads - Two-way sync sales data across more than one CRM ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID** (`app.unified.to` → Settings → API Keys). 2. An activated CRM integration (e.g. HubSpot) — start in the Sandbox. 3. A **connection ID** from the embedded Authorization component (https://docs.unified.to/concepts/embedded-components). Keep the API key server-side only. ## Core request pattern ``` https://api.unified.to/crm/{connection_id}/{object} ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `api.unified.to` (NA), `api-eu.unified.to` (EU), `api-au.unified.to` (AU). ## Data objects | Object | Purpose | | ---------- | ------------------------------------------- | | `contact` | A person | | `company` | An organization / account | | `deal` | A deal / opportunity | | `lead` | A lead | | `pipeline` | A sales pipeline and its stages | | `event` | An activity/event logged against a record | | `taxonomy` | Enumerated field options (read-only) | ### Supported operations (contact shown; others follow the same shape) | Method | Path | Description | | -------- | ---------------------------- | ------------------ | | `GET` | `/crm/{cid}/contact` | List contacts | | `GET` | `/crm/{cid}/contact/{id}` | Get one contact | | `POST` | `/crm/{cid}/contact` | Create a contact | | `PATCH` | `/crm/{cid}/contact/{id}` | Update a contact | | `DELETE` | `/crm/{cid}/contact/{id}` | Delete a contact | The same verbs apply to `company`, `deal`, `lead`, `pipeline`, and `event`. `taxonomy` is read-only. Not every CRM supports every object or field. Check the **Feature Support** tab on the integration page in `app.unified.to`; a `501` response means the operation is unsupported. ## Pagination, filtering & sorting - `limit` (max 100) and `offset` (zero-based) for paging. - `updated_gte=YYYY-MM-DDTHH:MM:SSZ` for incremental sync. - `query=` filters by name or email (integration-specific). - `sort` (`created_at`, `updated_at`, `name`) + `order` (`asc` / `desc`). Returned count `< limit` means you are on the last page. ## Example: read and create contacts (fetch) ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }; // List recently updated contacts for a sync const list = await fetch( `${BASE}/crm/${connectionId}/contact?updated_gte=2025-01-01T00:00:00Z&limit=100`, { headers } ); const contacts = await list.json(); // Create a contact await fetch(`${BASE}/crm/${connectionId}/contact`, { method: 'POST', headers, body: JSON.stringify({ name: 'Grace Hopper', emails: [{ email: 'grace@example.com' }], telephones: [{ telephone: '+1-555-0100', type: 'WORK' }], }), }); ``` Field names differ slightly by CRM; consult the contact data model at https://docs.unified.to/crm/contact/model for the canonical fields. ## Example: with the official SDK ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { crmContacts } = await unified.crm.listCrmContacts({ connectionId, limit: 100 }); ``` SDKs for Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. ## Keeping data in sync (recommended) - Register **webhooks** for `contact`, `company`, `deal`, and `lead` events and upsert by `id`. See https://docs.unified.to/reference/webhooks and https://docs.unified.to/concepts/virtual_webhooks. - Sync into your own database for fast, consistent, CRM-independent queries. ## Error handling | Code | Meaning & action | | ---- | ----------------------------------------------------------- | | 401 | Connection broken — re-authorize the end-user. | | 403 | Missing scopes — fix provider app + Unified.to scopes. | | 429 | Provider rate limit — back off; prefer webhook sync. | | 501 | Operation unsupported by CRM — check Feature Support. | ## References - CRM API overview & data model: https://docs.unified.to/crm/overview - Contact data model: https://docs.unified.to/crm/contact/model - REST basics: https://docs.unified.to/reference/rest - Pagination & filtering: https://docs.unified.to/reference/pagination ## unified-debug-webhooks-connections URL: https://docs.unified.to/skills/unified-debug-webhooks-connections/SKILL.md # Debug Unified.to webhooks & connections When an integration stops working, the cause is almost always an unhealthy **connection** (credentials/scopes) or an unhealthy **webhook** (delivery or read failure). This skill teaches an agent how to diagnose and fix both, based on Unified.to's troubleshooting guides. ## When to use this skill - A connection is marked **Unhealthy** / **Unhealthy now**, or stopped returning data - A webhook is not firing, is delayed, or is marked **unhealthy** - Webhook **signature validation** is failing - You need to decide between polling, native webhooks, and virtual webhooks ## First: is it the connection or the webhook? A webhook can only be as healthy as the connection it reads from. **Check the connection first** — 401/403 errors reported against a webhook are really connection problems. Fix the connection, and the webhook usually recovers. --- ## Part 1 — Debugging unhealthy connections A connection is **unhealthy** when a provider no longer accepts its credentials or access. Transient issues (rate limits, temporary 5xx) do **not** flip health. ### Health statuses | Status | Meaning | | --------------- | ------------------------------------------------------------------- | | `Healthy` | A recent API call or OAuth token refresh succeeded | | `Unhealthy` | Provider rejected credentials/access; no success since | | `Unhealthy now` | Recently failed after previously succeeding | | `New` | Created but not yet used | ### Diagnose 1. Open the **Connections** dashboard in your Unified.to workspace. 2. Find the connection marked `Unhealthy` / `Unhealthy now` and note its **connection ID**. 3. Open **API Call Logs** and filter by that connection ID (logs are retained for the **past 60 days**). 4. Open the latest failing call and read its **status code** and description. ### Fix by status code | Code | Cause | Fix | | ------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | **401** | App access revoked, or the customer entered wrong credentials | Verify access, confirm credentials, then **re-authorize the same connection** (see below). | | **403** | Scope mismatch between the provider app and Unified.to | Make the provider developer-app scopes match Unified.to; update your embedded/auth URL scopes. | | **404** | Missing/incorrect input data (note: Enrichment treats 404 as success) | Validate the input data you sent. | | **429** | Provider rate limit exceeded | Throttle, switch to webhooks for sync, or request higher limits. | | **5xx** | Temporary provider outage or bug | Check the provider status page, retry with backoff, contact support if it persists. | ### Detect proactively Subscribe to the **Notifications** webhook (Workspace Settings) for real-time `CONNECTION_UNHEALTHY` and `CONNECTION_HEALTHY` events so you can prompt the end-user to re-authorize before they notice data is stale. See https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections. ### Re-authorize in place (keep the connection ID) Do not create a new connection to fix expired or revoked credentials; that changes the connection ID and orphans webhooks and stored IDs. Instead: 1. Backend (API key): `POST /unified/connection/{connection_id}/reauth` returns `{ reauth_token, expires_at }` (single-use, expires in 30 minutes). 2. Send the end-user to the normal authorization URL with `connection_id={connection_id}&reauth_token={reauth_token}` added. 3. On success the same `id` comes back on `success_redirect`, and a `CONNECTION_UPDATED` (plus `CONNECTION_HEALTHY`) event fires. 4. `error=authorization error: invalid or expired re-authorization link` means the token is missing, expired, already used, or for a different connection; create a new token. See https://docs.unified.to/concepts/embedded-components#re-authorize-an-existing-connection. --- ## Part 2 — Debugging unhealthy webhooks ### Failure modes | Failure | Meaning | | -------------------------- | ------------------------------------------------------------------------- | | **Failed to subscribe** | Creation failed — integration unavailable, missing permissions, or a bug (native webhooks only) | | **Failed to refresh token**| Could not reach the provider to retrieve event data | | **Failed to read** | Could not retrieve new data from the provider (config/integration error) | | **Failed to dispatch data**| **Your** server did not respond, or returned a non-2xx, to the webhook POST | | **Failed to process data** | A problem in the read or dispatch step | ### Diagnose 1. Open the **Webhooks** section in your workspace and note the webhook's **connection ID**. 2. Open **API Call Logs**, filter by that connection ID, and open the latest error to read the status code and description (60-day retention). Common codes: **400** = integration-level issue (needs vendor support); **401/403** = connection credential/scope problems (see Part 1), not the webhook itself. ### Retry behavior (know this before you "lose" an event) - **Your endpoint unavailable:** up to **3 immediate retries** (1s apart), then **Fibonacci backoff** (1, 2, 3, 5, 8 … minutes) continuing for **up to 2 weeks**. - **Provider unavailable:** Fibonacci backoff starting at 1-minute delays. So a temporarily failing endpoint is not a lost event — make your handler **idempotent** and return `200 OK` quickly. Do heavy work asynchronously. ### Detect proactively Subscribe to the **Notifications** webhook for the `WEBHOOK_UNHEALTHY` event. --- ## Part 3 — Webhook signature validation (a top cause of "webhooks not working") If your endpoint rejects valid deliveries, signature mismatch is usually why. The `sig256` field is `HMAC-SHA256(workspace_secret, data + nonce)`, base64 encoded — where `data` is the exact `data` array from the payload body and `nonce` is its string. (`sig` is the deprecated SHA-1 variant; use `sig256`.) Rules that break signatures if ignored: 1. **Preserve field order** of `data` exactly as received. In Go, unmarshal `data` as `json.RawMessage` — a `map[string]interface{}` reorders keys. 2. **No extra whitespace.** Re-serialize compactly. In Python use `json.dumps(data['data'], separators=(',', ':'), ensure_ascii=False)`. 3. Use a **timing-safe** comparison (`compare_digest` / `timingSafeEqual` / `subtle.ConstantTimeCompare`). 4. The **workspace secret** (app.unified.to → Settings → API) is server-side only — never validate in the browser. ```typescript import { createHmac, timingSafeEqual } from 'crypto'; function validateWebhook(workspaceSecret: string, body: { data: any[]; nonce: string; sig256: string }) { const computed = Buffer.from( createHmac('sha256', workspaceSecret) .update(JSON.stringify(body.data)) // must match the sender's encoding/order .update(body.nonce) .digest('base64'), 'utf8' ); const provided = Buffer.from(body.sig256, 'utf8'); return computed.length === provided.length && timingSafeEqual(computed, provided); } ``` ### Data webhook payload fields `data` (array), `webhook`, `nonce`, `sig` (deprecated SHA-1), `sig256`, `external_xref` (the value you set on the connection, echoed back), and `type`: - `INITIAL-PARTIAL` — each page of the initial sync - `INITIAL-COMPLETE` — last page (or empty list) of the initial sync - `VIRTUAL` — each page from a virtual webhook - `NATIVE` — each page from a native webhook --- ## Part 4 — Native vs virtual webhooks (when "it never fires") If an integration has no native webhook support, use a **virtual webhook** — Unified.to polls the provider on an interval you set and posts changes to your URL, so you consume it exactly like a native webhook. | | Native | Virtual | | --------------- | ----------------------------------- | ---------------------------------------------- | | Delivery | Immediate push | Scheduled polling (≥ ~1 min), interval you set | | Events | created, updated, deleted (if supported) | mainly created & updated | | Retry / health | per-integration | built-in read + dispatch retry & health tracking | Check support: integration page → **Feature Support** → **Webhooks** (rows are labeled `native` or `virtual`). If you expected instant delivery but the integration is virtual-only, the delay is expected — lower the interval or accept the latency. See https://docs.unified.to/concepts/virtual_webhooks. --- ## Debug checklist 1. Reproduce and capture the exact **status code** from API Call Logs (filter by connection ID). 2. `401/403` → fix the **connection** (credentials/scopes), re-authorize it in place, retry. 3. Webhook not firing → confirm the integration supports the event type (native vs virtual) and that the connection is healthy. 4. Deliveries rejected → verify **signature** (order, whitespace, secret) and that your endpoint returns `200` fast and is idempotent. 5. `429`/`5xx` → transient; rely on retries/backoff, don't flip to polling loops. 6. Subscribe to `CONNECTION_UNHEALTHY` / `WEBHOOK_UNHEALTHY` notifications to catch failures early. ## References - Introduction to webhooks (payload, signatures): https://docs.unified.to/reference/webhooks - Virtual vs native webhooks: https://docs.unified.to/concepts/virtual_webhooks - Create & configure webhooks: https://docs.unified.to/guides/how_to_create_and_configure_webhooks - Filter webhook events: https://docs.unified.to/guides/how_to_filter_webhook_events - Troubleshoot unhealthy connections: https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections - Troubleshoot unhealthy webhooks: https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks - HTTP status codes: https://docs.unified.to/reference/rest ## unified-ecommerce URL: https://docs.unified.to/skills/unified-ecommerce/SKILL.md # Build an e-commerce product integration with the Unified.to Commerce API Unified.to normalizes many commerce platforms behind one REST API, so you write your catalog/inventory integration once and it works across every supported storefront. This skill teaches an agent how to build against the Unified Commerce API. ## When to use this skill Use this skill when the task is to: - Sync a product catalog (items and their variants) - Read or update inventory levels across locations - Manage collections and sales channels - Support more than one storefront without platform-specific code ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID**. 2. An activated commerce integration (e.g. Shopify) — use the Sandbox first. 3. A **connection ID** from the embedded Authorization component (https://docs.unified.to/concepts/embedded-components). Keep the API key server-side only. ## Core request pattern ``` https://api.unified.to/commerce/{connection_id}/{object} ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `api.unified.to` (NA), `api-eu.unified.to` (EU), `api-au.unified.to` (AU). ## Data objects | Object | Purpose | | -------------- | ---------------------------------------------------- | | `item` | A product | | `itemvariant` | A variant (size/color/SKU) of a product | | `collection` | A grouping of products | | `inventory` | Inventory levels for an item/variant at a location | | `location` | A store/warehouse location | | `saleschannel` | A sales channel | | `reservation` | An inventory reservation | | `review` | A product review | | `availability` | Product availability (read-only) | ### Supported operations (item shown; others follow the same shape) | Method | Path | Description | | -------- | ----------------------------------- | -------------------- | | `GET` | `/commerce/{cid}/item` | List products | | `GET` | `/commerce/{cid}/item/{id}` | Get one product | | `POST` | `/commerce/{cid}/item` | Create a product | | `PATCH` | `/commerce/{cid}/item/{id}` | Update a product | | `DELETE` | `/commerce/{cid}/item/{id}` | Delete a product | The same `GET / POST / PATCH / DELETE` verbs apply to `itemvariant`, `collection`, `inventory`, `location`, `saleschannel`, `reservation`, and `review`. `availability` is read-only. Not every provider supports every object or field — check the **Feature Support** tab on the integration page in `app.unified.to`. A `501` response means the operation is unsupported for that provider. ## Pagination, filtering & sorting - `limit` (max 100) and `offset` (zero-based) for paging. - `updated_gte=YYYY-MM-DDTHH:MM:SSZ` for incremental sync. - `sort` (`created_at`, `updated_at`, `name`) + `order` (`asc` / `desc`). Returned count `< limit` means you are on the last page. ## Example: sync a product catalog (fetch) ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }; // Page through all products, newest first async function* allItems(connectionId) { let offset = 0; const limit = 100; while (true) { const res = await fetch( `${BASE}/commerce/${connectionId}/item?limit=${limit}&offset=${offset}`, { headers } ); const page = await res.json(); yield* page; if (page.length < limit) break; offset += limit; } } // Create a product await fetch(`${BASE}/commerce/${connectionId}/item`, { method: 'POST', headers, body: JSON.stringify({ name: 'Classic Tee', description: '100% cotton', is_active: true, }), }); ``` ## Example: with the official SDK ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { commerceItems } = await unified.commerce.listCommerceItems({ connectionId, limit: 100, }); ``` SDKs for Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. ## Keeping data in sync (recommended) For catalog and inventory data, sync into your own database rather than polling: - Register **webhooks** for `item`, `itemvariant`, and `inventory` events. See https://docs.unified.to/reference/webhooks. - Upsert by `id` on each event. This makes local queries fast, consistent, and provider-independent. ## Error handling | Code | Meaning & action | | ---- | ---------------------------------------------------------------- | | 401 | Connection broken — re-authorize the end-user. | | 403 | Missing scopes — fix provider app + Unified.to scopes. | | 429 | Provider rate limit — back off; prefer webhook sync. | | 501 | Operation unsupported by provider — check Feature Support. | ## References - Commerce API overview & data model: https://docs.unified.to/commerce/overview - REST basics: https://docs.unified.to/reference/rest - Pagination & filtering: https://docs.unified.to/reference/pagination - Source guide: https://docs.unified.to/guides/how_to_build_an_e_commerce_product_integration_with_unified ## unified-enterprise-search-rag URL: https://docs.unified.to/skills/unified-enterprise-search-rag/SKILL.md # Build enterprise search with RAG using the Unified.to Storage & KMS APIs Retrieval-Augmented Generation needs fresh, normalized content from wherever a customer keeps it. Unified.to gives you one API across many file stores (Storage API: Google Drive, SharePoint, Box, S3…) and knowledge bases (KMS API: Notion, Confluence…). This skill teaches an agent how to build a RAG ingestion pipeline against these APIs. ## When to use this skill Use this skill when the task is to: - Ingest a customer's documents/pages into a vector database - Build enterprise or knowledge search over connected sources - Ground an LLM (RAG) on real, up-to-date customer data - Keep the index current in real time as source content changes ## Architecture ``` Source apps ──► Unified.to (Storage / KMS API) ──► your ingestion worker │ chunk + embed ◄─────────┘ │ ▼ vector DB (Pinecone, pgvector, Weaviate, …) │ user query ──► embed ──► similarity search ──► LLM answer ``` ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID**. 2. Activated Storage and/or KMS integrations — start in the Sandbox. 3. A **connection ID** per connected source (https://docs.unified.to/concepts/embedded-components). 4. An embedding model and a vector database of your choice. Keep the API key server-side only. ## Core request pattern ``` https://api.unified.to/storage/{connection_id}/file # files https://api.unified.to/kms/{connection_id}/page # knowledge-base pages https://api.unified.to/kms/{connection_id}/space # spaces / collections ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `api.unified.to` (NA), `api-eu.unified.to` (EU), `api-au.unified.to` (AU). ## Data objects | API | Object | Purpose | | ------- | --------- | ---------------------------------------------- | | Storage | `file` | A file/document in a connected store | | KMS | `space` | A space / collection of pages | | KMS | `page` | A knowledge-base page (title + content) | | KMS | `comment` | A comment on a page | ### Supported operations | Method | Path | Description | | ------ | ----------------------------- | ------------------ | | `GET` | `/storage/{cid}/file` | List files | | `GET` | `/storage/{cid}/file/{id}` | Get one file | | `GET` | `/kms/{cid}/space` | List spaces | | `GET` | `/kms/{cid}/page` | List pages | | `GET` | `/kms/{cid}/page/{id}` | Get one page | Both objects also support `POST / PATCH / DELETE` when you need to write back. For RAG ingestion you mostly `GET`. Check the **Feature Support** tab in `app.unified.to`; a `501` means unsupported. ## Ingestion: page through content ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}` }; async function* allPages(connectionId) { let offset = 0; const limit = 100; while (true) { const res = await fetch( `${BASE}/kms/${connectionId}/page?limit=${limit}&offset=${offset}`, { headers } ); const page = await res.json(); yield* page; if (page.length < limit) break; offset += limit; } } // Ingest each page into the vector DB for await (const doc of allPages(connectionId)) { const chunks = chunk(doc.content ?? ''); // your chunker const vectors = await embed(chunks); // your embedding model await vectorDb.upsert( vectors.map((v, i) => ({ id: `${doc.id}:${i}`, values: v, metadata: { source_id: doc.id, title: doc.title, url: doc.web_url }, })) ); } ``` For Storage files, list with `/storage/{cid}/file`, download the file content, extract text, then chunk/embed/upsert the same way. ## Incremental & real-time sync Do not re-ingest everything on a schedule. Keep the index fresh cheaply: - **Incremental:** poll with `updated_gte=YYYY-MM-DDTHH:MM:SSZ` to fetch only content changed since your last run. - **Real-time:** register **webhooks** for `file` and `page` create/update/delete events and upsert/delete the affected vectors on each event. See https://docs.unified.to/reference/webhooks and https://docs.unified.to/concepts/virtual_webhooks. Always store the source `id` in vector metadata so updates and deletes map back to the right vectors. ## Official SDKs ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { kmsPages } = await unified.kms.listKmsPages({ connectionId, limit: 100 }); ``` SDKs for Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. ## Error handling | Code | Meaning & action | | ---- | ----------------------------------------------------------- | | 401 | Connection broken — re-authorize the end-user. | | 403 | Missing scopes — fix provider app + Unified.to scopes. | | 429 | Provider rate limit — back off; prefer webhook sync. | | 501 | Operation unsupported by source — check Feature Support. | ## References - KMS API overview & data model: https://docs.unified.to/kms/overview - Storage API overview & data model: https://docs.unified.to/storage/overview - Webhooks & virtual webhooks: https://docs.unified.to/reference/webhooks - REST basics: https://docs.unified.to/reference/rest - Source guides: https://docs.unified.to/guides/how_to_build_enterprise_search_using_rag and https://docs.unified.to/guides/retrieval_augmented_generation_rag ## unified-hris URL: https://docs.unified.to/skills/unified-hris/SKILL.md # Build an HR / employee-data app with the Unified.to HRIS API Unified.to normalizes many HRIS platforms behind one REST API. Write your HR integration once and it works across every supported platform. This skill teaches an agent how to build against the Unified HRIS API. ## When to use this skill Use this skill when the task is to: - Sync an employee directory and org structure (groups/departments) - Read or write time off, attendance, and time shifts - Read payroll data (payslips, deductions, benefits) - Onboard or provision employees across more than one HR system ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID**. 2. An activated HRIS integration (e.g. BambooHR) — start in the Sandbox. 3. A **connection ID** from the embedded Authorization component (https://docs.unified.to/concepts/embedded-components). Keep the API key server-side only. ## Core request pattern ``` https://api.unified.to/hris/{connection_id}/{object} ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `api.unified.to` (NA), `api-eu.unified.to` (EU), `api-au.unified.to` (AU). ## Data objects (subset) | Object | Purpose | | ------------ | ------------------------------------------------ | | `employee` | An employee | | `group` | A department / team / group | | `company` | A company / legal entity | | `location` | A work location | | `timeoff` | A time-off request / balance | | `attendance` | An attendance record | | `timeshift` | A scheduled shift | | `benefit` | A benefit enrollment | | `deduction` | A payroll deduction | | `document` | A document attached to an employee | Read-only objects include `payslip` and `taxonomy`. ### Supported operations (employee shown; most objects follow the same shape) | Method | Path | Description | | -------- | ----------------------------- | ------------------ | | `GET` | `/hris/{cid}/employee` | List employees | | `GET` | `/hris/{cid}/employee/{id}` | Get one employee | | `POST` | `/hris/{cid}/employee` | Create an employee | | `PATCH` | `/hris/{cid}/employee/{id}` | Update an employee | | `DELETE` | `/hris/{cid}/employee/{id}` | Delete an employee | The same verbs apply to `group`, `company`, `location`, `timeoff`, `attendance`, `timeshift`, `benefit`, `deduction`, and `document`. Not every platform supports every object or field — check the **Feature Support** tab in `app.unified.to`. A `501` response means unsupported. ## Pagination, filtering & sorting - `limit` (max 100) and `offset` (zero-based). - `updated_gte=YYYY-MM-DDTHH:MM:SSZ` for incremental sync. - `sort` (`created_at`, `updated_at`, `name`) + `order` (`asc` / `desc`). ## Example: sync employees and read time off (fetch) ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }; // Page through the employee directory async function* allEmployees(connectionId) { let offset = 0; const limit = 100; while (true) { const res = await fetch( `${BASE}/hris/${connectionId}/employee?limit=${limit}&offset=${offset}`, { headers } ); const page = await res.json(); yield* page; if (page.length < limit) break; offset += limit; } } // Read time-off for a given employee const timeoff = await ( await fetch(`${BASE}/hris/${connectionId}/timeoff?employee_id=EMPLOYEE_ID`, { headers }) ).json(); ``` Consult the employee data model at https://docs.unified.to/hris/employee/model for canonical fields. ## Example: with the official SDK ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { hrisEmployees } = await unified.hris.listHrisEmployees({ connectionId, limit: 100 }); ``` SDKs for Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. ## Keeping data in sync (recommended) - Register **webhooks** for `employee`, `group`, and `timeoff` events and upsert by `id`. See https://docs.unified.to/reference/webhooks. - Sync into your own database for fast, consistent, platform-independent queries. ## Error handling | Code | Meaning & action | | ---- | ----------------------------------------------------------- | | 401 | Connection broken — re-authorize the end-user. | | 403 | Missing scopes — fix provider app + Unified.to scopes. | | 429 | Provider rate limit — back off; prefer webhook sync. | | 501 | Operation unsupported by platform — check Feature Support. | ## References - HRIS API overview & data model: https://docs.unified.to/hris/overview - Employee data model: https://docs.unified.to/hris/employee/model - Accessing employees and users: https://docs.unified.to/guides/how_to_access_employees_and_users - REST basics: https://docs.unified.to/reference/rest - Pagination & filtering: https://docs.unified.to/reference/pagination ## unified-mcp-connection URL: https://docs.unified.to/skills/unified-mcp-connection/SKILL.md # Use the Unified.to MCP server for a connection In **connection mode** the Unified MCP server exposes one end-customer connection's integration-specific tools — for a HubSpot connection, tools to list and create CRM contacts, deals and companies; for Greenhouse, jobs, candidates and applications. Which tools appear depends on what the integration supports and which permissions the connection was authorized with. Each tool call counts as one API request on the Unified.to plan. Managing the workspace itself (listing connections, webhooks, logs) is **Core mode** — see the `unified-mcp-core` skill (https://docs.unified.to/skills/unified-mcp-core/SKILL.md). Source of truth: https://docs.unified.to/mcp/overview, /mcp/authentication, /mcp/server-options and /mcp/installation. When in doubt, check them (or `search_docs` on the Core MCP server) instead of guessing. ## Step 1: Gather the inputs 1. **Connection ID** — created when the end-user authorized an integration (see the `unified-quickstart` skill). Usually stored against the app's user/account. If the Core MCP server is connected, `list_unified_connections` (filter by `external_xref` or `categories`) finds it. 2. **Region** — the host must match the workspace's region: | Region | Endpoint | | ------ | ----------------------------------- | | US | `https://mcp-api.unified.to/mcp` | | EU | `https://mcp-api-eu.unified.to/mcp` | | AU | `https://api-au.unified.to/mcp` | Streamable HTTP at `/mcp` is the only transport; `/sse` returns `410`. 3. **Who holds the URL** — the developer's backend, or the end-customer (or a third-party LLM client acting for them). This decides authentication. ## Step 2: Choose authentication | Option | URL / headers | When | | ------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | **Workspace key + connection** | `…/mcp?connection=` with `Authorization: Bearer ` | The developer's own server calls the LLM API. **Never** expose it — the key grants the whole account | | **Signed user token** | `…/mcp?token=--` | The URL is given to an end-customer or pasted into Claude.ai / Cursor. Grants only that connection | | **Enterprise-managed (`ema_`)** | `Authorization: Bearer ema_…`, optional `?connection=` | An enterprise customer's IdP decides who gets access. Enabled per workspace by Unified.to | Get a signed user token URL from Core MCP `get_unified_connection_mcp_url` with `mode: "user"`, or from the Unified API. Rules: - Put credentials in the `Authorization` header, not `?token=`, wherever the client allows it (`?token=` is deprecated). The signed user-mode URL is the exception — it is designed to be pasted as-is. - All other options are URL query parameters. - Keep the workspace key server-side in an env var (`UNIFIED_API_KEY`); never send it to a browser, log it, or commit it. **Enterprise-managed tokens:** exchange the IdP's ID-JAG at `POST /oauth/token` (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, `assertion`, optional `scope` such as `crm.read ats.write` or `*`). The assertion's `resource` claim must be the MCP server's resource identifier — read it from `/.well-known/oauth-protected-resource/mcp` rather than hard-coding it. A token minted for MCP is rejected by the REST API and vice versa. Scopes filter the tool list; passthrough, auth, saml, scim and metadata tools (including `include_external_tools`) are unreachable, and Core management tools are never available to an `ema_` token. ## Step 3: Limit the tools Most models degrade badly with many tools (some cap at 10–20). Always narrow the set with URL parameters: | Parameter | Effect | | ------------------------ | ------------------------------------------------------------------------------------------ | | `permissions` | Comma list of Unified scopes, e.g. `crm_contact_read,crm_deal_read` | | `tools` | Comma list of tool IDs (wildcards allowed via `get_unified_connection_mcp_url`), e.g. `list_*` | | `hide_sensitive=true` | Strips PII (names, emails, phones, …) from results before the model sees them | | `defer_tools` | `all` or tool IDs the client should load lazily | | `aliases` | `word:alias1,alias2;word2:alias3`, e.g. `employee:user,person` for better matching | | `include_external_tools` | Also expose the vendor's raw API as tools (more power, more tools) | Start read-only (`*_read` permissions) unless the feature must write, and add `hide_sensitive=true` when the use case doesn't need personal data. To see exactly what a URL exposes, call `tools/list` over MCP or `GET /tools` (below). ## Step 4: Wire it into the client ### Anthropic Messages API (MCP connector — no tool loop needed) ```typescript const response = await anthropic.beta.messages.create({ model: 'claude-opus-5-5', max_tokens: 1024, messages: [{ role: 'user', content: 'List open jobs and summarize the newest candidates' }], mcp_servers: [ { type: 'url', url: `https://mcp-api.unified.to/mcp?connection=${connectionId}&permissions=ats_job_read,ats_candidate_read`, name: 'unified', authorization_token: process.env.UNIFIED_API_KEY, // sent as Authorization: Bearer }, ], tools: [{ type: 'mcp_toolset', mcp_server_name: 'unified' }], betas: ['mcp-client-2025-11-20'], }); ``` Every `mcp_servers` entry must be referenced by an `mcp_toolset`. Allowlist tools there with `default_config: { enabled: false }` plus `configs`. The response contains `mcp_tool_use` / `mcp_tool_result` blocks followed by the answer. Not available on Bedrock or Vertex — use the tool loop below there. ### OpenAI Responses API (remote MCP) ```python resp = client.responses.create( model="gpt-4.1", tools=[{ "type": "mcp", "server_label": "unified", "server_url": f"https://mcp-api.unified.to/mcp?connection={connection_id}", "headers": {"Authorization": f"Bearer {os.environ['UNIFIED_API_KEY']}"}, "require_approval": "never", }], input="list the candidates and analyse their resumes", ) ``` ### Any model, your own tool loop (`/tools` endpoints) For Gemini, Cohere, Grok, Groq, Bedrock/Vertex, or whenever your code must log or approve each call: 1. `GET https://mcp-api.unified.to/tools?connection=&type=` (plus `permissions` / `tools` / `aliases`) with the `Authorization` header → tool definitions shaped for that provider. 2. Pass them as the model's `tools` / function declarations. 3. For each tool call the model returns, `POST /tools/{id}/call` with the same connection/auth query and the arguments object as the JSON body. The result is `{ content: [{ type: 'text', text }], structuredContent }`. 4. Return the result as the provider's tool-result message and loop. Tool calls are rate-limited per workspace before they run — handle HTTP `429` with backoff. ### End-user clients (signed user token only) - **Claude.ai**: Settings → Connectors (custom) → URL `https://mcp-api.unified.to/mcp?token=--`. - **Cursor**: `.cursor/mcp.json` → `{ "mcpServers": { "": { "url": "" } } }`. - **Claude Desktop** (stdio): `npx -y mcp-remote ` as the command. - **Claude Code**: `claude mcp add --transport http ""`. Never hand an end-customer a URL or config that contains the workspace API key. ## Step 5: Verify 1. List the tools (`tools/list`, `GET /tools`, or your client's `/mcp` view) and check only the intended ones appear. 2. Make one read call against a **Sandbox** connection first. 3. If Core MCP is available, confirm the calls with `list_unified_apicalls` (`connection_id`, `type: "mcp"`). Troubleshooting: "not found" → wrong region host or connection ID. `401` → bad/rotated key, or the connection's credentials expired (end-user must re-authorize). `403` → scope not granted (connection permissions or `ema_` token scopes). Missing tool → integration doesn't support it, or `permissions`/`tools` filtered it out. `410` → still on `/sse`. For more see https://docs.unified.to/skills/unified-debug-webhooks-connections/SKILL.md. ## Related skills - Core (workspace) MCP: https://docs.unified.to/skills/unified-mcp-core/SKILL.md - Getting connections in the first place: https://docs.unified.to/skills/unified-quickstart/SKILL.md - Category data models (to explain tool results): the `unified-crm`, `unified-ats-jobboard`, `unified-hris`, … skills listed at https://docs.unified.to/skills ## unified-mcp-core URL: https://docs.unified.to/skills/unified-mcp-core/SKILL.md # Use the Unified.to Core MCP server (workspace mode) The Unified MCP server has two personalities, chosen by the credential: | Mode | Credential | Tools | | ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------ | | **Core (workspace) mode** | workspace API key, **no** `connection` | Workspace management: connections, webhooks, integrations, logs, docs | | **Connection mode** | workspace API key + `connection`, or a signed token | One end-customer connection's data tools (CRM contacts, ATS jobs, …) | This skill covers **Core mode**. It operates on the developer's own workspace **configuration** (connections, webhooks, integrations, API-call logs, issues, environments) — it does **not** read or write end-customer data inside a connection. For that, mint a connection URL with `get_unified_connection_mcp_url` and follow the `unified-mcp-connection` skill (https://docs.unified.to/skills/unified-mcp-connection/SKILL.md). Source of truth: https://docs.unified.to/mcp/core. If anything here disagrees with the docs, or with the tool schemas the server actually returns, trust those and call `search_docs`. ## Step 1: Is the server connected? Look for tools named `search_docs`, `list_unified_connections`, `list_unified_integrations`, `list_unified_apicalls` (some clients prefix them, e.g. `mcp__unified-core__list_unified_connections`). If they exist, skip to Step 2. If not, help the developer add it. Ask which **region** the workspace lives in — a credential only resolves on its own region's host, anything else returns "not found": | Region | Endpoint (Streamable HTTP) | | ------ | ----------------------------------- | | US | `https://mcp-api.unified.to/mcp` | | EU | `https://mcp-api-eu.unified.to/mcp` | | AU | `https://api-au.unified.to/mcp` | The developer gets the workspace API key at https://app.unified.to/settings/api. With no account yet, the agent can create one itself: `POST https://api.unified.to/agent/signup` returns a workspace API key, the MCP URL and a claim link for the developer (https://docs.unified.to/mcp/agent-signup). Send it as `Authorization: Bearer `; read it from an environment variable (`UNIFIED_API_KEY`) so it never lands in a committed file. `?token=` in the URL still works but is deprecated (responses carry `Deprecation: true`). SSE (`/sse`) has been removed and returns `410` — change it to `/mcp`. **Claude Code** — the plugin bundles this server with every Unified.to skill and stores the key in secure storage: ``` /plugin marketplace add https://docs.unified.to/.claude-plugin/marketplace.json /plugin install unified@unified-to ``` Or just the server: ```bash claude mcp add --transport http unified-core https://mcp-api.unified.to/mcp \ --header "Authorization: Bearer $UNIFIED_API_KEY" ``` **Codex** ```bash codex mcp add unified-core --url https://mcp-api.unified.to/mcp --bearer-token-env-var UNIFIED_API_KEY ``` **Cursor** (`.cursor/mcp.json` or `~/.cursor/mcp.json`) ```json { "mcpServers": { "unified-core": { "url": "https://mcp-api.unified.to/mcp", "headers": { "Authorization": "Bearer ${env:UNIFIED_API_KEY}" } } } } ``` **VS Code** (`.vscode/mcp.json`) — prompts for the key on first start: ```json { "inputs": [ { "type": "promptString", "id": "unified-api-key", "description": "Unified.to workspace API key", "password": true } ], "servers": { "unified-core": { "type": "http", "url": "https://mcp-api.unified.to/mcp", "headers": { "Authorization": "Bearer ${input:unified-api-key}" } } } } ``` **stdio-only clients** — wrap it with `mcp-remote`: ```json { "mcpServers": { "unified-core": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp-api.unified.to/mcp", "--header", "Authorization:${AUTH_HEADER}"], "env": { "AUTH_HEADER": "Bearer " } } } } ``` Most clients need a restart (or `/mcp` reconnect) before the tools appear. ## Step 2: The tools | Area | Tools | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | Docs | `search_docs`, `list_unified_skills`, `get_unified_skill` | | Connections | `list_unified_connections`, `get_unified_connection`, `create_unified_connection`, `update_unified_connection`, `remove_unified_connection`, `get_unified_connection_mcp_url` | | Webhooks | `list_unified_webhooks`, `get_unified_webhook`, `create_unified_webhook`, `update_unified_webhook`, `remove_unified_webhook`, `update_unified_webhook_trigger` | | Integrations | `list_unified_integrations` | | Issues | `list_unified_issues`, `get_unified_issue` | | API calls | `list_unified_apicalls`, `get_unified_apicall` | | Environments | `list_unified_environments`, `create_unified_environment`, `remove_unified_environment` | The server may expose more than this list (for example connection re-authorization or secrets-manager tools). Read the schemas your client loaded rather than assuming parameters. Useful filters (check the schema before relying on them): - `list_unified_connections`: `categories`, `env`, `external_xref` (your own user/account ID stored on the connection), `updated_gte`, `limit`/`offset`. - `list_unified_integrations`: `active: true` for what the workspace has activated, `env` (`Sandbox` / `Production`), `categories`, `type`, `summary: true` for a compact list. - `list_unified_apicalls`: `connection_id`, `webhook_id`, `error: true`, `type` (`login`, `webhook`, `inbound`, `mcp`), `integration_type`, `updated_gte`. Logs are kept for 60 days. - `create_unified_webhook`: requires `connection_id`, `object_type` (e.g. `crm_contact`) and `event` (`created` / `updated` / `deleted`), plus `hook_url`; `webhook_type` is `native` or `virtual` (`interval` in minutes for virtual). `is_healthy` is read-only — an unhealthy webhook cannot be reset, only recreated. ## Step 3: Rules for acting on a workspace These tools change a real account. Follow them every time: 1. **Read before you write.** Start with `list_*` / `get_*`. Summarize what you found before proposing a change. 2. **Confirm destructive or outward-facing calls.** Ask the developer before any `remove_*`, before `update_unified_connection` (it can break a live customer integration), before `create_unified_webhook` (it starts delivering data to a URL) and before `update_unified_webhook_trigger`. 3. **Mind the environment.** Sandbox and Production are separate; pass `env` and say which one you looked at. 4. **Never print secrets.** Connection records can contain tokens and credentials, and `mode: "api"` MCP URLs embed the workspace API key. Don't echo them into chat, code, logs or commits; refer to IDs instead. 5. **Use `search_docs` instead of guessing** about object shapes, integration support or error meanings. ## Common workflows **"What's set up in my workspace?"** `list_unified_integrations` (`active: true`) for each env → `list_unified_connections` → `list_unified_webhooks`. Report counts per integration, unhealthy connections, and unhealthy or paused webhooks. **"Why is customer X's integration failing?"** 1. Find the connection: `list_unified_connections` with `external_xref`, or ask for the connection ID. `get_unified_connection` for its health and permissions. 2. `list_unified_apicalls` with `connection_id` and `error: true`; open the latest with `get_unified_apicall` and read the status code and message. 3. 401/403 → credentials or scopes; the end-user must re-authorize. 429 → provider rate limit. 404 → wrong ID or object not supported; check `list_unified_integrations` for that integration's support. 5xx → provider outage, usually transient. 4. Check `list_unified_issues` for an open ticket on it. For deeper guidance load https://docs.unified.to/skills/unified-debug-webhooks-connections/SKILL.md. **"Set up a webhook for new CRM contacts"** Confirm the connection, object, event, target URL and env with the developer, then `create_unified_webhook`. Verify with `get_unified_webhook` and, after the first run, `list_unified_apicalls` with `webhook_id`. **"Give my agent / my customer MCP access to one connection"** Call `get_unified_connection_mcp_url`: | Parameter | Purpose | | ------------------------ | ----------------------------------------------------------------------------- | | `connection_id` | Required | | `mode` | `api` (default) embeds the workspace key — **never share**. `user` mints a connection-scoped signed token — safe to share, revoked by deleting the connection | | `permissions` | Limit tools by scope, e.g. `crm_contact_read,crm_company_read` | | `tools` | Limit by tool ID or wildcard, e.g. `list_*,*invoice*` | | `hide_sensitive` | `true` strips PII from tool responses | | `defer_tools` | `all` or tool IDs, to save context | | `aliases` | Extra wording for tool matching, e.g. `employee:user;employees:users` | | `include_external_tools` | `true` adds the vendor's raw passthrough endpoints as tools | Default to `mode: "user"` whenever the URL leaves the developer's own servers, and to the narrowest `permissions` / `tools` the use case allows — many LLMs degrade with more than 20–50 tools. Read `mcp_url` from the response (there is no `sse_url` any more). The URL does not expire on its own; treat it as a secret. Then hand over to the `unified-mcp-connection` skill. ## Related skills - Connection-mode MCP: https://docs.unified.to/skills/unified-mcp-connection/SKILL.md - Getting started: https://docs.unified.to/skills/unified-quickstart/SKILL.md - Debugging: https://docs.unified.to/skills/unified-debug-webhooks-connections/SKILL.md - REST fundamentals: https://docs.unified.to/skills/unified-api/SKILL.md ## unified-messaging URL: https://docs.unified.to/skills/unified-messaging/SKILL.md # Build a chat / support bot with the Unified.to Messaging API Unified.to normalizes many messaging platforms behind one REST API. Write your bot or notification integration once and it works across every supported chat platform. This skill teaches an agent how to build against the Unified Messaging API. ## When to use this skill Use this skill when the task is to: - Send messages into channels (alerts, notifications, bot replies) - Read messages and channels - Build a support or Q&A bot that works across chat platforms - Archive conversations ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID**. 2. An activated messaging integration (e.g. Slack or Discord) — start in the Sandbox. Bot integrations often require a bot token/connection; see the platform's setup guide (e.g. Slack, Discord, Telegram). 3. A **connection ID** from the embedded Authorization component (https://docs.unified.to/concepts/embedded-components). Keep the API key server-side only. ## Core request pattern ``` https://api.unified.to/messaging/{connection_id}/{object} ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `api.unified.to` (NA), `api-eu.unified.to` (EU), `api-au.unified.to` (AU). ## Data objects | Object | Purpose | | --------- | ---------------------------------------- | | `channel` | A channel / conversation / room | | `message` | A message within a channel | ### Supported operations | Method | Path | Description | | -------- | --------------------------------- | ------------------ | | `GET` | `/messaging/{cid}/channel` | List channels | | `GET` | `/messaging/{cid}/channel/{id}` | Get one channel | | `POST` | `/messaging/{cid}/channel` | Create a channel | | `PATCH` | `/messaging/{cid}/channel/{id}` | Update a channel | | `DELETE` | `/messaging/{cid}/channel/{id}` | Delete a channel | | `GET` | `/messaging/{cid}/message` | List messages | | `GET` | `/messaging/{cid}/message/{id}` | Get one message | | `POST` | `/messaging/{cid}/message` | Send a message | | `PATCH` | `/messaging/{cid}/message/{id}` | Update a message | | `DELETE` | `/messaging/{cid}/message/{id}` | Delete a message | Not every platform supports every object or field. Check the **Feature Support** tab in `app.unified.to`; a `501` response means unsupported. ## Pagination & filtering - `limit` (max 100) and `offset` (zero-based). - Filter messages by `channel_id`, and use `updated_gte=YYYY-MM-DDTHH:MM:SSZ` for incremental reads. ## Example: list channels and send a message (fetch) ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }; // Find a channel const channels = await ( await fetch(`${BASE}/messaging/${connectionId}/channel?limit=100`, { headers }) ).json(); const support = channels.find((c) => c.name === 'support'); // Send a message into it await fetch(`${BASE}/messaging/${connectionId}/message`, { method: 'POST', headers, body: JSON.stringify({ channel_id: support.id, message: 'Thanks for reaching out — an agent will reply shortly.', }), }); ``` ## Building a support bot 1. **Receive inbound messages** by registering a **webhook** for the `message` object so new messages reach you in real time (no polling). See https://docs.unified.to/reference/webhooks and https://docs.unified.to/concepts/virtual_webhooks. 2. Generate a reply (e.g. with an LLM over your knowledge base). 3. **Post the reply** with `POST /messaging/{cid}/message` into the same `channel_id`. See the worked example: https://docs.unified.to/guides/how_to_build_a_discord_support_bot_with_unified_and_langbase. ## Example: with the official SDK ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { messagingChannels } = await unified.messaging.listMessagingChannels({ connectionId, limit: 100, }); ``` SDKs for Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. ## Error handling | Code | Meaning & action | | ---- | ----------------------------------------------------------- | | 401 | Connection broken — re-authorize / re-add the bot. | | 403 | Missing scopes — fix provider app + Unified.to scopes. | | 429 | Provider rate limit — back off; prefer webhook sync. | | 501 | Operation unsupported by platform — check Feature Support. | ## References - Messaging API overview & data model: https://docs.unified.to/messaging/overview - Webhooks & virtual webhooks: https://docs.unified.to/reference/webhooks - REST basics: https://docs.unified.to/reference/rest - Source guide: https://docs.unified.to/guides/how_to_build_a_discord_support_bot_with_unified_and_langbase ## unified-payments URL: https://docs.unified.to/skills/unified-payments/SKILL.md # Build a payments / fintech app with the Unified.to Payment API Unified.to normalizes many payment providers behind one REST API. Write your integration once and it works across every supported payment platform. This skill teaches an agent how to build payment features against the Unified Payment API. ## When to use this skill Use this skill when the task is to: - Accept one-time payments or create hosted payment links - Create and manage recurring subscriptions / billing - List and reconcile payments, payouts, and refunds - Support more than one payment provider without provider-specific code ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID** (`app.unified.to` → Settings → API Keys). 2. An activated payment integration (e.g. Stripe) in the Sandbox environment. 3. A **connection ID** obtained after an end-user authorizes the integration through the embedded Authorization component. See https://docs.unified.to/concepts/embedded-components. Store the API key server-side only. Never expose it in client code. ## Core request pattern All endpoints follow `{API_CATEGORY}/{CONNECTION_ID}/{DATA_OBJECT}`: ``` https://api.unified.to/payment/{connection_id}/{object} ``` Authenticate every request with a bearer token: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `https://api.unified.to` (North America), `https://api-eu.unified.to` (Europe), `https://api-au.unified.to` (Australia). Use the base URL that matches the region where the workspace was created. ## Data objects | Object | Purpose | | -------------- | -------------------------------------------------- | | `payment` | A one-time or captured payment / charge | | `subscription` | A recurring billing subscription | | `link` | A hosted payment link | | `payout` | Funds paid out from the provider to the merchant | | `refund` | A refund against a payment | ### Supported operations | Method | Path | Description | | -------- | -------------------------------------- | --------------------------- | | `GET` | `/payment/{cid}/payment` | List payments | | `GET` | `/payment/{cid}/payment/{id}` | Get one payment | | `POST` | `/payment/{cid}/payment` | Create a payment | | `PATCH` | `/payment/{cid}/payment/{id}` | Update a payment | | `DELETE` | `/payment/{cid}/payment/{id}` | Delete/void a payment | | `GET` | `/payment/{cid}/subscription` | List subscriptions | | `POST` | `/payment/{cid}/subscription` | Create a subscription | | `PATCH` | `/payment/{cid}/subscription/{id}` | Update a subscription | | `DELETE` | `/payment/{cid}/subscription/{id}` | Cancel a subscription | | `GET` | `/payment/{cid}/link` | List payment links | | `POST` | `/payment/{cid}/link` | Create a payment link | | `GET` | `/payment/{cid}/payout` | List payouts (read-only) | | `GET` | `/payment/{cid}/refund` | List refunds (read-only) | Not every provider supports every object or field. Check the **Feature Support** tab of an integration in `app.unified.to` before relying on an operation. A `501 Not Implemented` response means the provider does not support that call. ## Pagination, filtering & sorting List endpoints accept query parameters: - `limit` (max 100 per page) and `offset` (zero-based) for paging. - `updated_gte=YYYY-MM-DDTHH:MM:SSZ` to fetch only records changed since a time. - `sort` (`created_at`, `updated_at`, `name`) with `order` (`asc` / `desc`). You know you have reached the last page when the number of returned records is less than the requested `limit`. ## Example: list and create payments (fetch) ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }; // List the most recent payments const list = await fetch( `${BASE}/payment/${connectionId}/payment?limit=50&sort=created_at&order=desc`, { headers } ); const payments = await list.json(); // Create a payment const created = await fetch(`${BASE}/payment/${connectionId}/payment`, { method: 'POST', headers, body: JSON.stringify({ total_amount: 49.99, currency: 'USD', contact_id: 'CONTACT_ID', reference: 'order-1234', }), }); const payment = await created.json(); ``` ## Example: with the official SDK Unified.to publishes SDKs; each uses its language's naming convention (TypeScript uses camelCase; the docs use snake_case). ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { paymentPayments } = await unified.payment.listPaymentPayments({ connectionId, limit: 50, }); ``` Other SDKs: Python, PHP, Java, Go, C#, Ruby — see https://docs.unified.to/reference/sdks. ## Keeping data in sync (recommended) Do not poll on every user action. Instead, sync provider data into your own database and query it locally: - Register **webhooks** to receive create/update/delete events for `payment`, `subscription`, and `refund`. See https://docs.unified.to/reference/webhooks and https://docs.unified.to/concepts/virtual_webhooks. - On webhook receipt, upsert the object into your database keyed by its `id`. This gives consistent, fast, provider-independent queries. ## Error handling | Code | Meaning & action | | ---- | ---------------------------------------------------------------------- | | 401 | Connection broken / revoked — prompt the user to re-authorize. | | 403 | Missing scopes — fix scopes in the provider app and Unified.to. | | 429 | Provider rate limit — back off and retry; prefer webhooks for sync. | | 501 | Provider does not support this operation — check Feature Support. | ## References - Payment API overview & data model: https://docs.unified.to/payment/overview - REST basics (auth, regions, status codes): https://docs.unified.to/reference/rest - Pagination & filtering: https://docs.unified.to/reference/pagination - Source guide: https://docs.unified.to/guides/how_to_build_a_fintech_application_with_unified_payments_api ## unified-quickstart URL: https://docs.unified.to/skills/unified-quickstart/SKILL.md # Add Unified.to integrations to an app Unified.to puts one real-time REST API and one data model in front of hundreds of SaaS integrations across CRM, ATS, HRIS, accounting, commerce, messaging, ticketing, storage, calendar, and more. Your app authorizes an end-user's account once, gets a **connection ID**, and calls `https://api.unified.to/{category}/{connection_id}/{object}`. The same code works for every integration in a category. This skill takes an app from nothing to a working, verified integration in the **Sandbox** (synthetic data). It summarizes the official quick start at https://docs.unified.to/quick-start. When anything here is unclear or looks out of date, the docs are the source of truth: search them (see Step 0) instead of guessing. ## Before you start: agree on a plan Inspect the project first (language, framework, where server code lives, how env vars and the database are handled). Then show the developer a short checklist and **wait for approval** before editing code: 1. **Use case & categories:** what the app needs (e.g. "read CRM contacts", "create ATS candidates"). Map it to a category and object (`crm/contact`, `ats/candidate`, `hris/employee`, `accounting/invoice`, …). 2. **Integrations:** which vendors to offer first (e.g. HubSpot, Salesforce). 3. **Region:** where the Unified.to workspace lives. US is the default (`https://api.unified.to`), EU is `https://api-eu.unified.to` and AU is `https://api-au.unified.to`. 4. **Where connection IDs are stored:** usually a table or column keyed by the app's user or account ID. 5. **Files you will add or change:** env config, SDK client, auth UI, the callback route, the first API call, and optionally a webhook route. If the project already talks to vendor APIs directly (e.g. its own HubSpot client), say so and propose which calls move to Unified.to. Don't delete existing integrations without approval. ## Step 0: Tools **Unified.to Core MCP server (recommended).** If tools such as `search_docs`, `list_unified_integrations`, `list_unified_connections`, and `list_unified_apicalls` are available, use them throughout. They manage the developer's workspace and answer questions from the docs. If they are not available, suggest the developer adds the server (https://docs.unified.to/mcp/core): - US: `https://mcp-api.unified.to/mcp`, EU: `https://mcp-api-eu.unified.to/mcp` - Auth: header `Authorization: bearer `. Prefer the header and an environment variable over the `?token=` URL form. The workspace API key gives full access to the Unified.to account. Never write it into committed config files. **Docs for agents.** Index: https://docs.unified.to/llms.txt. Every docs page has a Markdown version (append `.md`), and the full docs are available as a single file at https://docs.unified.to/llms-full.txt. The OpenAPI spec is at https://api.unified.to/openapi.json, with one file per category at `https://api.unified.to/docs/openapi-{category}.json`. **Category skills.** After this quick start, load the matching skill for object shapes and category-specific patterns (list below under "Next"). ## Step 1: Workspace credentials If the developer already has a Unified.to account, ask them to open **Settings → API Keys** at https://app.unified.to and copy the **Workspace ID**, **API Key**, and **workspace secret** (used to verify signatures). If they don't, sign up yourself (https://docs.unified.to/mcp/agent-signup): 1. `POST https://api.unified.to/agent/signup` (EU: `api-eu.unified.to`, AU: `api-au.unified.to`). No body or credentials needed. 2. Save `api_key` and `workspace_id` from the response right away; the key is shown only once. Use the returned `mcp_url` for the Core MCP server. 3. Show `claim_url` to the developer. Claiming makes them the owner, lets them upgrade before the 30-day Test plan ends, and gives them the workspace secret. The response's `sandbox_integrations` are already active, so for those you can skip Step 2. Add them to the project's env config (`.env`, secrets manager, etc.) and make sure `.env` is git-ignored: ```bash UNIFIED_WORKSPACE_ID=... UNIFIED_API_KEY=... # server-side only, never sent to the browser UNIFIED_WORKSPACE_SECRET=... # server-side only UNIFIED_ENV=Sandbox # switch to Production at launch ``` ## Step 2: Activate integrations in the Sandbox The Sandbox (also called "Synthetic Data") returns mock data and accepts any mock credentials, so no vendor developer accounts are needed yet (https://docs.unified.to/concepts/sandbox). Ask the developer to open **Integrations** in app.unified.to, pick **Sandbox** from the environment dropdown (top right), search for each integration, and click **Activate**. Credentials can be left empty. Verify with `list_unified_integrations` (Core MCP) or `GET https://api.unified.to/unified/integration/workspace/{workspace_id}?env=Sandbox&active=true` that they are active. Integrations are tied to the environment they were activated in. ## Step 3: Install the SDK Prefer an official SDK over raw HTTP (https://docs.unified.to/reference/sdks): | Language | Package | | ---------- | ------------------------------------------------------------ | | TypeScript | `npm install @unified-api/typescript-sdk` | | Python | https://github.com/unified-to/unified-python-sdk | | PHP | https://github.com/unified-to/unified-php-sdk | | Java | `to.unified:unified-java-sdk` (Maven Central) | | Go | https://github.com/unified-to/unified-go-sdk | | C# | `UnifiedTo` (NuGet) | | Ruby | https://github.com/unified-to/unified-ruby-sdk | Other languages can generate a client from https://api.unified.to/openapi.json. SDKs use the language's naming convention (e.g. camelCase in TypeScript), while the docs use snake_case. Create one server-side client: ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; export const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! }, // EU/AU workspaces: serverURL: 'https://api-eu.unified.to' or 'https://api-au.unified.to' }); ``` ## Step 4: Let end-users authorize their accounts The end-user picks an integration, authorizes it, and is redirected back to the app with a new connection ID (https://docs.unified.to/concepts/embedded-components). Choose one: **A. Embedded Authorization component (fastest).** Plain JS: ```html
``` Framework packages are also available: `@unified-api/react-directory`, `@unified-api/vuejs-directory`, `@unified-api/angular-directory`, and `@unified-api/svelte-directory`. The developer can set options (scopes, redirects) and copy code at https://app.unified.to/embed. **B. Authorization URL (full control, e.g. your own integration picker).** ``` https://api.unified.to/unified/integration/auth/{workspace_id}/{integration_type} ?redirect=1 &env=Sandbox &success_redirect=https://yourapp.com/integrations/callback &failure_redirect=https://yourapp.com/integrations/callback &scopes=crm_contact_read,crm_company_read &state= &external_xref= ``` **Always request only the scopes you need.** If you leave scopes out, every possible scope is requested (https://docs.unified.to/concepts/scopes). **Callback route (server-side).** On success, Unified.to appends `id` (the connection ID), `state`, `nonce`, `type`, and `sig`. On failure it appends `error` to `failure_redirect`. Verify the signature, confirm `state` belongs to the signed-in user, and then store the connection ID against that user (https://docs.unified.to/guides/how_to_associate_a_connection_id_with_your_end_user): ```typescript import { createHmac, timingSafeEqual } from 'crypto'; // sig = HmacSha256(workspace.secret).update(connection.id).update(nonce).update(state).digest('hex') function isValidAuthCallback(q: { id: string; nonce: string; state?: string; sig: string }) { const expected = createHmac('sha256', process.env.UNIFIED_WORKSPACE_SECRET!) .update(q.id) .update(q.nonce) .update(q.state ?? '') .digest('hex'); return expected.length === q.sig.length && timingSafeEqual(Buffer.from(expected), Buffer.from(q.sig)); } ``` Store the connection ID in the database. Don't keep it only in `localStorage`. Connections can also be looked up later by user with `GET /unified/connection?external_xref={user_id}`. ## Step 5: Make the first API call Every data call is `{category}/{connection_id}/{object}` with `Authorization: Bearer $UNIFIED_API_KEY` (https://docs.unified.to/reference/rest): ```typescript const { crmContacts } = await unified.crm.listCrmContacts({ connectionId, limit: 20, }); ``` ```bash curl "https://api.unified.to/crm/$CONNECTION_ID/contact?limit=20" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` Apply these rules from the start: - **Start from a list endpoint.** IDs can't be guessed. - **Pagination:** use `limit` (max 100) and `offset`. A page shorter than `limit` is the last page. Use `updated_gte` for incremental reads, and `sort`/`order` where needed (https://docs.unified.to/reference/pagination). - **Field selection:** `fields=` trims the response. Vendor-specific data is available through `fields=raw` (https://docs.unified.to/reference/fields). - **Capability varies by integration:** a `501` means the vendor doesn't support the operation. Check the integration's **Feature Support** tab. - **Errors:** `401` means the connection needs re-authorizing, so prompt the user. `403` means a scope or permission is missing. `429` means back off and retry (https://docs.unified.to/reference/rate_limits). Don't blindly retry a `POST` that timed out; list first to check whether the record was created. ## Step 6: Verify end to end Don't report success until all of these pass: 1. The app builds, and its tests and lint pass. 2. Run the app, open the auth UI, pick a Sandbox integration, and enter any mock credentials. The callback stores a connection ID. 3. `list_unified_connections` (Core MCP) or `GET /unified/connection` shows the new connection. 4. The first API call returns synthetic records in the app. 5. If anything fails, read `list_unified_apicalls` / `list_unified_issues` (Core MCP) or the API call logs at app.unified.to before changing code. ## Step 7 (optional): Keep data in sync with webhooks To get updates pushed instead of polling, register a webhook (https://docs.unified.to/guides/how_to_create_and_configure_webhooks). Use `create_unified_webhook` (Core MCP) or: ``` POST https://api.unified.to/unified/webhook?include_all=true { "hook_url": "https://yourapp.com/webhooks/unified", "connection_id": "", "object_type": "crm_contact", "event": "updated" } ``` `include_all=true` backfills historical data. `updated` events also fire for new records. The handler must follow these rules: - **Verify `sig256`.** It is `base64(HMAC-SHA256(workspace secret, JSON.stringify(data) + nonce))`. Keep the `data` array's key order exactly as received and compare timing-safely (https://docs.unified.to/reference/webhooks). - **Branch on `type`:** `INITIAL-PARTIAL`, `INITIAL-COMPLETE`, `VIRTUAL`, `NATIVE`. - **Upsert by `id`.** Duplicates and out-of-order deliveries happen, so handlers must be idempotent. - **Return `200` quickly** and do heavy work asynchronously. - Use `external_xref` from the payload to find the owning user. ## Critical rules - The API key and workspace secret stay **server-side**, in env vars or a secrets manager. Never put them in client bundles, committed files, or logs. - Build and test in the **Sandbox**. Always pass `env` explicitly to the Core API and the auth URL. - Unified.to is **real-time and pass-through**: it does not cache the end-user's data. If the app needs fast queries, sync into the app's own database (webhooks + upsert). - Request the **minimum scopes**. - Verify **every signature** (auth callback `sig`, webhook `sig256`). - Associate every connection with the app's user (`state` and/or `external_xref`) at creation time. - When unsure about an endpoint, field, or behavior, **search the docs** (`search_docs` or https://docs.unified.to/llms.txt). Don't invent parameters. ## Going to production (the developer does this) From https://docs.unified.to/quick-start#go-to-production: 1. Register a developer app with each vendor to get OAuth or API-key credentials. The docs have step-by-step guides for each vendor. 2. Activate the integration in the **Production** environment with those credentials. 3. Enable matching scopes in the vendor's developer app (https://docs.unified.to/concepts/scopes). 4. Change `env=Sandbox` to `env=Production` in the auth component or URL. Adding more integrations later needs no code changes. Activate them, and they appear in the Authorization component automatically. ## Next: category skills Load the skill that matches the use case: - CRM: https://docs.unified.to/skills/unified-crm/SKILL.md - ATS / job boards: https://docs.unified.to/skills/unified-ats-jobboard/SKILL.md - Assessments: https://docs.unified.to/skills/unified-assessment/SKILL.md - HRIS / payroll: https://docs.unified.to/skills/unified-hris/SKILL.md - Accounting / invoicing: https://docs.unified.to/skills/unified-accounting-invoicing/SKILL.md - Payments: https://docs.unified.to/skills/unified-payments/SKILL.md - E-commerce: https://docs.unified.to/skills/unified-ecommerce/SKILL.md - Messaging: https://docs.unified.to/skills/unified-messaging/SKILL.md - Calendar: https://docs.unified.to/skills/unified-calendar/SKILL.md - Ticketing: https://docs.unified.to/skills/unified-ticketing/SKILL.md - Enterprise search / RAG: https://docs.unified.to/skills/unified-enterprise-search-rag/SKILL.md - "Sign in with …": https://docs.unified.to/skills/unified-auth-signin/SKILL.md - REST fundamentals: https://docs.unified.to/skills/unified-api/SKILL.md - SDK details: https://docs.unified.to/skills/unified-sdks/SKILL.md - Debugging connections and webhooks: https://docs.unified.to/skills/unified-debug-webhooks-connections/SKILL.md - Managing the workspace through the Core MCP server: https://docs.unified.to/skills/unified-mcp-core/SKILL.md - Giving an LLM / AI agent access to a connection over MCP: https://docs.unified.to/skills/unified-mcp-connection/SKILL.md Need help? Email hello@unified.to or see https://unified.to/support. ## unified-sdks URL: https://docs.unified.to/skills/unified-sdks/SKILL.md # Use the Unified.to SDKs The Unified.to SDKs wrap the same REST API behind typed, idiomatic clients. Prefer an SDK over raw HTTP: it is generated from the OpenAPI spec, stays in sync with the data model, handles auth and retries, and gives you typed requests and responses. This skill teaches an agent how to install and call the SDKs. ## When to use this skill Use this skill when the task is to: - Install and configure a Unified.to SDK in TypeScript, Python, PHP, Java, Go, C#, or Ruby - Authenticate an SDK client with a workspace API key - Call list / get / create / update / delete methods for any category - Page, filter, and select fields through the SDK - Handle SDK errors and configure retries For the object shapes of a specific category, also load the matching skill (e.g. `unified-crm`), or the foundational `unified-api` skill for the REST fundamentals. ## Prerequisites 1. A Unified.to workspace **API key** (`app.unified.to` → Settings → API Keys). Keep it server-side only. 2. A **connection ID** identifying the customer's connected app (https://docs.unified.to/concepts/embedded-components). ## Available SDKs | Language | Package | Repo / registry | | ---------- | -------------------------------- | ---------------------------------------------------------------- | | TypeScript | `@unified-api/typescript-sdk` | https://www.npmjs.com/package/@unified-api/typescript-sdk | | Python | `Unified-python-sdk` | https://github.com/unified-to/unified-python-sdk | | PHP | `unified-to/unified-php-sdk` | https://github.com/unified-to/unified-php-sdk | | Java | `to.unified:unified-java-sdk` | https://central.sonatype.com/artifact/to.unified/unified-java-sdk | | Go | `unified-to/unified-go-sdk` | https://github.com/unified-to/unified-go-sdk | | C# | `UnifiedTo` | https://www.nuget.org/packages/UnifiedTo | | Ruby | `unified-to/unified-ruby-sdk` | https://github.com/unified-to/unified-ruby-sdk | Each SDK's README carries language-idiomatic examples for every method. ## Core pattern 1. Construct the client with your API key as the `jwt` security value. 2. Call `..(...)` — e.g. `listCrmContacts`, `createCrmContact`, `listAtsCandidates`, `updateAccountingInvoice`. 3. Pass a request object that always includes the `connectionId`, plus body and query parameters. 4. Read the typed resource off the response (the response also exposes the HTTP `statusCode`). ## TypeScript ```bash npm add @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const sdk = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! }, }); async function run() { // List const res = await sdk.crm.listCrmContacts({ connectionId: process.env.CONNECTION_ID!, limit: 20, }); if (res.statusCode === 200) { console.log(res.crmContacts); } // Create await sdk.crm.createCrmContact({ connectionId: process.env.CONNECTION_ID!, crmContact: { name: 'Grace Hopper', emails: [{ email: 'grace@example.com' }] }, }); } run(); ``` ## Python ```bash pip install Unified-python-sdk ``` ```python from unified_python_sdk import UnifiedTo from unified_python_sdk.models import shared with UnifiedTo(security=shared.Security(jwt="")) as unified_to: # List res = unified_to.crm.list_crm_contacts(request={ "connection_id": "", "limit": 20, }) print(res.crm_contacts) # Create unified_to.crm.create_crm_contact(request={ "connection_id": "", "crm_contact": {"name": "Grace Hopper"}, }) ``` Every method also has an `await`-able `_async` variant (e.g. `create_crm_contact_async`). ## Other languages PHP, Java, Go, C#, and Ruby follow the same shape — construct a client with the `jwt` security value, then call `.`. See each SDK's README (table above) for the exact idiomatic syntax. ## Naming conventions Each SDK uses its language's standard naming convention for field names — e.g. TypeScript uses camelCase (`connectionId`, `updatedGte`) while Python uses snake_case (`connection_id`, `updated_gte`). All of the written docs use snake_case; translate to the SDK's convention. ## Pagination, filtering & field selection Pass the same parameters the REST API accepts as fields on the request object, in the SDK's naming convention: - `limit` (max 100) and `offset` (zero-based) to page. A returned count `< limit` means the last page. - `updatedGte` / `updated_gte` for incremental sync. - `query` for free-text search; per-resource filters such as `companyId`. - `sort` and `order` to order results. - `fields` to return only the fields you need (smaller, cheaper responses). ## Retries The SDKs have built-in retry support with exponential backoff — configure it per-call or globally. In TypeScript: ```typescript const sdk = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! }, retryConfig: { strategy: 'backoff', backoff: { initialInterval: 1, maxInterval: 50, exponent: 1.1 }, }, }); ``` ## Error handling Failed calls raise a typed SDK error. In TypeScript, catch `UnifiedToError`, which exposes `message`, `statusCode`, `headers`, `body`, and `rawResponse`: ```typescript import * as errors from '@unified-api/typescript-sdk/sdk/models/errors'; try { await sdk.crm.listCrmContacts({ connectionId }); } catch (error) { if (error instanceof errors.UnifiedToError) { console.log(error.statusCode, error.message, error.body); } else { throw error; } } ``` Common statuses: `401` (re-authorize the connection), `403` (scopes/IP), `429` (rate limit — back off), `501` (operation unsupported by that provider). ## Generating an SDK for another language For any language not listed, generate a client from the OpenAPI spec at https://api.unified.to/openapi.json with a tool such as [OpenAPI Generator](https://openapi-generator.tech/). There is also a [Cloudflare Worker template](https://github.com/unified-to/unified-cloudflare-worker). ## References - SDKs overview: https://docs.unified.to/reference/sdks - REST basics: https://docs.unified.to/reference/rest - Pagination & filtering: https://docs.unified.to/reference/pagination - Embedded components (connections): https://docs.unified.to/concepts/embedded-components ## unified-ticketing URL: https://docs.unified.to/skills/unified-ticketing/SKILL.md # Build a helpdesk / ticketing integration with the Unified.to Ticketing API Unified.to normalizes many ticketing platforms behind one REST API. Write your support/issue-tracking integration once and it works across every supported platform. This skill teaches an agent how to build against the Unified Ticketing API. ## When to use this skill Use this skill when the task is to: - Create tickets from your product and sync their status back - Two-way sync tickets between systems - Add notes/comments to tickets - Manage customers and ticket categories across more than one platform ## Prerequisites 1. A Unified.to workspace with an **API key** and **Workspace ID**. 2. An activated ticketing integration (e.g. Zendesk) — start in the Sandbox. 3. A **connection ID** from the embedded Authorization component (https://docs.unified.to/concepts/embedded-components). Keep the API key server-side only. ## Core request pattern ``` https://api.unified.to/ticketing/{connection_id}/{object} ``` Authenticate every request: ``` Authorization: Bearer YOUR_API_KEY Content-Type: application/json ``` Regional base URLs: `api.unified.to` (NA), `api-eu.unified.to` (EU), `api-au.unified.to` (AU). ## Data objects | Object | Purpose | | ---------- | ---------------------------------------- | | `ticket` | A support ticket / issue | | `customer` | The requester / customer on a ticket | | `note` | A note / comment on a ticket | | `category` | A ticket category / queue | ### Supported operations (ticket shown; others follow the same shape) | Method | Path | Description | | -------- | --------------------------------- | ------------------ | | `GET` | `/ticketing/{cid}/ticket` | List tickets | | `GET` | `/ticketing/{cid}/ticket/{id}` | Get one ticket | | `POST` | `/ticketing/{cid}/ticket` | Create a ticket | | `PATCH` | `/ticketing/{cid}/ticket/{id}` | Update a ticket | | `DELETE` | `/ticketing/{cid}/ticket/{id}` | Delete a ticket | The same verbs apply to `customer`, `note`, and `category`. Not every platform supports every object or field. Check the **Feature Support** tab in `app.unified.to`; a `501` response means unsupported. ## Pagination, filtering & sorting - `limit` (max 100) and `offset` (zero-based). - `updated_gte=YYYY-MM-DDTHH:MM:SSZ` for incremental sync. - `sort` (`created_at`, `updated_at`) + `order` (`asc` / `desc`). ## Example: create a ticket and add a note (fetch) ```javascript const BASE = 'https://api.unified.to'; const headers = { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json', }; // Create a ticket const ticketRes = await fetch(`${BASE}/ticketing/${connectionId}/ticket`, { method: 'POST', headers, body: JSON.stringify({ subject: 'Cannot reset password', description: 'The reset email never arrives.', priority: 'HIGH', }), }); const ticket = await ticketRes.json(); // Add an internal note to it await fetch(`${BASE}/ticketing/${connectionId}/note`, { method: 'POST', headers, body: JSON.stringify({ ticket_id: ticket.id, note: 'Escalated to the auth team.', }), }); ``` Consult the ticket data model at https://docs.unified.to/ticketing/ticket/model for canonical fields. ## Example: with the official SDK ```bash npm install @unified-api/typescript-sdk ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY! } }); const { ticketingTickets } = await unified.ticketing.listTicketingTickets({ connectionId, limit: 100, }); ``` SDKs for Python, PHP, Java, Go, C#, and Ruby: https://docs.unified.to/reference/sdks. ## Keeping data in sync (recommended) - Register **webhooks** for `ticket` and `note` events and upsert by `id` so your inbox stays current without polling. See https://docs.unified.to/reference/webhooks. ## Error handling | Code | Meaning & action | | ---- | ----------------------------------------------------------- | | 401 | Connection broken — re-authorize the end-user. | | 403 | Missing scopes — fix provider app + Unified.to scopes. | | 429 | Provider rate limit — back off; prefer webhook sync. | | 501 | Operation unsupported by platform — check Feature Support. | ## References - Ticketing API overview & data model: https://docs.unified.to/ticketing/overview - Ticket data model: https://docs.unified.to/ticketing/ticket/model - REST basics: https://docs.unified.to/reference/rest - Pagination & filtering: https://docs.unified.to/reference/pagination ## workable-oauth-app URL: https://docs.unified.to/skills/workable-oauth-app/SKILL.md # Workable API Credentials **Read this first: Workable has no self-serve developer portal where you register an OAuth app and walk away with a client ID and secret.** There is no "create app" button. Workable's own OAuth documentation states it plainly: "Official partners can access Workable API endpoints through OAuth 2.0 ... the Partner should be authorized beforehand by Workable and get a `client_id` and `client_secret`." Credentials are issued by hand, by Workable, to accepted partners. The partner program's Native Integration tier — the one described as leveraging the Partner API — states a requirement of a **minimum of 15 mutual customers**. If the goal is "get Workable OAuth credentials this week," the honest answer is that it is not available at any price without that partnership, and the run should stop at §5. What *is* self-serve is different, and it is what almost every Workable integration actually runs on: **each customer generates their own API access token inside their own Workable account**, from Settings → Integrations → Apps. It is an Admin-only action, the token is account-level, it carries scopes the customer picks, it has a **mandatory expiration** (30 days to a maximum of 2 years), and it is shown exactly once. A multi-tenant platform can run entirely on this: ask each customer for a token and a subdomain, and store the pair. Decide which of the two models you are building against before doing anything else (§1) — the expensive mistakes here all start with building the wrong one. Three more things that cost real time: **every request is scoped to an account subdomain** and a connect flow that does not collect one is broken before it starts (§2); an account-level token reading `/employees` silently returns **only published employees with publicly available info** unless you pass a `member_id` (§7) — the classic partial-data failure in this category; and tokens expire on a clock the customer chose and you never see (§5). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Credential model** | Per-customer API access token, partner token, or partner OAuth? (§1) | | **Partner status** | Do you have an accepted Workable partner application, or are you collecting per-customer tokens? (§4) | | **Account subdomain(s)** | Required on every request; there is no way to guess it (§2) | | **Scope set** | The exact `r_*` / `w_*` strings the connector calls (§7) | | **Integration name**, logo, description | Shown to the customer on the OAuth grant screen and in the partner listing (§5) | | **Redirect URI(s)** | Every callback host, final, up front — Workable provisions them for you (§6) | | **Mutual customers** | The Native tier names a minimum of 15; be ready to say who (§4) | | **Token expiry policy** | Which expiration you will tell customers to pick, and who watches the clock (§5) | | **A Workable tenant to test against** | Sandbox access is a partner benefit, not a signup form (§4) | ## Quick Start 1. Establish which **credential model** this actually needs (§1). Most wrong turns start here. 2. Confirm you can collect the **account subdomain** in your connect flow — nothing works without it (§2). 3. Check whether existing credentials are worth reusing; re-issuing orphans every existing connection (§3). 4. If partner OAuth: apply to the partner program. This gates everything else (§4). 5. Obtain the credential — the customer's Integrations page, or Workable's partner provisioning (§5). 6. Give Workable **every** callback host in that same request; you cannot self-serve them later (§6). 7. Pin down scopes *and* the acting user's Workable permissions — both are required, neither substitutes (§7). 8. Check rate limits before you promise a sync cadence (§8). 9. Verify with a real authorize → callback → refresh → refresh-again round trip against a second account (§9). 10. Hand the secret to a human, never to source control (§10). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **OAuth 2.0 is partner-gated, not self-serve.** Workable's OAuth page describes only an authorization-code flow for partners who have already been issued a `client_id` and `client_secret` by Workable. No public registration form, no console, no way to self-register a client. - **The OAuth endpoints are on the `www` host**, not the per-account subdomain: authorize at `https://www.workable.com/oauth/authorize`, token and refresh at `https://www.workable.com/oauth/token`, revoke at `https://www.workable.com/oauth/revoke`. The authorize call carries `resource=user`. An `api.workable.com` alias answers on the same paths today, but the documented host is `www`. - **The developer hub moved.** `developers.workable.com` now redirects to `https://www.workable.com/developers`, and the partner-apply URL still printed at the bottom of Workable's own OAuth page (`developers.workable.com/partner-program/apply`) **404s**. The live applications are at `https://www.workable.com/partnership-program/apply` and, for the third-party tier, `https://www.workable.com/partnership-program/third-party-partners/apply`. Any runbook still pointing at the old URL is stale. - **Customer API tokens now expire.** The generation form requires an expiration from a fixed dropdown: 30 days, 90 days, 6 months, 1 year, 2 years. There is no "never." Expired tokens can be **renewed in place** — the token string does not change — which is the one piece of good news in this section. - **Only Admin users can generate or revoke tokens**, and Workable states there is "no dedicated access only for this use": you cannot delegate token management without making someone an Admin. - **Three token types with different reach.** Workable's endpoint reference labels each endpoint "accessible with all token types", "with account tokens and user tokens", or "only with user tokens". Offers (`/offers/:id` and its approve/reject actions) are **user-token only**; `/members`, `/recruiters` and `/events` exclude partner tokens. - **A Workable MCP server exists** at `https://mcp.workable.com/mcp` (documented page last updated 2026-07-16) and it *does* support OAuth Dynamic Client Registration (RFC 7591/8414) at `https://mcp.workable.com/oauth/register`, unauthenticated. Its metadata points at the same `workable.com/oauth/authorize` and `/oauth/token` endpoints and advertises PKCE `S256`. **This is not a back door to REST API credentials** — the registration is scoped to the MCP resource. Do not present it as a self-serve OAuth route for a connector; if someone wants to try, that is a question for Workable, not an assumption to build on. - **The authorization server advertises more scopes than the customer-facing token form offers**, including `w_jobs`, `r_offers`, `w_offers`, `r_time_tracking`, `w_time_tracking`, `r_reviews`, `w_reviews` and `r_reports`. See §7 before requesting any of them. If the Integrations page or the developer hub does not look like this, stop and report what you actually see. ## 1. Which credential, and who issues it A reader arriving here is usually holding the wrong one. Workable runs one API surface — Workable's own docs and help centre call it the **SPI**, after the `/spi/v3` path prefix every authenticated call uses — but three credential types reach it with different privileges: | Credential | Who issues it | How it is sent | Reach | | --- | --- | --- | --- | | **API access token** (account token) | The customer, self-serve, Admin only, in their own account | `Authorization: Bearer ` against `https://.workable.com/spi/v3/` | Account-level. Excluded from user-token-only endpoints (offers). Reduced employee visibility without `member_id` (§7) | | **OAuth 2.0 access token** (user token) | Workable, to accepted partners, as `client_id` + `client_secret`; the customer then grants access | `Authorization: Bearer `; authorize/token on `https://www.workable.com/oauth/` | Acts as the authorizing **user**. The only type that reaches offers. Spans every account that user can see (§2) | | **Partner token** | The customer generates it against an *already-listed* official integration on their Integrations page | `Authorization: Bearer ` **plus** `X-WORKABLE-CLIENT-ID: ` | Limited to the scopes agreed when the integration was built. Excluded from `/members`, `/recruiters`, `/events` | Two traps follow directly from the table: - **"Partner token" and "partner OAuth" are different things.** A partner token is a per-customer bearer token for a listed integration, with a client-ID header — no redirect, no consent screen, no refresh. Partner OAuth is the redirect flow. Both require Workable to have accepted you as a partner; only one involves a `client_secret`. - **The `X-WORKABLE-CLIENT-ID` header is also the legacy rate-limit lever.** Workable documents that third parties "still borrowing account tokens" can get partner-tier limits by setting that header. Sending it does not upgrade what the token may *read* — only what it may read *quickly* (§8). > **Product fact — dated.** As of **2026-09-20**, Unified.to's Workable connector supports **two** authentication > modes and collects the account subdomain in both. In **API-token** mode it asks the customer for two separate > inputs — the token itself, and the subdomain, framed as `https://{subdomain}.workable.com` — and points them at > Workable's account Integrations settings page to generate the token; the token is sent as a bearer credential and > the subdomain is substituted into the API base host. In **OAuth 2.0** mode it runs an authorization-code flow that > *also* requires the customer to supply the subdomain, sends `response_type`, `resource`, `client_id`, a > space-delimited `scope`, `redirect_uri` and `state` on the authorize call, exchanges and refreshes with a > form-encoded POST carrying client ID, client secret and (for refresh) the refresh token — and points its authorize > and token calls at Workable's `api.` host rather than the `www.` host the documentation shows. Its OAuth scope set > is per unified object and per direction, drawn from `r_candidates`, `w_candidates`, `r_jobs`, `w_jobs`, > `r_requisitions`, `r_employees` and `r_account`. When a client ID is present the connector sets the > `X-WORKABLE-CLIENT-ID` header on every call, including in API-token mode. It records Workable's published limits as > 10 requests/10 seconds for API tokens and 50/10 seconds for OAuth, and applies a fixed backoff because Workable does > not reliably return `Retry-After`. It records sandbox access as partner-only, and the URL it stores for obtaining > OAuth credentials is the **`developers.workable.com` partner-apply page that now 404s** (see Platform state). The > connector's recorded last-tested date is well over two years old. **Confirm all of this with the connector's owner > before acting on it** — connector configuration changes independently of this skill. ## 2. The subdomain and account model — collect it or nothing works Every authenticated Workable call is scoped to an account, and the account is named in the **host**: ``` https://.workable.com/spi/v3/jobs ``` There is no tenant discovery from a bare API token, and no default. A connect flow that collects only a credential is incomplete: **ask for the subdomain as a first-class input**, alongside the token, and validate it before saving. Workable tells customers to find it in their company profile settings; it is also the host of their Workable URL. Whether one credential spans accounts depends entirely on the type: - **Account tokens are single-account.** The token was generated inside one Workable account and reaches that account. A customer with two Workable accounts must generate two tokens, and your connect flow must let them make two connections. - **OAuth user tokens can span accounts.** The flow authorizes a *user*, and `GET /accounts` (scope `r_jobs`) returns "a collection of all the accounts you have access to," each with its own subdomain. Workable's own MCP server is built on this shape: call `get_accounts` first, then pass the chosen `subdomain` on every subsequent call. A connector on OAuth should therefore **enumerate accounts after authorization and let the customer choose**, rather than assuming one. Silently picking the first account is a data-leak-shaped bug when a recruiter has access to two client tenants. - Note the asymmetry with the OAuth endpoints: the authorize/token calls go to the **`www`** host, while the data calls go to the **per-account subdomain** host. Workable's own OAuth example calls `https://www.workable.com/spi/v3/accounts` with a user token to discover the subdomains first. A client that hardcodes one host for everything will work right up until it does not. ## 3. Reuse the existing credentials, or request new ones Re-issued partner credentials mean a **new client ID, and every existing customer authorization is bound to the old one** — every customer re-authorizes. Reuse what exists for: adding a redirect URI, changing scopes, rotating a compromised secret, or diagnosing a failure. Request a **new** client only when the user explicitly wants one: a separate product, a replacement for a compromised client, or a deliberate move from per-customer tokens to partner OAuth (which is a migration project, not a config change — every customer re-connects). For **per-customer API tokens** the calculus is much gentler: each customer's token is independent, so revoking or regenerating one affects exactly one tenant. Say which path you are taking before you start. ## 4. Accounts: the partner program, and a tenant to test against **The partner program** (`https://www.workable.com/developers`, applications at `https://www.workable.com/partnership-program/apply`) is the gate for anything Workable has to issue. What the run needs to know: - Workable's technology-partner page describes two tiers. **Native Integration Partners** — the tier described as leveraging the Partner API — state a requirement of a **minimum of 15 mutual customers**. **3rd Party Integration Partners** are described as having **no mutual-customer requirement** but correspondingly fewer benefits (directory listing and co-marketing, rather than the dedicated technical support and API access of the Native tier). A pre-revenue product with no Workable customers has no route to the Native tier. - Workable publishes **no review timeline, no cost and no SLA** for partner applications. Do not invent one. Plan in months and say so. - The partner categories Workable advertises — sourcing, assessments, video interviews, background checks, HRIS/onboarding — are **partner categories, not separate API surfaces**. (Do not confuse the `/spi/` path prefix with "sourcing partner integration": Workable's own help centre uses "SPI" as the name of the whole API.) If a category needs a dedicated API, that is something Workable tells you during onboarding, not something to assume. - The agreement is a **business and legal decision**. Do not fill in revenue, volume, security or compliance claims on the user's behalf — collect the questions and hand them back. **A tenant to test against.** Workable's sandbox route runs through the partner program (`https://www.workable.com/partnership-program/technology-partners`); there is no self-serve developer tenant. Without partner status, the only test tenant is a real Workable account someone already pays for — which means testing **writes** against production data. Say that out loud before anyone runs a create. Anything requiring a human — signing the agreement, email verification, being made an Admin, requesting sandbox access — hand back rather than looping. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste — the redirect URIs from §6 and the scope strings from §7 — or a ready-to-send application/email body for §5, then continue once they report back with the client ID or the subdomain. ## 5. Obtaining the credentials ### Path A — Customer-generated API access token (self-serve, available today) This is what a customer does in their own account, and the only path that needs nothing from Workable: 1. The customer must be an **Admin**. Workable states there is "no dedicated access only for this use" — no API-management permission exists to delegate. 2. Profile icon → **Settings → Integrations → Apps**, then the **API Access Tokens** section near the top. (Workable also links this directly as `https://www.workable.com/backend/account/integrations`.) 3. **+ Generate API token** → name it → choose an **expiration** (30 days / 90 days / 6 months / 1 year / 2 years) → select **at least one** scope (§7) → **Generate token**. 4. The token appears in a modal **once**. "Your access token will not be visible after closing the modal. If you need it again, you should generate a new one." 5. Separately, get the **account subdomain** (§2). Ask for it by name; do not derive it from an email domain. Two operational facts worth telling the customer at the same time: - **Renewing an expired token keeps the same string.** Workable's expired-token **Renew** button means every script using it keeps working with no redeploy. **Revoking** does the opposite — it breaks everything bound to that token immediately and forces a new one. Tell customers to renew, never to regenerate, unless the token leaked. - **Multiple tokens are allowed**, with different scopes and expirations. A customer can issue one narrow token per integration rather than one god token shared by four vendors. That is the right ask. ### Path B — Partner token (requires being a listed official partner) For an integration Workable has already accepted and listed. The customer opens their Integrations page, finds your integration, and clicks **Generate Token**; Workable shows the permissions attached and the token can be revoked at any time. Calls must carry **both** `Authorization: Bearer ` and `X-WORKABLE-CLIENT-ID: `, and Workable's docs say to email `partners@workable.com` to obtain the UID (`integrations@workable.com` appears in the rate-limit note for the same header). Scopes "will be determined during the development of your integration" — they are negotiated, not chosen at runtime. ### Path C — Partner OAuth 2.0 (authorization code), for a multi-tenant connector Only after Workable has issued a `client_id` and `client_secret`. The documented flow: - **Authorize** — open a browser window to `https://www.workable.com/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&resource=user&response_type=code&scope=r_jobs+r_candidates+w_candidates`. Scopes are space-delimited (`+`-encoded in the query). Note `resource=user` — it is in every documented example. Workable's docs list `state` as no more than good practice; treat it as mandatory, because it is your only CSRF defence here. - **Callback** — Workable redirects to your `redirect_uri` with `?code=...` in the query string. - **Exchange** — `POST https://www.workable.com/oauth/token` with `grant_type=authorization_code`, `client_id`, `client_secret`, `code` and `redirect_uri`. Workable's own example sends these as **multipart form fields** (`curl -F`); a client that only knows how to post a JSON body should be checked before blaming the credentials. - **Response** — `{ access_token, token_type: "bearer", expires_in: 7200, refresh_token, scope, created_at }`. **`expires_in` is 7200 — two hours.** - **Refresh** — same endpoint, `grant_type=refresh_token` with `client_id`, `client_secret`, `refresh_token`. The response "contains a new pair of tokens": **the refresh token rotates**. Store the new one from every refresh, or the second refresh fails. - **Revoke** — `POST https://www.workable.com/oauth/revoke` with `client_id`, `client_secret` and `token` (the refresh token). If the user revokes from either side, the authorization-code flow must be repeated; Workable says this "could manifest as a `401` response code on a refresh token request." - **PKCE** is not part of the documented partner flow, though the authorization server advertises `S256` in its metadata. Do not assume either way without asking Workable. Workable does not publish a refresh-token lifetime. Two hours on the access token plus rotation on every refresh means **a connection that is never exercised is a connection you cannot vouch for** — refresh on a schedule, not lazily. ## 6. Redirect URLs For **partner OAuth only** — neither the API token nor the partner token has a redirect. Workable's docs say the `redirect_uri` "should also be the same provided by the partner in the provisioning process": you do not edit it in a console, you give Workable the list when they provision your client. Register **every** callback host up front. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` The `redirect_uri` you send on both the authorize call **and** the token exchange must match what Workable provisioned. Workable does not document its matching rules, so assume exact string matching and no trailing-slash forgiveness. Discovering a missing region later is an email and a wait, not a config change. ## 7. Scopes — and the permission rules that outrank them Scopes are `r_` / `w_`, space-delimited on the authorize URL. The set a **customer** can choose from when generating an account token, as Workable documents it: | Scope | Grants | | --- | --- | | `r_account` | `/departments`, `/legal_entities`, `/work_schedules`, `/employee_fields` | | `r_jobs` | Jobs, application forms, questions, stages, custom attributes, job members and recruiters, events — **and** `/accounts`, `/accounts/:subdomain`, `/members`, `/recruiters`, `/stages` | | `r_candidates` | Candidates, candidate and job activities, `/candidates/:id/offer` | | `r_employees` | **Sensitive.** `/employees`, `/employees/:id`, employee documents | | `r_requisitions` | `/requisitions`, `/requisitions/:code` (Hiring Plan accounts only) | | `r_timeoff` | Time-off categories, requests, balances | | `w_candidates` | Create/update candidates, comments, tags, disqualify, revert, copy, relocate, move, ratings | | `w_comments` | Candidate comments only (a subset of `w_candidates`) | | `w_departments` | Create, update, merge, delete departments | | `w_employees` | Employee records and documents | | `w_members` | Update, invite, deactivate, restore members | | `w_requisitions` | Create, update, approve, reject requisitions | | `w_timeoff` | Create time-off requests | **The authorization server advertises more than that list**, including `w_jobs`, `r_offers`, `w_offers`, `r_time_tracking`, `w_time_tracking`, `r_reviews`, `w_reviews` and `r_reports`. Those appear in endpoint requirements too (time tracking, performance-review templates). But they are **not in the customer-facing token form**, so a connector that requests them on an account token will not get them, and a customer told to "tick `w_jobs`" will report that the option does not exist. Request the extras only against partner OAuth, and confirm with Workable that your client is permitted them rather than assuming the metadata is a menu. Three rules that outrank scopes entirely — this is where silent partial data comes from: 1. **Token type gates endpoints, independently of scope.** Workable's reference labels each endpoint. `/offers/:id` and offer approve/reject are "accessible **only with user tokens**" — an account token with a perfect scope set cannot read an offer. `/members`, `/recruiters` and `/events` are "accessible with account tokens and user tokens", which excludes partner tokens. If a capability is missing, check the label before changing the scope. 2. **`/employees` quietly returns less to an account token.** Workable: "If you are using an account level token you will get only published employees with publicly available info. To get all types of employees there is a need to add `member_id` of an hr admin." `/employees/:id` and employee documents are the same — user tokens work directly, account tokens "need `member_id` to work." **This produces a 200 with a short, thin list, not an error.** An HRIS sync that looks like it is working and is missing most of the workforce is this. Whose `member_id` you pass decides what you see, so it is that member's Workable permissions — not your scopes — that cap the data. 3. **The generating user's role decides whether the token exists at all.** Only Admins can create or revoke tokens. A customer contact who is not an Admin will report that the Integrations page has no such section, and they will be right. Ask for the narrow set. Changing scopes on an account token means the customer generates a **new** token (the old one keeps its original scopes), and changing them on partner OAuth means every customer re-authorizes. ## 8. Rate limits, the MCP server, listing - **Rate limits are per client, in fixed 10-second windows**, and the credential type sets the ceiling: **account tokens 10 requests / 10 seconds**, **OAuth 2.0 tokens 50 / 10 seconds**, **partner tokens 50 / 10 seconds**. Ten requests per ten seconds is *low* — it is the binding constraint on any full ATS sync, and it is the single strongest technical argument for partner status. Exceeding it returns **HTTP 429**. - Read `X-Rate-Limit-Limit`, `X-Rate-Limit-Remaining` and `X-Rate-Limit-Reset` (a timestamp of the next interval) from every response. **Workable does not document a `Retry-After` header** — do not rely on one; back off to the reset timestamp. - Legacy partners still using borrowed account tokens can reach the 50/10s tier by setting `X-WORKABLE-CLIENT-ID`. That changes throughput only, never what the token may read. - **The MCP server** (`https://mcp.workable.com/mcp`) is a separate product surface with its own OAuth metadata and Dynamic Client Registration. It is worth knowing about — it confirms the authorize/token endpoints and the full scope vocabulary — but it is not a credential route for a REST connector (§ Platform state). - **Listing** in the partner directory comes with the partner program. Workable's docs also carry pages promoting third-party unified-API vendors as an alternative to building direct; treat directory presence as no evidence of what any given integration is built on. ## 9. Verify end-to-end Do not stop at "the token came back." Test what a customer's tenant actually does: 1. Call `GET /accounts` first. It proves the credential works, and on OAuth it tells you **which subdomains** this user can reach (§2). Confirm your connect flow makes the customer choose when there is more than one. 2. Make one real read against `https://.workable.com/spi/v3/jobs` — not against the `www` host. 3. **Read `/employees` and count.** Compare against what the customer says their headcount is. A short list is the `member_id` trap (§7), not an empty account. 4. Read an **offer** if your product maps offers. An account token failing here is by design, not a bug. 5. On OAuth: **refresh, then refresh again using the token returned by the first refresh.** That is what catches a client that ignores rotation, and it is a two-minute test for a failure that otherwise appears hours later. 6. Watch `X-Rate-Limit-Remaining` during a real sync, not a single call. At 10/10s you will find the ceiling fast. 7. Re-run against a **second** account, not the one that generated the credential. | Symptom | Cause | | --- | --- | | 401 on every call, credential looks correct | Missing space between `Bearer` and the token — Workable calls this out by name (§5) | | 401 on a refresh-token request | The user revoked the grant from either side; the full authorization-code flow must be repeated (§5) | | 404 on every call, credential is valid | Wrong or missing account subdomain in the host, or calling the `www` host for account-scoped data (§2) | | Auth works, `/offers/:id` 403s or 404s | Offers are **user-token only**; an account or partner token cannot reach them (§7) | | `/employees` returns a short list of thin records, HTTP 200 | Account-level token without `member_id` of an HR admin — published employees with public info only (§7) | | `/members`, `/recruiters` or `/events` unavailable | Partner tokens are excluded from these endpoints (§7) | | Worked for months, 401 today, nothing changed | The customer's token hit its chosen expiration (30d–2y). Tell them to **Renew**, not regenerate (§5) | | Everything broke at once for one customer | An Admin revoked the token — revocation is immediate and total (§5) | | Customer says the Integrations page has no token section | They are not an Admin; there is no delegated permission (§5) | | `429` under light load | 10 requests/10 seconds on account tokens. Throttle from the `X-Rate-Limit-*` headers; there is no `Retry-After` (§8) | | Second refresh fails, first succeeded | The refresh token rotates; store the new one from every response (§5) | | Customer cannot find the scope you asked for | It is an authorization-server scope, not one offered on the account-token form (§7) | | Requisition endpoints 403/404 for one customer | Requisitions are gated on the Hiring Plan subscription (§7) | | Browser-side call blocked | Workable does not support CORS; this API is server-to-server only | ## 10. Hand off — never commit the token - **Do not** write an API access token, partner token, client secret or refresh token into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the human running this, for their secret store or console. The account **subdomain** is not a secret and may be recorded; the token is, and they usually arrive in the same message — separate them before you paste anything. - If a code change is needed (a callback host, a scope string, a token host), keep it secret-free and say plainly what the human must set out of band. - Report a secret once so it can be pasted into the secret store, say clearly that it is now in the transcript and can be revoked, then move on. - Close with: which credential model you ended up on; the account subdomain(s); the client ID if partner OAuth; where the secret was delivered; the authorize, token and revoke endpoints; the exact scope strings; **the expiration the customer chose and who is watching that clock**; the `member_id` requirement to pass on if employees are in scope; the rate-limit tier the connector will actually get; and whatever is still waiting on Workable. ## Stop and ask Hand back to a human rather than guessing when: **there is no accepted Workable partnership** and the ask is OAuth credentials (say so in the first reply — do not start a registration that cannot complete); the partner application needs mutual-customer, revenue, security or compliance claims; someone proposes moving live per-customer connections to partner OAuth (every customer re-connects); the only available test tenant is production and the task involves writes; a customer cannot generate a token because nobody wants to be made an Admin; the `member_id` requirement means asking a customer to nominate an HR admin whose visibility defines your data set; the 10-requests/10-seconds ceiling makes a promised sync cadence impossible; someone wants to treat the MCP server's Dynamic Client Registration as a REST-API credential route; or the Integrations page and developer hub do not match the **Platform state** section. ## References Verified to resolve on 2026-09-20. - Getting started / generate an API access token — https://workable.readme.io/reference/generate-an-access-token - OAuth 2.0 (partner authorization-code flow, refresh, revoke) — https://workable.readme.io/page/oauth - Partner Token — https://workable.readme.io/reference/partner-token - Rate limiting — https://workable.readme.io/reference/rate-limits - `/accounts` — https://workable.readme.io/reference/accounts - `/accounts/:subdomain` — https://workable.readme.io/reference/accountssubdomain - `/employees` (account-token visibility limits) — https://workable.readme.io/reference/employees - `/employees/:id` (`member_id` requirement) — https://workable.readme.io/reference/employeesid - `/offers/:id` (user tokens only) — https://workable.readme.io/reference/requisitionscode-copy-1 - Webhook subscriptions — https://workable.readme.io/reference/webhook-subscriptions - Workable MCP server — https://workable.readme.io/reference/workable-mcp-server - MCP OAuth authorization-server metadata — https://mcp.workable.com/.well-known/oauth-authorization-server - Full documentation index (every endpoint with its scope and token types) — https://workable.readme.io/llms.txt - Generating/revoking access tokens (Admin rule, expirations, scope table) — https://help.workable.com/hc/en-us/articles/115015785428-Generating-revoking-access-tokens-for-Workable-s-API - Troubleshooting API issues — https://help.workable.com/hc/en-us/articles/4903195036183-Troubleshooting-API-issues - Workable API Documentation (help centre) — https://help.workable.com/hc/en-us/articles/115013356548-Workable-API-Documentation - Workable API help section — https://help.workable.com/hc/en-us/sections/4903169507735-Workable-API - Integrating with Workable via Unified.to API — https://help.workable.com/hc/en-us/articles/21453337746327-Integrating-with-Workable-via-Unified-to-API - Developer hub and partner program — https://www.workable.com/developers - Technology partner program and tiers — https://www.workable.com/partnership-program/technology-partners - Partner application — https://www.workable.com/partnership-program/apply - Third-party partner application — https://www.workable.com/partnership-program/third-party-partners/apply - Account Integrations settings (sign-in required) — https://www.workable.com/backend/account/integrations ## workday-oauth-app URL: https://docs.unified.to/skills/workday-oauth-app/SKILL.md # Workday OAuth 2.0 Credentials **Read the next four lines before anything else, because they are the whole shape of this task.** There is no Workday developer portal that issues you one client ID and secret for all your customers. For a connector that reaches other organizations' Workday tenants, **every customer's own Workday administrator registers an API client inside that customer's tenant and hands you a separate client ID and client secret** (§1). Your deliverable is therefore not a registration form you fill in — it is an **admin runbook you hand to each customer**, plus a connect flow that can accept per-customer endpoints (§3, §5). Three more things decide whether this works, and none of them is on the registration form. **Every host is per-customer, and there are two of them.** The authorization host and the web-service host are different machines. Workday's own worked example shows an authorization endpoint of `https://wd3.myworkday.com/tenant_name/authorize` and a REST endpoint of `https://wd3-services1.myworkday.com/api/v1/tenant_name` — same customer, different hostnames. Your connect flow has to collect both, or derive them, before it can build an authorize URL (§3). **A scope is not enough.** Workday has two independent permission layers: the functional-area *scopes* on the API client, and the *domain security policies* granted to the security group behind the authorizing account. Get the scope right and the domain wrong and Workday does not error — it returns fewer rows, or none. This is the single most misdiagnosed Workday failure and it looks like an empty tenant (§4, §7). **Security changes do nothing until they are activated.** A Workday admin can do every step correctly and still have a dead integration because nobody ran **Activate Pending Security Policy Changes** (§4). ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Which customer tenant**, and which type | Production, Sandbox, Sandbox Preview or Implementation — each is a separate tenant with its own hosts and its own registration (§3) | | **Who the Workday-side admin is** | Only they can run the tasks. You cannot do any of this yourself (§1) | | **REST or SOAP** | Decides the whole integration, not just the credential (§2) | | **Auth host, webservice host, tenant name** | Three distinct values (§3) | | **Which functional areas** the integration needs | Drives the Scope prompt on the API client (§7) | | **Which domain security policies** the endpoints need | Per endpoint, from Workday's published endpoint descriptions (§7) | | **Redirect URIs** | Every callback host you serve, exact strings (§6) | | **Authorization-code grant or ISU refresh-token grant** | Two different registration tasks, two different runbooks (§5) | | **Dedicated ISU, or a named human** | Decides what the token can see, and whether it dies when somebody leaves (§4) | | **Is a Workday sandbox/demo tenant needed?** | Not self-serve; a commercial conversation (§11) | ## Quick Start 1. Confirm the premise with the user: **credentials are per-customer**, so this is a runbook, not a registration (§1). 2. Decide **REST or SOAP** before writing a line of the runbook (§2). 3. Establish the customer's **auth host, webservice host and tenant name**, and which tenant type (§3). 4. Have the admin create an **ISU**, an **integration system security group**, grant **Report/Task View** on the right domains, and run **Activate Pending Security Policy Changes** (§4). 5. Have the admin enable **OAuth 2.0 Clients** in tenant setup, then run **Register API Client** (authorization code grant) *or* **Register API Client for Integrations** (ISU refresh-token grant) (§5). 6. Give them the exact **redirect URI** list to paste (§6). 7. Give them the exact **functional areas** to select, and the **domains** to grant (§7). 8. Collect client ID, client secret, authorization endpoint, token endpoint and REST endpoint — the secret is shown once (§8). 9. Work out the **token lifetimes** and whether non-expiring refresh tokens were enabled (§9). 10. Warn them, in writing, about **tenant refreshes and scope changes** (§10). 11. Verify end to end against the customer's real tenant, then hand off (§13, §14). ## Platform state (verified 2026-09-20 — re-verify before trusting) **A large part of Workday's integration documentation sits behind a Workday Community sign-in.** Everything in this skill that is stated as fact was read on a page a signed-out reader can open. Where a claim could not be verified that way it is labelled **[unverified]** and you should treat it as a hypothesis to test, not a fact to assert. The honest list of what is walled is in §15. - **There is no self-serve Workday developer program that issues production API credentials for other companies' tenants.** Workday's own instruction for connecting a third-party product is a sequence of tasks the customer's administrator runs inside their tenant: `Edit Tenant Setup – Security` → `Register API Client`. Workday publishes this pattern in its own product integration guides. - **Workday does operate a Developer Site Console that issues API clients** (`Create API Client`, with a client name, redirect URI, authorized CORS domains and a scope picker). That path is **Workday Extend / Orchestrate**, it routes through regional API gateways rather than the customer's tenant host, and — crucially — *"When you migrate your client to a nondevelopment tenant such as an implementation or production tenant, you must allow the client ID on the target tenant. Only a Company Administrator can add or remove tenants from an API client's allowlist."* So even the closest thing to a central client still needs per-customer consent by that customer's own administrator. See §1. - **REST API security is documented as acting on behalf of a user.** Workday: *"REST APIs act on behalf of the individual using the client. The user's security profile affects the REST API access to Workday resources."* And: *"If the current user can't access the domain or business process, the REST API returns an error. Example: The `GET /supervisoryOrganizations` REST API returns only the organizations that the user has authorization to access."* Scopes cap what a token may request; they never widen what the account may see. - **Report/Task permissions are what REST needs. Integration permissions are SOAP-only.** Workday states this explicitly: *"Workday REST APIs require the Report/Task Permissions in the security policy, whether the user is an ISU or an individual user. The Integration Permissions only apply to Workday SOAP web services."* **View** for GET, **Modify** for POST/PUT/PATCH/DELETE. A runbook that tells an admin to grant Integration → Get for a REST integration is wrong and will look like a permissions bug forever. - **Scopes are functional areas, published per endpoint.** Workday: *"The endpoint descriptions … include the domains that secure the endpoints. The endpoint descriptions also include the scope, which is the corresponding Workday functional area."* Both the REST API Explorer and the REST Services Directory are reachable signed-out. - **Pagination is `limit`/`offset`,** default `limit` 20, maximum 100, *"Some REST APIs override this value with a maximum of 1000"*, and Workday sorts *"in ascending instance ID order"* by default. - **`Non-Expiring Refresh Tokens` is a checkbox on the registration task,** and Workday's own integration guides tell administrators to tick it. - **Refresh-token lifetime is absolute, not rolling** — for the Developer Site client at least: *"That refresh timeout begins at the first authorization step and doesn't get extended, even if you get a new refresh token."* **[unverified]** for the in-tenant client; assume the same and test. - **Access-token lifetime is not a fixed number.** Workday: *"The token timeout is tied to the session timeout configured for the user by the Workday administrator."* Anything that hardcodes one hour is guessing (§9). - **A tenant refresh copies production into the lower tenants.** Workday: *"A tenant refresh copies all assets (including the ISU configuration) from Production (PROD) to Implementation (IMPL) and Sandbox (SBOX) tenants."* Written about Extend apps, but the mechanism is the tenant, not the app (§10). - **A Workday sandbox is not self-serve.** Non-production tenants belong to Workday customers and to partners under a partner agreement; Workday's partner and Marketplace programs are commercial relationships, not a signup form (§11). If the customer's tenant does not look like this, stop and report what you actually see rather than clicking on. ## 1. The premise: per-customer credentials, not a central app **Answer this out loud before you do anything else, because it reshapes the project plan.** For a connector that reaches *other organizations'* Workday tenants, the working model is: > Each customer's Workday administrator registers an API client in their own tenant and gives you that tenant's > client ID, client secret, authorization endpoint, token endpoint and REST endpoint. You store one credential > set **per connection**, not one per integration. The consequences, all of which are project-planning consequences rather than technical ones: - **Your connect flow must accept credentials from the customer.** A "Connect Workday" button that only kicks off a redirect cannot work, because you do not know the authorize host until the customer tells you. - **Onboarding is a document.** Budget for a runbook, a support path for admins who get stuck at *Activate Pending Security Policy Changes*, and a way to collect a client secret securely. - **Every scope change is a customer-side change.** You cannot add a functional area yourself. See §10. - **There is nothing to "get approved".** No review queue, no install cap, no listing gate on the credential itself. The gate is the customer's own security team. **The one thing that looks like a central app, and why it is not.** Workday's Developer Site Console does issue an API client tied to *your* account, with a scope picker and a redirect URI, and it authenticates against regional API gateways (`https://api.workday.com` / `https://api.us.wcp.workday.com` for the US, plus EU, UK and Singapore gateways) rather than a customer host. But it is the **Workday Extend / Orchestrate** path — Workday says plainly *"Don't use tenant base paths when calling Workday REST APIs with Extend and Orchestrate"* — and reaching any customer's implementation or production tenant still requires **that customer's Company Administrator** to add the tenant to the client's allowlist. It is per-customer consent wearing a central client's clothes, and it comes with a different product (and a different commercial relationship) attached. If someone proposes it as a shortcut to one-credential-for-all, that is a **Stop and ask**. ## 2. REST or SOAP — decide this first Workday has two completely separate API surfaces, with two separate credential models. Picking the wrong one costs weeks, because almost nothing transfers between them. | | **REST (Workday REST APIs)** | **SOAP (Workday Web Services, WWS)** | | --- | --- | --- | | Credential | OAuth 2.0 — client ID + secret + refresh token | ISU username and password, carried as a WS-Security UsernameToken **inside the SOAP envelope** | | Registration | `Register API Client` / `Register API Client for Integrations` in the tenant | No API client at all — just an ISU and a security group | | Endpoint shape | one base path, service and version in the path | **one endpoint per service**, each versioned separately | | Permissions | **Report/Task** View/Modify on domains | **Integration** Get/Put on domains | | Workday's own positioning | *"better suited for smaller, user-initiated transactions … not for bulk data export or import"* | *"generally favored for large, scheduled, system-to-system data transfers"* | | Coverage | Narrower; some objects have no REST equivalent | Much wider; the long tail of HR, payroll and financial objects lives here | | Docs | REST API Explorer / REST Services Directory | Public Web Services API Directory (WSDLs and XSDs) | **Choose SOAP when** the integration needs breadth (payslips, deductions, bank accounts, payment elections, worker documents, financial objects) or bulk extraction. **Choose REST when** the integration needs a modern JSON surface, low-latency reads, business-process events, or write operations that REST actually exposes. Workday's own guidance is to use REST *"also for cases where no equivalent SOAP API exists"* — for example retrieving business-process event attachments. Two practical notes that decide the argument more often than the table does: - **SOAP needs no OAuth registration at all.** If the customer's security team is slow, the ISU-and-password path gets you connected sooner — at the cost of holding a password. - **Some data exists only on one side.** Worker document *files*, for instance, are a SOAP-side concern; the REST person photo endpoints are not a substitute. Check the object list against both directories before promising coverage. **Product fact (2026-09-20).** This platform ships **two separate Workday connectors**, and the difference between them is exactly this fork. The REST connector uses OAuth 2.0 authorization-code grant against the service-based REST APIs (staffing, person, absence management, recruiting, learning, payroll, customer accounts, accounts payable, procurement, business-process events) and covers HRIS, ATS, LMS and accounting objects. The **legacy** connector is SOAP against Workday Web Services, authenticates with an **ISU username and password over WS-Security UsernameToken with no HTTP Authorization header at all**, pins a single WWS version across every service, and covers a noticeably wider object set — including payslips, deductions, bank accounts, locations, taxonomies, payment terms, payments and a passthrough surface. **Choosing the wrong one is the expensive mistake here.** Confirm the current split with the connector's owner before you write a runbook for a customer. ## 3. Tenant, hosts and endpoints Three values identify a customer, and they are not interchangeable. | Value | What it is | Where the admin finds it | | --- | --- | --- | | **Tenant** | the customer's Workday tenant name, e.g. `tenant_name` | in the path of the REST endpoint, and in the sign-in URL | | **Webservice host** | the host that serves API traffic, e.g. `wd3-services1.myworkday.com` | the host portion of the REST API endpoint | | **Auth host** | the host that serves the authorize page, e.g. `wd3.myworkday.com` | the host portion of the authorization endpoint | Workday's own worked example, verbatim: *"When the endpoint is `https://wd3-services1.myworkday.com/api/v1/tenant_name`, then the Webservice Host is `wd3-services1.myworkday.com` and the Tenant is `tenant_name`."* and *"When the authorization endpoint is `https://wd3.myworkday.com/tenant_name/authorize`, the Auth Host is `wd3.myworkday.com`."* So the endpoint shapes are: ``` authorize https:////authorize token https:///ccx/oauth2//token REST https:///api////... ``` Notes that each cost a cycle: - **The authorize URL does not contain `/ccx/oauth2/`.** It is `https:////authorize`. The token URL *does*. If you are working from a brief that puts both under `/ccx/oauth2/`, the authorize half is wrong. - **The REST base path is published in two forms and both are in circulation.** Workday's REST fundamentals give `https://{tenantHostname}/api/{serviceName}/{version}/{tenant}` with the worked example `https://tenant1.myworkday.com/api/staffing/v7/gms/workers`; Workday's own product guides also print `https://example.myworkday.com/ccx/api/v1/`. Treat **both `/api/…` and `/ccx/api/…`** as shapes a customer may paste, and normalize whichever arrives. A connector that only recognises one of them will build a doubled path and 404 (§13 has a live example of exactly this). - **`wd2` / `wd3` / `wd5` / `wd103` style prefixes are the Workday pod the customer sits on**, and `-impl-`/`-services1` segments distinguish implementation from production service hosts. Workday's own examples show `wd3.myworkday.com`, `wd3-services1.myworkday.com` and `example.myworkday.com`; the fuller `wdN-impl-servicesN.workday.com` family is consistent across many integrators' guides but **[unverified]** on a publicly reachable Workday page. **Never hardcode a host — always collect it.** - **Object ids** come in three documented forms: a 32-character Workday ID (WID), a reference id (`/workers/Employee_ID=21005`), and `me` for the current user. **Tenant types are separate universes.** Workday customers run a **Production** tenant, a **Sandbox** (a copy of production data, used for testing), a **Sandbox Preview** (a copy plus the next release's functionality), and one or more **Implementation** tenants. Each has its own hostname, its own API client registration, its own ISU and its own security activation. A credential from sandbox will not work against production, and testing in sandbox proves nothing about production's domain grants. Plan for **two runbook runs per customer**, minimum. ## 4. Before any client exists: tenant setting, ISU, group, activation This section is where Workday integrations actually die. Everything here is the customer admin's work. **4.1 — Turn OAuth on for the tenant.** Task `Edit Tenant Setup – Security`, tick **OAuth 2.0 Clients Enabled**. Workday's description: *"Enables OAuth 2.0 clients to access the Workday API for your tenant."* If this is off, `Register API Client` is not usable and nothing downstream matters. **4.2 — Create an Integration System User (ISU).** Task `Create Integration System User`. Workday's own guidance for app integrations: create the account and *"Keep session timeout at zero to prevent expiration."* Common practice is also to tick **Do Not Allow UI Sessions** so the account cannot be used interactively — sensible, but note the interaction in §5.1. Use a **dedicated ISU**, never a named person. A credential tied to a human dies when that human changes role or leaves, and takes the customer's sync with it. **4.3 — Create a security group and put the ISU in it.** Task `Create Security Group`, type **Integration System Security Group (Unconstrained)** — or **Constrained**, if the customer wants to limit the integration to specific supervisory organizations. Add the ISU as a member. A constrained group is a perfectly good answer to a nervous security team, but say plainly that it will make the integration return a **subset** of the company, and that the subset will look like missing data downstream. **4.4 — Grant domain permissions.** Task `Maintain Permissions for Security Group`. For every endpoint the integration calls, grant the group access on the domain that secures it: - **REST: `Report/Task` permission — `View` for GET, `Modify` for POST/PUT/PATCH/DELETE.** - **SOAP: `Integration` permission — `Get` / `Put`.** - **Do not mix these up.** Workday: *"The Integration Permissions only apply to Workday SOAP web services."* Two rules that catch people: - **Sub-resources need the parent.** Workday: *"If your app calls a REST API sub-resource, ensure that the user has access to the parent resource."* A grant on the worker photo domain is useless if the worker domain is not granted. - **Domain names drift between Workday releases.** Take the domain from the endpoint's own "Secured by" line in the current API documentation, not from a runbook written two releases ago. **4.5 — Activate.** Task `Activate Pending Security Policy Changes`, with a comment. **Nothing in 4.3 or 4.4 takes effect until this runs.** Workday's own app-integration steps list it as a distinct step for exactly this reason. Put it in the runbook in bold, and put it in your support macro too — it is the first thing to ask about when a brand-new connection authenticates cleanly and returns nothing. ## 5. Register the API client — two tasks, two runbooks Workday has **two** registration tasks and they produce different things. Pick deliberately. ### 5.1 `Register API Client` — authorization code grant (browser flow) This is the one that uses a redirect URI and produces a user-bound token. Workday's own product guides give the sequence: | Field | Value | | --- | --- | | **Client Name** | anything identifiable, e.g. your product's name | | **Client Grant Type** | **Authorization Code Grant** | | **Access Token Type** | **Bearer** | | **Redirection URI** | your callback (§6) | | **Non-Expiring Refresh Tokens** | **tick it** (§9) | | **Scope (Functional Areas)** | the areas the integration needs (§7) | Then the admin, or whoever will own the connection, completes the browser authorization. **Whoever signs in at the authorize screen is whose permissions the token carries.** Workday's own instruction for this is worth copying into your runbook verbatim: sign in **in an incognito window** as the integration user, because otherwise *"the integration doesn't connect with the wrong credentials for the Workday tenant, which would cause syncing issues."* For SSO tenants Workday gives a bypass sign-in URL of the form `https:///wday/authgwy//login.htmld?redirect=n`. Note the tension with §4.2: an ISU set to **Do Not Allow UI Sessions** cannot sign in at an authorize screen. For the browser flow the customer either relaxes that setting for the authorization, or authorizes as a different service account. Raise it before they hit it. **[unverified]** on a public Workday page — confirm in the tenant. ### 5.2 `Register API Client for Integrations` — ISU refresh-token grant (no browser) A separate task. No redirect URI, no consent screen. The admin registers the client, ticks **Non-Expiring Refresh Tokens**, selects the functional areas, saves the **Client ID** and **Client Secret**, and then — from the client's related actions — runs **`Manage Refresh Tokens for Integrations`**, selects the **ISU** in the **Workday Account** field, and copies the refresh token off the *Successfully Regenerated Refresh Token* page. This path is structurally better for a machine integration: the token is bound to the ISU rather than to whoever happened to click, there is no SSO or incognito dance, and there is no consent screen to re-run. Its cost is that the customer hands you a long-lived refresh token in a form, so your credential intake has to be secure. Workday documents this task as requiring the *Integration Security* domain in the Integration functional area plus domains in the System functional area. **If your connect flow supports both, offer 5.2 first.** Most Workday admins find it easier, and it removes the "who authorized this?" ambiguity that causes partial-data incidents months later. ## 6. Redirect URIs For the authorization-code path only (§5.1). Give the admin the exact strings; Workday matches them against what your authorize request sends. ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` - **Give each customer only the one host that serves them.** Because the client lives in *their* tenant, there is no benefit to registering four, and a wrong one is a support ticket. - **Whether the task accepts more than one redirection URI per client is [unverified]** on a publicly reachable Workday page. Ask the admin what the form shows and record the answer here. - **The redirect URI is constant; the authorize host is the customer's.** One callback serves every tenant. Carry the tenant identity in `state`. - Workday's own authorize example marks `redirect_uri` as optional and defaulted from registration, and shows the same value sent again at the token exchange. Send it explicitly in both. ## 7. Scopes are functional areas — and they are only half the permission **Layer 1 — Scope (Functional Areas), on the API client.** These are Workday product areas, not OAuth scope strings in the usual sense. They are selected from a prompt at registration time, and they are **not sent in the authorize request** — Workday's documented authorize parameters are `response_type`, `client_id`, `redirect_uri` and `state`. A connector therefore cannot widen scope at connect time; the customer's registration decides it. The functional-area names as they appear in Workday's published REST service descriptions (observed 2026-09-20 across the service specifications this platform works from — the exact list a given tenant offers depends on which Workday products that customer licenses): | Area | Typically covers | | --- | --- | | `Staffing` | workers, supervisory organizations, job changes — the backbone of any HRIS read | | `Contact Information` | work/home addresses, emails, phones | | `Personal Data` | gender, date of birth, marital status, personal photo | | `Organizations and Roles` | supervisory org detail, org membership, manager resolution | | `Time Off and Leave` | time-off details, balances, requests | | `Core Payroll`, `USA Payroll`, `CAN Payroll` | payroll inputs and country payroll | | `Core Compensation` | compensation | | `Recruiting`, `Talent Pipeline`, `Worker Profile and Skills` | ATS objects, candidate skills/experience/education | | `Learning Core` | learning content | | `Customer Accounts`, `Supplier Accounts`, `Procurement`, `Project Billing` | AR, AP, purchase orders, billing | | `Performance Enablement` | reviews and performance | | `System`, `Tenant Non-Configurable` | tenant-level and business-process-event endpoints | **Layer 2 — domain security policies, on the security group (§4.4).** This is the layer that actually decides what comes back. Workday publishes, for each endpoint, both *"Secured by:"* (the domains) and *"Scope:"* (the functional area) in the endpoint description in the REST API Explorer and the REST Services Directory. Read the endpoint you actually call; do not extrapolate from a sibling. **Why this matters more than it sounds.** A token can carry the `Staffing` scope and still return an empty list, because the ISU's group was never granted `View` on the domain securing `/workers`. There is no error, no warning, no `insufficient_scope` header — the collection is simply short or empty, exactly as Workday documents for `GET /supervisoryOrganizations`. When a customer says "your sync is missing people", check the domain grants and the group's constraint before you look at a single line of mapping code. **Ask for the narrowest set that works, and grant `View` only** unless the integration writes. Write operations need `Modify`, and business-process-backed writes additionally need permission on the business process itself. ## 8. Capture the credentials From the registered client, the admin collects and sends you: - **Client ID** - **Client Secret** — Workday's own warning: *"it's not possible to recover it after closing this page."* If it is lost, the client must be re-registered or a new secret generated. Capture it at registration. - **Authorization Endpoint** — `https:////authorize` - **Token Endpoint** — `https:///ccx/oauth2//token` - **REST API Endpoint** — carries the webservice host and the tenant - **Refresh Token** — only on the 5.2 path, from `Manage Refresh Tokens for Integrations` **Token exchange.** Workday's published form for the authorization-code exchange is a POST to the token endpoint with `grant_type=authorization_code`, `client_id`, `code` and `redirect_uri`, body encoded `x-www-form-urlencoded`, with the client credentials in the Authorization header. Be careful here: Workday's **Developer Site** client uses a non-standard header — base64 of `clientId:clientSecret` prefixed with `ID ` and a space, not `Basic`. For the **in-tenant** client the conventional `Authorization: Basic base64(client_id:client_secret)` is what integrators use and what this platform sends, but **[unverified]** on a publicly reachable Workday page for the `/ccx/oauth2//token` endpoint. If an exchange is rejected with no useful message, try the other header form and the credentials-in-body form before concluding the secret is wrong. **PKCE.** Workday documents PKCE (`code_challenge` / `code_challenge_method=S256` / `code_verifier`) for the Developer Site client. It is aimed at public clients that cannot hold a secret; a server-side connector holding a client secret does not need it. This platform does **not** send PKCE for Workday. Rotating a client secret breaks every exchange and refresh for **that one customer** until the new value is deployed. Never rotate without that customer's explicit go-ahead. ## 9. Token behaviour | Token | Lifetime | Rule | | --- | --- | --- | | `access_token` | **not a fixed number** | Workday: *"The token timeout is tied to the session timeout configured for the user by the Workday administrator."* Bearer type. | | `refresh_token` | absolute window set at registration, **or non-expiring** | *"That refresh timeout begins at the first authorization step and doesn't get extended, even if you get a new refresh token."* Ticking **Non-Expiring Refresh Tokens** removes the window. | | `code` | short-lived, single use | Reuse gives a grant error | What follows: - **Do not hardcode an access-token lifetime.** Different customers configure different session timeouts, and an ISU is often configured with a session timeout of 0. A connector that assumes an hour will, for a customer whose timeout is shorter, keep presenting a dead token until the next scheduled refresh and produce intermittent `401 Invalid Access Token`. Prefer honouring the `expires_in` the token endpoint returns, and treat a 401 as a refresh trigger rather than a failure. - **Insist on Non-Expiring Refresh Tokens at registration.** Because the refresh window is absolute rather than rolling, a connection that refreshes diligently every hour still dies on a fixed date unless the box was ticked. Retro-fitting it means the customer re-registers or re-mints — so get it right the first time. - **Assume the refresh response may rotate the refresh token, and persist whatever comes back.** Workday's Developer Site path explicitly returns a new refresh token on every refresh. Whether the in-tenant `/ccx/oauth2//token` endpoint does the same is **[unverified]**. The safe implementation stores the returned refresh token when one is present and reuses the existing one when it is absent — which is what this platform does. - **A "Refresh Token Rotation" setting** on the registration task could **not** be confirmed on any publicly reachable Workday page. If the admin reports seeing such a control, record what it says and add it here; do not assert it exists. ## 10. What silently kills a Workday connection Rank these when a connection that worked stops working. - **A tenant refresh.** Workday: *"A tenant refresh copies all assets (including the ISU configuration) from Production (PROD) to Implementation (IMPL) and Sandbox (SBOX) tenants."* When a customer refreshes their sandbox from production, the sandbox's registered clients, ISUs and security configuration are replaced wholesale by production's. Any credential you hold against that sandbox is now pointing at configuration that no longer exists as you knew it. **Treat every sandbox connection as disposable, and tell customers in writing that refreshing a tenant may require re-running the runbook for that tenant.** The precise effect on an already-issued refresh token is **[unverified]** — Workday does not publish it on a signed-out page — but the observed field behaviour is that lower-tenant connections stop working after a refresh and must be re-established. Do not promise otherwise. - **Do not confuse tenant refreshes with Workday's weekly service updates.** Workday delivers *"Weekly service updates … within the weekend maintenance window"*, and separate feature releases that reach preview tenants about five weeks early. Those are code updates and do not re-copy tenant data or configuration. A tenant refresh is a customer-initiated copy of production into a lower tenant. Different events, different blast radius. - **A functional area added after the token was minted.** Because scope lives on the client rather than in the authorize request, adding an area means the customer edits the client — and the existing token may not pick it up. **Re-mint the refresh token (5.2) or re-authorize (5.1) after any scope change.** Budget for this every time you add an object to the integration. - **Domain grants changed but not activated.** Back to §4.5. - **The ISU's password expired, or the account was disabled.** Especially on the SOAP path, where the password *is* the credential. Ask the customer to exempt the ISU from password expiry. - **A named human authorized the connection and then left.** §4.2. This is why the ISU exists. - **A constrained security group's scope changed.** Reorganizations move supervisory orgs; a constrained group quietly starts returning fewer workers. ## 11. Sandboxes, partners and Marketplace - **You cannot self-serve a Workday tenant.** Workday tenants belong to Workday customers. A vendor gets one by being a customer, by being given access by a customer, or through a Workday partner arrangement — in every case a commercial conversation with a cost attached, not a signup form. Do not promise a testing environment you have not secured. - **Test against a customer's Implementation or Sandbox tenant** where the customer will allow it. It is the normal arrangement, and it is also why §10's tenant-refresh warning matters so much. - **Workday Marketplace and the partner programs are a separate, later question.** Workday describes Marketplace solutions as *"built on and certified for the Workday platform"*, with a **Design Approved** designation for integrations whose *"use case, architecture, and data mapping have been reviewed by Workday"*. Listing is about distribution and credibility; **none of it is required to connect a customer who wants you connected.** Eligibility, certification scope, agreements and fees are commercial decisions — surface them and hand back. ## 12. RaaS — the other data path, and what it does to scoping **Reports-as-a-Service (RaaS)** exposes a Workday **custom report** as a web service. Workday's description: RaaS *"enables advanced and search reports to function as web services, allowing access to report results through URLs"*, with output as **CSV, GData, JSON, RSS, Simple XML or Workday XML**, and a stable namespace of the form `urn:com.workday.report/` that survives renaming the report. It is, in practice, how a great many Workday integrations actually move bulk data, and it changes the shape of this whole task: - **The contract is the report, not the API.** A customer's Workday analyst builds the report, and its columns are whatever they chose. Two customers' "worker report" will not match. That is flexibility for them and per-customer mapping work for you. - **Scoping moves.** What the report returns is bounded by the report's own data source and filters *and* by the running account's security — so §4 still applies, but the functional-area scope conversation is replaced by a conversation about which report and which data source. - **Workday's own advice:** copy a standard report to a custom report and web-service-enable the copy, rather than depending on a delivered report Workday may change. - **Report owner usernames must not contain a backslash** for GET requests to execute — an odd, real constraint worth knowing when a single customer's RaaS calls fail and nobody else's do. If the integration's value is bulk HR extraction and the customer already has Workday analysts, ask whether RaaS is the right path before writing a REST runbook. ## 13. What this platform's connector expects today **Dated product facts, observed 2026-09-20.** Confirm with the connector's owner before you register anything — connector configuration changes more often than a vendor's tenant does. - **The REST connector uses per-customer OAuth credentials and says so.** It is explicitly *not* configured to use this platform's shared OAuth credentials. The connect experience asks the customer for **five** values — OAuth2 Client ID, OAuth2 Client Secret, Authorize Endpoint, Token Endpoint and REST API URL — with on-screen help pointing at Workday's *View API Clients* report for each. That is the per-customer model made concrete. - **It also supports a token-style connection** taking Client ID, Client Secret, **Refresh Token**, Token URL, API URL and Tenant ID — i.e. the §5.2 ISU refresh-token path, where the customer mints the refresh token in Workday and pastes it in. On that path the connector immediately performs a refresh to obtain a first access token, so a bad value fails at connect time rather than silently later. - **The authorize request carries `client_id`, `response_type`, `redirect_uri` and `state` — and no `scope`.** That is correct for Workday (scope lives on the API client, §7), but it means the connector has **no scope catalogue of its own**: what a connection can read is entirely decided by what the customer ticked. Your runbook is the only scope control you have. - **No PKCE is sent.** The code exchange is a form POST with HTTP Basic client authentication, carrying `grant_type`, `code` and `redirect_uri`; refresh is a form POST with `grant_type` and `refresh_token`, also Basic-authenticated. On the token-style path the client id and secret are additionally repeated in the body. - **Tenant and host are derived, not asked for, on the OAuth path.** The connector parses the tenant out of the pasted Token URL (`/ccx/oauth2//token`), falling back to the Authorize URL (`https:////authorize`) and then to a `/ccx/api/v/` API URL, and substitutes it into every service path at request time. On the token-style path an explicitly-entered Tenant ID wins. - **The pasted API URL is truncated to `/ccx/api`** so that service paths append cleanly instead of doubling the tenant segment. **This is a real risk** — see the bug note below. - **List calls page with `limit`/`offset` and a maximum page size of 100**, reading results from the `data` array, with `format=json` on reads and `PATCH` for updates. - **The connector's own setup notes tell the customer to tick Non-Expiring Refresh Tokens**, and to re-mint the refresh token if functional areas are added after the fact — consistent with §9 and §10. - **The access-token lifetime is pinned to 3600 seconds**, including by overwriting whatever the token endpoint reports on refresh. See the risk note below. - **The legacy SOAP connector** takes four values — Workday Host, Tenant Name, ISU Username, ISU Password — normalizes the host, appends `@` to the username itself, sends no HTTP Authorization header, and parses SOAP faults into readable errors. Its own onboarding checklist walks the admin through ISU creation, an unconstrained integration system security group, per-service **Integration Get/Put** domain grants, and **Activate Pending Security Policy Changes**, then tells them to read host and tenant off a WSDL URL via the *Public Web Services* report. - **No plaintext customer credential appears in either connector's source.** The only credential-shaped strings are obvious test placeholders in unit tests. **Two things to raise with the connector's owner** (both verifiable against Workday's published endpoint shapes in §3): 1. **The API-URL normalization only recognises `/ccx/api`.** Workday's REST fundamentals document the integration base path as `https://{tenantHostname}/api/{serviceName}/{version}/{tenant}`, and Workday's own product guide prints a customer-facing REST endpoint of `https://wd3-services1.myworkday.com/api/v1/tenant_name` — **without `/ccx`**. A customer who pastes that form gets no normalization, and service paths then append to `.../api/v1/`, producing a doubled path that 404s. Worth handling both shapes. 2. **Pinning the access-token lifetime to 3600 seconds contradicts Workday's documented behaviour**, which ties the access-token timeout to the authorizing account's configured session timeout. For a customer whose ISU session timeout is shorter than an hour, the connector will treat a dead token as live and produce intermittent `401 Invalid Access Token` until the next refresh. Honouring the returned `expires_in`, and refreshing on 401, is the safer shape. ## 14. Verify end-to-end Authorizing is the easy part. Test the part that fails. 1. Complete the connect flow against the **customer's real tenant**, with the account that will own the connection — not your own convenience account (§5.1). 2. Confirm you received a **refresh token**, and that **Non-Expiring Refresh Tokens** was ticked (§9). 3. Force a refresh, then force a **second** refresh using whatever the first one returned. That catches both a client ignoring rotation and a client over-trusting a pinned lifetime. 4. Make **one read per functional area** you asked for, and compare row counts against what that account sees in the Workday UI. A short list is a permission finding, not a mapping bug (§7). 5. Hit at least one **sub-resource** (a worker's addresses, photos or balances) — sub-resources fail independently of their parent when a domain grant is missing (§4.4). 6. If the customer has a sandbox, run the whole thing there too, and then ask when they next plan to refresh it (§10). | Symptom | Cause | | --- | --- | | `401 Unauthorized` | Valid credentials were not supplied. Check the Authorization header form (§8). | | `401 Invalid Access Token` | Token invalid or expired — generate a new one. If intermittent, suspect a pinned lifetime against a shorter session timeout (§9). | | `401` everywhere, brand-new client | `OAuth 2.0 Clients Enabled` never ticked in tenant setup (§4.1). | | `403 Not Authorized` / `S22 Permission Denied` | The account lacks the domain that secures the resource — **or** the parent resource of a sub-resource (§4.4). | | `404 Not Found: {value}` / `S21` | Workday lists two causes: a bad path or id, **and** the user not having correct permissions. Check permissions before hunting for the record. | | `S19 Not Authorized` (401) | Authentication missing or invalid — verify the bearer token. | | List returns 200 with no rows, or far too few | The classic Workday failure. Domain grants missing, never activated (§4.5), or a constrained security group (§4.3). Not a mapping bug. | | `Integration → Get` granted, REST still 403 | REST needs **Report/Task**; Integration permissions are SOAP-only (§4.4). | | Everything worked, then a whole tenant stopped | A tenant refresh (§10), or a password/account change on the ISU. | | One object stopped after you shipped a feature | A functional area was added to the client but the token was never re-minted (§10). | | Authorize page 404s or shows the wrong company | Wrong auth host or wrong tenant in the authorize URL. Remember the auth host is **not** the webservice host (§3). | | Paths 404 with the tenant appearing twice | The pasted REST URL was in a form the connector did not normalize (§13). | | Redirect error at the callback | The redirection URI registered on the client does not match exactly (§6). | | `405 Method not Allowed` / `S12` | Unsupported method for that resource. | | `S3 Unsupported Version` | The service version in the path is not available in that tenant. | | `S27` / `S38` (504) | Transaction timeout — reduce the scope of the request; for `S38` the write may have succeeded, so verify before retrying. | | SOAP returns HTTP 500 with an XML body | A SOAP Fault, not a server outage — parse `faultstring` / `Message` for the real cause. | ## 15. What could not be verified without a Workday Community login State these as open questions, not facts. Each was searched for and not found on a page a signed-out reader can open as of 2026-09-20. - The canonical admin-guide pages for **Register API Client**, **Register API Client for Integrations** and **Integration Security in Workday** (under `doc.workday.com/admin-guide/…/authentication-and-security/…`) are behind a Workday Community sign-in — some return `401`, one returns `200` with a sign-in screen. Everything in §5 was reconstructed from Workday product guides that *use* those tasks and are public. - The full field list and option set of the registration tasks — including whether **multiple redirection URIs** are accepted, and whether a **Refresh Token Rotation** control exists. - Whether the in-tenant `/ccx/oauth2//token` endpoint accepts standard **HTTP Basic** client authentication, and whether it rotates the refresh token on every refresh. - The exact effect of a **tenant refresh** on already-issued refresh tokens and registered API clients in the refreshed tenant. - The **sandbox refresh cadence** (commonly quoted as weekly) — Workday's public tenant-types material describes the tenant types but not a refresh schedule, and the tenant-management reference page returns **401**. - The `wdN-impl-servicesN` **host naming family** beyond the `wd3.myworkday.com` / `wd3-services1.myworkday.com` and `example.myworkday.com` examples Workday publishes. - Whether an ISU with **Do Not Allow UI Sessions** can complete an authorization-code consent screen. - Whether there is an **error-message or rate-limit reference** for REST beyond the public error-code table — `community.workday.com/rest/*` returns **401**. ## 16. Hand off — never commit the secret - **Do not** write a customer's client secret, refresh token or ISU password into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. These are **customer** credentials, not yours, and a leak is that customer's incident. - **Collect them through a secure intake**, not email. The admin is copying a value that is shown once. - If a code change is needed (a new callback host, host normalization, a token-lifetime fix), keep it credential-free and say what the human must set out of band. - Close with: which customer and which **tenant type**; **REST or SOAP**; which registration task was used (§5.1 or §5.2); the client ID; where the secret and refresh token were delivered; the three endpoint URLs with the `` / `` / `` values filled in; the exact **functional areas** ticked; the **domains** granted and confirmation that **Activate Pending Security Policy Changes** was run; which account the credential belongs to and whether it is a dedicated ISU; whether **Non-Expiring Refresh Tokens** was ticked; and anything left for the customer to do. ## Stop and ask Hand back to a human rather than guessing when: someone proposes a single central Workday credential for all customers, or proposes the Workday Extend / Developer Site client as a way to get one (§1); the choice between REST and SOAP has not been made and the object list has not been checked against both directories (§2); a customer's security team wants a **constrained** group and nobody has agreed what data loss that implies (§4.3); a customer cannot or will not run `Activate Pending Security Policy Changes` (§4.5); the integration needs a Workday tenant of your own, or a sandbox, or a Marketplace listing or certification — all commercial decisions (§11); RaaS is on the table and nobody has decided who owns the report definition (§12); a token behaviour in §9 needs to be relied on rather than tested; or the customer's tenant does not match the **Platform state** section above. ## References Verified 2026-09-20 — every URL below returned HTTP 200 to an anonymous reader. Workday's developer documentation also serves a Markdown form of each page at `https://developer.workday.com/doc/{docId}.md`, which is the reliable way to read it (the HTML is a JavaScript shell). **Workday REST and security** - Concept: Workday REST API Security (user-bound access, Report/Task vs Integration permissions, domains and scopes) — https://developer.workday.com/doc/dan1370797986071.md - Workday REST API Fundamentals (base paths, id formats, pagination) — https://developer.workday.com/doc/GUID-85810465-bcfb-4fdf-a26d-55eaff3968a8-enHYPHENus.md - Reference: REST API Error Messages (the S-code table used in §14) — https://developer.workday.com/doc/GUID-6f06b090-c7b5-47cc-b929-d2a1f1116eef-enHYPHENus.md - Concept: Workday REST API Pagination — https://developer.workday.com/doc/lvb1611857200890.md - REST API Authentication (index) — https://developer.workday.com/doc/GUID-6c598444-ce67-40d5-bd95-267ecfe439b8-enHYPHENus.md - REST APIs in Apps and Integrations (REST vs SOAP positioning, RaaS) — https://developer.workday.com/doc/GUID-6b063b57-9d85-474b-99b0-734d714652fd-enHYPHENus.md - SOAP API Authentication and Security — https://developer.workday.com/doc/GUID-4c354bdb-06cd-461d-a632-ea8303beaedb-enHYPHENus.md - REST API Explorer (per-endpoint "Secured by" and "Scope") — JavaScript app, open it in a browser — https://developer.workday.com/rest-api-explorer - REST Services Directory — JavaScript app, open it in a browser — https://community.workday.com/sites/default/files/file-hosting/restapi/index.html - Public Web Services (SOAP) API Directory — https://community.workday.com/sites/default/files/file-hosting/productionapi/index.html - SOAP API Reference landing page — https://community-content.workday.com/en-us/public/products/platform-and-product-extensions/soap-api-reference.html - Machine-readable index of Workday developer documentation — https://developer.workday.com/llms.txt **The in-tenant registration runbook (public guides that use the tasks)** - Connect to Workday Using OAuth — the fullest public walkthrough of `Register API Client`, the auth host vs webservice host split, `Non-Expiring Refresh Tokens`, the Scope (Functional Areas) prompt, the one-time client secret, and the incognito/SSO ISU sign-in — https://doc.workday.com/peakon/en-us/workday-peakon-employee-voice/integrations/workday-integration/nfa1667304944189.html - Register API Client for Data Lake (Refresh Token Grant) — the `Register API Client for Integrations` + `Manage Refresh Tokens for Integrations` path — https://doc.workday.com/admin-guide/en-us/workday-data-cloud/data-out/workday-data-lake/register-api-client-for-data-lake--refresh-token-g.html - Reference: Edit Tenant Setup – Security (the `OAuth 2.0 Clients Enabled` option) — https://doc.workday.com/admin-guide/en-us/manage-workday/tenant-configuration/tenant-setup/dan1370796470031.html - Create Integration System Users for Apps (ISU, security group, Activate Pending Security Policy Changes) — https://developer.workday.com/doc/GUID-f8d46604-e156-492f-a324-62ed2f6496f7.md - Install OfficeConnect with Tenants — a second public example of the authorize and REST endpoint shapes — https://doc.workday.com/adaptive-planning/en-us/workday-adaptive-planning-documentation/officeconnect-reports/using-officeconnect/install-and-set-up-officeconnect/configure-officeconnect-with-tenant-connections--i.html **OAuth flows and the Developer Site client (Extend / Orchestrate)** - Create Your API Client (Developer Site Console; Modify Authorized Tenants) — https://developer.workday.com/doc/zwx1518028675482.md - Authenticate Using the Authorization Code Grant Type — https://developer.workday.com/doc/yai1528997518068.md - Authenticate Using PKCE with the Authorization Code Grant Type — https://developer.workday.com/doc/hfk1553031369488.md - Request a New Access Token with a Refresh Token (token timeout tied to session timeout; absolute refresh window) — https://developer.workday.com/doc/cqa1553122177664.md - Reference: Workday Extend API Gateways and Authorization Base URLs (US/EU/UK/SG regions) — https://developer.workday.com/doc/dlh1653340161856.md - Modify Authorized Non-Development Tenants for API Clients using ISU Authentication — https://developer.workday.com/doc/GUID-a600ff24-400c-4621-8f23-1ec27a56fc61-enHYPHENus.md - Concept: ISU Authentication (Tenant Refresh from Production) — https://developer.workday.com/doc/GUID-cb3e0fcd-bb97-4005-8d10-3a02e88dab71-enHYPHENus.md **Tenants, RaaS, partners** - Workday Tenants and Tools (Production, Sandbox, Sandbox Preview, Implementation; weekly service updates) — https://doc.workday.com/workday-education/en-us/course-manuals/hcm-core-for-administrators/workday-tenants-and-tools.html - Concept: Reports as a Service (RaaS) — https://doc.workday.com/admin-guide/en-us/reporting-and-analytics/custom-reports-and-analytics/reports-as-a-service-raas-/dan1370796320263.html - Concept: Accessing RaaS Output — https://doc.workday.com/admin-guide/en-us/reporting-and-analytics/custom-reports-and-analytics/reports-as-a-service-raas-/dan1370797813643.html - Concept: Reports-as-a-Service (developer site) — https://developer.workday.com/doc/hto1529968349582.md - Workday Developers — https://developer.workday.com/ - Workday Partner Program Overview — https://www.workday.com/en-us/company/partners/partner-program-overview.html - Become a Workday Partner — https://www.workday.com/en-us/company/partners/become-a-partner.html - Workday Marketplace — https://marketplace.workday.com/ **Requires a Workday Community sign-in — listed so you know what you are missing, NOT as usable references** Do not cite these as sources. They mostly answer an anonymous request with `401`, and at least one of them intermittently answers `200` with an Okta sign-in screen instead — which is the same wall wearing a disguise, so check that a page actually rendered content before you quote it. If you have a Workday Community account, these are where the **[unverified]** items in §15 would most likely be settled. - Administrator Guide: Register API Client for Integrations — https://doc.workday.com/admin-guide/en-us/authentication-and-security/authentication/oauth/dan1370797831458.html - Administrator Guide: Integration Security in Workday (seen answering both `401` and `200`-with-sign-in) — https://doc.workday.com/admin-guide/en-us/authentication-and-security/security-for-integrations/dan1370797415412.html - Community: API Authentication Methods — https://community-content.workday.com/content/workday-community/en-us/kits-and-tools/products/platform-and-product-extensions/integrations/api-authentication-methods.html - Community: REST OAuth and REST error messages — https://community.workday.com/rest/oauth and https://community.workday.com/rest/error-messages - Community: Tenant Management — https://community-content.workday.com/en-us/reference/get-help/support/tenant-management.html ## xero-oauth-app URL: https://docs.unified.to/skills/xero-oauth-app/SKILL.md # Xero OAuth2 App Registration Get a working Xero OAuth2 client — a Xero account, an app in **My Apps**, redirect URIs, scopes, a client ID and secret — for a platform that connects many customers' Xero organisations. Two things about Xero decide whether this works, and neither is on the registration form. **A Xero access token is not bound to an organisation.** One token can reach several organisations, and every API call must carry a tenant id that you fetch from a separate endpoint after the flow completes. A connector that assumes "one connection, one organisation" silently syncs the wrong org, or one of several. That is §7. **Your app has a hard connection ceiling, and it is small.** Since 2 March 2026 Xero prices apps in tiers, and a new app starts on the free Starter tier with **5 connected organisations**. Customer number six cannot connect until someone pays, and getting past 1,000 requires certification plus a security self-assessment. That is §8. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, company URL, privacy policy URL | The name cannot contain the word "Xero" (§3) | | **Xero account email** | A free Xero account owns the app; the demo company is the test org (§2) | | **Redirect URIs** | Every callback host your platform serves (§4) | | **Scope set** | Exactly what the connector calls, plus `offline_access` (§5) | | **How many customer organisations** this app must serve | Decides the tier, the cost and whether certification is in scope (§8) | | **Who owns the tier/billing decision** | Moving off Starter needs a card on file — a commercial call, not yours (§8) | | **Does the client store the rotated refresh token?** | Blocking — a client that does not will fail on the second refresh (§9) | | **New app or an edit to an existing one?** | New credentials orphan every existing customer connection (§1) | ## Quick Start 1. Confirm a **new app** is needed — existing connections are bound to the current client ID (§1). 2. Sign in to, or create, a free Xero account and enable the demo company (§2). 3. Create the app in **My Apps**, choosing the right integration type (§3). 4. Add **every** redirect URI, exactly (§4). 5. Select the scopes the connector calls, plus `offline_access` (§5). 6. Capture the client ID and generate the client secret (§6). 7. Work through the tenant indirection before writing any client code (§7). 8. Establish the connection ceiling and who pays for the tier the roadmap needs (§8). 9. Confirm the client handles 30-minute access tokens and rotating 60-day refresh tokens (§9). 10. Verify with the demo company and a second organisation, then hand the credentials over (§11, §12). ## Platform state (verified 2026-09-20 — re-verify before trusting) Xero changed its commercial model and its scope model in the same six months. Both land on this task. - **Tiered pricing took effect 2 March 2026**, replacing the old revenue-share model. Five tiers, priced on connections and API egress: **Starter 5 connections (free)**, **Core 50 ($35 AUD/month)**, **Plus 1,000 ($245)**, **Advanced 10,000 ($1,445)**, **Enterprise unlimited (price on application)**. Plus and above require **app certification**; Advanced and above also require a **security assessment**, initial and annual. App Store listing needs Plus or higher. See §8. - **The docs contradict themselves on the connection limit.** The limits, tenants and pricing pages say Starter starts at 5 and Core lifts it to 50. The OAuth overview, the auth-flow page and the getting-started guide still say "25 tenants". **Treat 5 as the planning number**, and confirm against the tier shown on the app's own page in the developer portal — that is the only authoritative figure for a given app. - **Granular scopes replaced the broad ones from 2 March 2026.** `accounting.transactions`, `accounting.transactions.read` and `accounting.reports.read` are marked Deprecated and split into per-resource scopes. Apps created **on or after 2 March 2026 use granular scopes**; older apps can keep broad scopes **until September 2027**. A new app registered today is a granular-scope app, so an authorize URL still carrying the deprecated broad scopes is the first thing to check when consent fails (§5). - **Premium endpoints are tier-gated.** The **Journals** endpoint (general ledger) and the **Xero Practice Manager (XPM)** API are Advanced-tier features requiring a security assessment and use-case approval. **Bulk Connections** (one user connecting many organisations in a single flow) is likewise Advanced and approval-gated. Manual journals are *not* affected — a different endpoint, available in every tier. - **Rate limits now vary by tier**: 1,000 calls/day per organisation on Starter, 5,000 on Core and above. The rate-limit FAQ still prints a flat 5,000; the limits page is the current one (§10). - **The developer terms prohibit using Xero API data to train AI/ML models** (from 4 December 2025 for developers who registered on or after that date, from 2 March 2026 for everyone else). If the product does anything of the kind, stop and escalate — that is a legal question, not a registration step. If the portal does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer connection is bound to the old one** — every customer would have to reconnect. Reuse the existing app for: adding a redirect URI, adding or migrating a scope, rotating a compromised secret, or diagnosing a failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, or a deliberate test app. Xero itself recommends a throwaway test app for trying granular scopes against the demo company. Note that connections and API volume are metered **per app** and cannot be shared between apps you own — a second app starts again on Starter with its own 5 connections. Say which path you are taking first. ## 2. Account: sign in or sign up - **Xero account** — free, at `https://www.xero.com/signup/developers/`. Any Xero account can own apps; there is no separate developer registration. - **Demo company** (recommended test organisation) — enable it from `https://my.xero.com/`. Pre-populated with data, resettable at any time, can switch country (which resets it), **auto-resets 28 days after creation**, and you must reconnect your app after each reset. Payroll is enabled where the chosen country supports it. - **Trial organisation** — 30 days before billing details are required, no sample data, cannot be reset or switch country, but other users can be invited. - **Who may authorize** — the connecting user must be **Standard, Adviser or Administrator with the Connected Apps permission**. Reporting endpoints additionally need the user to have **Reports** access; Payroll endpoints need **Payroll Admin**. Scopes never exceed what the user may do. Hand control back to the user for anything only they can do: email verification, two-step authentication, accepting terms, adding a payment method, or requesting a tier upgrade. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (§4 redirect URIs, §5 scope list), then continue once they report back with the client ID. ## 3. Create the app Go to **My Apps** — `https://developer.xero.com/app/manage` — and click **New App**. The modal asks for a name, a company or application URL, and an **integration type**. The type is the choice that matters: | Integration type | Grant | Use it when | | --- | --- | --- | | **Web app** | Authorization code | A server that can keep a client secret — **this is what a multi-tenant connector needs** | | **Mobile or desktop app** | Authorization code with PKCE | Native clients that cannot hold a secret; no client secret is issued. Single-page apps are not supported | | **Custom connection** | Client credentials | A machine-to-machine integration with **exactly one** organisation, paid for by that organisation, AU/NZ/UK/US only | Notes that cost a review cycle: - The app name must be the go-to-market name of the product and **cannot contain the word "Xero"**. - A Custom Connection can never serve a second organisation, so it is the wrong shape for a connector — but it is also the right answer when a customer wants one bespoke org wired up without a public app. Custom Connections do not count against the "two uncertified apps per organisation" limit (§8). - Xero's certification checkpoints expect the connect experience to be a **full-page redirect**, not a pop-up, and expect your UI to show the connected organisation's name and a working disconnect button. Build for that now if listing is anywhere on the roadmap (§8). - Never ask a user for their Xero login, and never expose the client ID or secret in client-side code. ## 4. Redirect URIs Add **every** callback host your platform serves. Xero allows **up to 50 redirect URIs per app**, matched exactly. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Rules: - **HTTPS only**, with one exception: `http://localhost` is allowed for testing. **`http://127.0.0.1` is not accepted.** - **No wildcards.** Xero follows RFC 6749 §3.1.2 — an absolute URI, matched exactly. - A `redirect_uri` that is not registered does not fail politely at the callback: the user sees a Xero **500 "Sorry, something went wrong" screen** at the *start* of the flow. Same screen for an invalid scope or a wrong client ID, so read the message on it before guessing. - The `redirect_uri` sent at the token exchange must match the one used to get the code, or the exchange returns `unauthorized_client`. ## 5. Scopes Scopes are **space-delimited** in the authorize URL. The families: | Family | Examples | Notes | | --- | --- | --- | | Identity | `openid`, `profile`, `email` | Sign-in only. No organisation data, and no connection is created | | Offline | `offline_access` | **Mandatory** for a refresh token, and required for certification | | Accounting (granular) | `accounting.invoices[.read]`, `accounting.payments[.read]`, `accounting.banktransactions[.read]`, `accounting.manualjournals[.read]` | Replaced the broad `accounting.transactions[.read]` | | Accounting (other) | `accounting.settings[.read]`, `accounting.contacts[.read]`, `accounting.attachments[.read]`, `accounting.budgets.read`, `accounting.journals.read` | `accounting.journals.read` is the Advanced-tier general ledger (§8) | | Reports | `accounting.reports.balancesheet.read`, `.profitandloss.read`, `.trialbalance.read`, `.aged.read`, `.banksummary.read`, `.budgetsummary.read`, `.executivesummary.read`, `.taxreports.read` | Replaced the broad `accounting.reports.read`; the user also needs Reports access | | Payroll | `payroll.employees[.read]`, `payroll.settings[.read]`, `payroll.payruns[.read]`, `payroll.payslip[.read]`, `payroll.timesheets[.read]` | Same strings across the AU, UK and NZ payroll APIs; the user must be a Payroll Admin | | Files / Assets / Projects | `files[.read]`, `assets[.read]`, `projects[.read]` | Note that invoice and contact *attachments* are `accounting.attachments`, not `files` | | Certification-gated | `paymentservices`, `bankfeeds`, `finance.*.read`, `practicemanager.*` | Only after additional certification, and sometimes a commercial agreement | | Non-tenanted | `app.connections`, `marketplace.billing` | Client-credentials grant only (§7) | Four ways scopes fail that look like the portal being broken: 1. **Scopes are additive and cannot be narrowed.** Each authorization adds to what the user already consented to; the only way to reduce a token's scopes is to revoke it and start again. 2. **One tenant type per flow.** Requesting an accounting scope alongside a Practice Manager scope errors for the user. Multiple tenant types means multiple separate authorization flows. 3. **A missing granular scope returns 401 with `WWW-Authenticate: insufficient_scope`**, not a 403. Catch it and prompt the user to reconnect rather than logging it as a server error. 4. **Migrating scopes forces re-consent.** Existing tokens do not gain granular scopes; users must go through the flow again. Xero's suggested migration is to change the authorize link now and let re-authorizations drift users over before September 2027, then force the stragglers by deleting their connection. Request the narrow set, and say in your summary what you chose and why. Unexplained or ambiguous scopes are a certification finding. **Product fact.** As of 2026-09-20, Unified.to's Xero connector requests a per-object scope set rather than one blanket list: the identity leg asks for `openid`, `profile` and `email`, and each unified object maps to the narrowest scopes it needs — drawn from `accounting.settings` / `accounting.settings.read`, `accounting.invoices` / `.read`, `accounting.contacts` / `.read`, `accounting.payments` / `.read`, `accounting.banktransactions` / `.read`, `accounting.manualjournals` / `.read`, `accounting.attachments` / `.read`, the three report scopes `accounting.reports.balancesheet.read`, `accounting.reports.profitandloss.read` and `accounting.reports.trialbalance.read`, `projects` / `projects.read`, `payroll.employees.read` and `payroll.settings.read` — with **`offline_access` appended to every one**. Two objects still request the **deprecated broad `accounting.transactions` / `accounting.transactions.read`**, and aged-payable/receivable reports are computed from invoice and transaction reads instead of `accounting.reports.aged.read`. The connector does **not** use PKCE for Xero, exchanges the code with HTTP Basic auth over a form POST, sends the client id on refresh, treats a connection as valid for about 60 days (tracking the refresh token, not the 30-minute access token), and has the revocation endpoint wired up. It is also configured to use Unified.to's shared OAuth credentials by default. Confirm all of this with the connector's owner before you register anything — those broad scopes in particular are the detail most likely to have moved. ## 6. Capture the credentials From **My Apps** → your app → **Configuration**: - **Client ID** — visible on the app page. - **Client secret** — click **Generate a secret**. It is shown **once**; if it is lost, generate a new one. Web apps get a secret, PKCE apps never do. - **Endpoints** — authorize `https://login.xero.com/identity/connect/authorize`, token `https://identity.xero.com/connect/token`, revocation `https://identity.xero.com/connect/revocation`, connections `https://api.xero.com/connections`, API base `https://api.xero.com/api.xro/2.0/`. The OpenID Connect discovery document is at `https://identity.xero.com/.well-known/openid-configuration` and the JWKS at that path plus `/jwks`. - **Environment** — there is no separate sandbox host. Production and test both run against the same endpoints; the demo company is the "sandbox" (§2). Both the token exchange and the refresh authenticate with `Authorization: Basic base64(client_id + ":" + client_secret)` over `application/x-www-form-urlencoded`. PKCE apps send the same header with an empty secret — the trailing colon still matters. Rotating the secret breaks every token exchange and refresh until the new value is deployed; issued access tokens keep working until they expire. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. ## 7. The tenant indirection — one token, many organisations This is the part that a connector gets wrong, and nothing in the registration form warns you about it. A Xero access token represents **a user**, not an organisation. Immediately after the exchange you must call the connections endpoint and pick a tenant: ``` GET https://api.xero.com/connections Authorization: Bearer ``` Each element carries `id` (the connection id), `tenantId`, `tenantType` (`ORGANISATION`, `PRACTICEMANAGER` or `PRACTICE`), `tenantName`, `authEventId`, `createdDateUtc` and `updatedDateUtc`. Every subsequent API call then needs both headers: ``` Authorization: Bearer xero-tenant-id: ``` What follows from that: - **One authorization can return several organisations**, and the list also includes organisations the user connected in *earlier* flows — all reachable with the newest token. Filter with `?authEventId=` to see only what was just authorized. The docs note the last-connected tenant's `authEventId` matches the token's claim. - **`tenantId` is not `connectionId`.** `DELETE https://api.xero.com/connections/{connectionId}` (204, empty body) removes a connection and takes the *connection* id. Deleting a connection does not invalidate the token; revoking the token does not tidy your own records. - **A connection is a unique user-tenant pair.** Three users connecting the same organisation is one connection against your ceiling; one user connecting three organisations is three. - **Connections never expire on their own.** They persist regardless of token expiry or API inactivity, and disappear only when the user disconnects from `https://apps.xero.com/connected`, loses permission in the tenant, you DELETE the connection, the token is revoked, the tenant is deleted, or you delete the app. Since you now pay by connection, abandoned trials cost money — Xero documents a cleanup routine for exactly this. - **Cross-tenant contamination is your problem.** Xero's own guidance calls out shared caches, static variables and connection pools as the failure mode, and certification reviews multi-organisation handling if you offer it. - **If you lose the user's token** you can still manage connections with the client-credentials grant and the `app.connections` scope — available to web (code-grant) apps only, and the GET form requires a tenant id or user id header to filter by. - The `Organisation` endpoint is the cheapest way to identify what you just connected: name, `ShortCode` (for deep links), `CountryCode`, `BaseCurrency`, `Class` (spot trials) and `IsDemoCompany`. **Product fact.** As of 2026-09-20, Unified.to's Xero connector resolves the tenant once, right after authorization: it calls the connections endpoint filtered by the token's authentication event, falls back to an unfiltered listing if that returns nothing, **stores the first organisation in the list against the connection**, and sends that id as the tenant header on every list, get, create, update, delete and passthrough call. It also reads the organisation endpoint once to cache the country and product version. The practical consequences: a customer who authorizes three organisations in one flow gets **one** of them synced, the other two still count against the app's connection ceiling, and which one is picked depends on Xero's ordering. Modelling several organisations means several connections in Unified.to, one per org. Confirm the current behaviour with the connector's owner before you promise a customer multi-org behaviour. ## 8. The connection ceiling and certification Your app cannot serve more organisations than its tier allows, and the free tier is very small. | Tier | Connections | Monthly fee | Gate | | --- | --- | --- | --- | | Starter | 5 | free | none — where every new app begins | | Core | 50 | $35 AUD | payment method on file | | Plus | 1,000 | $245 AUD | **app certification**; App Store listing optional | | Advanced | 10,000 | $1,445 AUD | certification + **security assessment** (initial and annual) | | Enterprise | no limit | on application | certification + security assessment; App Store listing required | Also true, and easy to miss: - **Each organisation may connect at most two uncertified apps.** Custom Connections are exempt. So a customer already running two uncertified integrations cannot add yours, regardless of your own headroom. - **Egress is metered.** Tiers include a monthly API egress allotment (10 GB on Core, 50 on Plus, 250 on Advanced) with overage at $2.40 AUD/GB; the organisation endpoint is excluded. A bulk-sync connector can blow through an allotment long before it hits the connection count, so raise it as a cost question early. - **Connections and volume are metered per app** and cannot be pooled across apps, even your own. - **Certification is a process, not a checkbox.** Xero expects roughly ten active customer connections and a "Sign Up with Xero" flow as prerequisites, then reviews against published checkpoints: connection management UI, branding and naming, minimal scopes with `offline_access`, error handling, account and payment mapping. You apply by upgrading the plan from the app's own page in the developer portal. - **The security self-assessment gates 1,000 connections and the premium endpoints.** Xero shares it at around 800 connections; until it is passed the app is capped at 999 and cannot use the Journals endpoint or the XPM/Xero HQ APIs. - Some app categories (financial services, practice APIs) need approval *before* applying. None of this is a step you can perform. Certification timelines, tier fees and security questionnaires are commercial and compliance decisions — surface the ceiling, the cost and the gate, and hand back (see **Stop and ask**). ## 9. Token behaviour: 30 minutes, 60 days, and rotation on every use | Token | Lifetime | Rule | | --- | --- | --- | | `access_token` | **30 minutes** | JWT; carries `scope` and `authentication_event_id`. Verify via the JWKS if you verify at all | | `refresh_token` | **60 days, rolling** | Only issued when `offline_access` was requested. **Rotates on every use** | | `id_token` | 5 minutes | Only with `openid`; identity claims only | | authorization `code` | 5 minutes, single use | Reusing or delaying it gives `invalid_grant` | The three ways this bites: 1. **Rotation.** Every refresh returns a *new* refresh token and invalidates the old one. A client that keeps reusing the original succeeds once and fails on the second refresh. Persist the new token in the same transaction as the access token. 2. **The 30-minute grace window.** If your app never receives or fails to save the refresh response, the previous refresh token still works for **30 minutes**. After that the customer must re-authorize. Worth a retry path. 3. **The 60-day idle death.** The window is rolling, so an actively refreshed connection lives forever — but a connection nobody touches for 60 days is gone, and Xero does not tell you when: there is no expiry field on the token. Compute `refresh_token_expires_at = now + 60 days` at every refresh and store it. An expired refresh token returns HTTP 400 with `invalid_grant`, and the connection still shows as connected on Xero's side until you delete it (§7). ## 10. Rate limits, correlation ids and support Per **tenant**, not per app, except where noted: - **Concurrent**: 5 calls in flight at once. - **Minute**: 60 calls per minute. - **Daily**: 1,000 on Starter; 5,000 on Core and above. - **App minute limit**: 10,000 calls per minute across all tenants. Every response carries `X-DayLimit-Remaining`, `X-MinLimit-Remaining` and `X-AppMinLimit-Remaining`. Exceeding a limit returns **HTTP 429** with `X-Rate-Limit-Problem` naming which one; the minute and daily limits also return **`Retry-After`** in seconds. Windows are fixed and reset at different times per tenant, so honour `Retry-After` rather than computing your own backoff. Certified apps get "Rapid Sync" — lifted limits for the first 30 minutes of a new organisation connection. Every response also carries a correlation id — the troubleshooting page calls it `X-Correlation-Id` in prose and prints it as `Xero-Correlation-Id`, so log whichever the response actually contains and quote it in any support ticket. All API traffic requires **TLS 1.2 or higher**; older TLS gets a 403 with an HTML "Access Denied" body rather than JSON. ## 11. Verify end-to-end Testing against the demo company alone hides the multi-org problem. Test both. 1. Authorize the demo company through your platform's real connect flow and confirm the token response carries a **refresh token** (it will not, if `offline_access` was missing). 2. Call the connections endpoint and confirm you store the `tenantId` and send it as a header on a real read call. 3. Authorize a **second** organisation with the same Xero user, then re-read the connections endpoint — confirm your platform's behaviour with two tenants on one token is what you told the customer it would be (§7). 4. Force a **refresh**, then force a **second** refresh using the token the first one returned. That is what catches a client ignoring rotation. 5. Confirm a non-admin but Standard/Adviser user can authorize, if customers will use one. | Symptom | Cause | | --- | --- | | Xero 500 "Sorry, something went wrong" at the *start* of the flow | Unregistered `redirect_uri`, invalid scope, or wrong client ID (§4, §5) | | `unauthorized_client` at the token exchange | `redirect_uri` at exchange differs from the one used for the code (§4) | | `invalid_client` | Wrong client ID or secret; regenerate the secret if unsure (§6) | | `invalid_grant` right after consent | Code expired (5 minutes), already used, or wrong `grant_type` (§9) | | `invalid_grant` on a long-idle connection | Refresh token passed its rolling 60 days — customer must re-authorize (§9) | | Second refresh fails, first one worked | Client is not storing the rotated refresh token (§9) | | 403 with `AuthenticationUnsuccessful` | Missing/incorrect tenant header, user revoked access, user lost permission, or tenant deleted (§7) | | 401 with `WWW-Authenticate: insufficient_scope` | A granular scope was never consented to — prompt a reconnect (§5) | | Customer's org missing from the consent dropdown | User lacks Standard/Adviser permission, or that org is already connected to this app (§2) | | New customers cannot connect, existing ones fine | App hit its tier's connection ceiling, or the org already has two uncertified apps (§8) | | Reports or payroll endpoints 403 for one customer | Authorizing user lacks Reports access or Payroll Admin (§2) | | 429 with `X-Rate-Limit-Problem` | Per-tenant minute/daily/concurrent limit — honour `Retry-After` (§10) | | 403 with an HTML "Access Denied" body | TLS 1.1 or lower (§10) | | Data lands against the wrong organisation | Tenant id reused across connections, or a multi-org authorization collapsed to one tenant (§7) | ## 12. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (a redirect host, a scope migration, storing rotated refresh tokens), keep it secret-free and say what the human must set out of band. - Close with: app name and integration type; the Xero account that owns it; client ID; where the secret was delivered; the authorize, token, revocation and connections endpoints; the exact scope strings; the app's **current tier and connection ceiling**; whether the client stores rotated refresh tokens; how the tenant id is resolved and stored; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the client does not persist rotated refresh tokens (report it — do not register and hope); the customer count exceeds the current tier and someone must approve a paid tier or certification; the roadmap needs the Journals endpoint, XPM/Xero HQ, Bulk Connections or any certification-gated scope; a customer needs multi-organisation behaviour the connector does not implement (§7); the product might use Xero data in a way the AI/ML training prohibition touches; certification, the security self-assessment, commercial terms or App Store listing questions ask for compliance, legal or volume claims; or the portal does not match the **Platform state** section above. ## References - OAuth 2.0 overview and flow comparison — https://developer.xero.com/documentation/guides/oauth2/overview - Authorization code flow — https://developer.xero.com/documentation/guides/oauth2/auth-flow - PKCE flow — https://developer.xero.com/documentation/guides/oauth2/pkce-flow - Custom Connections — https://developer.xero.com/documentation/guides/oauth2/custom-connections - Client credentials (connection management) — https://developer.xero.com/documentation/guides/oauth2/client-credentials - Tenants and the connections endpoint — https://developer.xero.com/documentation/guides/oauth2/tenants - Token types, rotation and revocation — https://developer.xero.com/documentation/guides/oauth2/token-types - Scopes — https://developer.xero.com/documentation/guides/oauth2/scopes - Granular scopes FAQ — https://developer.xero.com/faq/granular-scopes - API limits and uncertified app limits — https://developer.xero.com/documentation/guides/oauth2/limits - Troubleshooting (auth errors, correlation id, TLS) — https://developer.xero.com/documentation/guides/oauth2/troubleshooting - OAuth 2.0 FAQ (redirect URI count, wildcards, token expiry) — https://developer.xero.com/faq/oauth2 - Permissions FAQ (who can authorize, Reports and Payroll roles) — https://developer.xero.com/faq/permissions - Developer pricing and tiers — https://developer.xero.com/pricing - Pricing and policy updates FAQ — https://developer.xero.com/faq/pricing-and-policy-updates - Certification checkpoints — https://developer.xero.com/documentation/xero-app-store/app-partner-guides/certification-checkpoints - Building and growing your app (certification path) — https://developer.xero.com/documentation/xero-app-store/app-partner-guides/building-and-growing-your-app - Multi-tenancy best practices — https://developer.xero.com/documentation/best-practices/managing-connections/multi-tenancy - Identifying inactive connections — https://developer.xero.com/documentation/best-practices/managing-connections/identifying-inactive-connections - Managing tokens and tenant ids — https://developer.xero.com/documentation/best-practices/data-integrity/managing-tokens - Development accounts and the demo company — https://developer.xero.com/documentation/development-accounts - Getting started guide — https://developer.xero.com/documentation/getting-started-guide - My Apps — https://developer.xero.com/app/manage - Free Xero account signup — https://www.xero.com/signup/developers/ ## zendesk-oauth-app URL: https://docs.unified.to/skills/zendesk-oauth-app/SKILL.md # Zendesk OAuth2 App Registration Get a working Zendesk OAuth client — the account that owns it, the client record, redirect URLs, scopes, unique identifier and secret — for a platform that connects many customers' Zendesk accounts. Zendesk's defining trap is ownership. **An OAuth client is created inside one Zendesk account and is scoped to that account's subdomain.** It authorizes `https://yourcompany.zendesk.com` and nothing else. A customer on `https://theircompany.zendesk.com` sending your `client_id` to *their* subdomain's authorize endpoint gets an error, because on their instance that client does not exist. Nothing about the client form hints at this, and it is invisible until the second customer tries to connect. Authorizing many customer subdomains with one set of credentials requires a **global OAuth client** (§3), which Zendesk issues on request — developers cannot create one. Two more things are expensive to get wrong. **The client secret is shown once**, and on conversion to a global client you lose the administration page entirely (§7). And **token expiry changed on 30 April 2026** (§8): any client created today issues 30-minute access tokens by default, where older clients issued tokens that never expired — a connector that never implemented refresh will authorize fine and then stop working half an hour later. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Client name**, description, company, logo | Shown on the consent screen customers see; all are required for a global client (§3) | | **Unique identifier** | The OAuth `client_id`. Must be the production value, and `zdg-`-prefixed for a global client (§3) | | **Which Zendesk account owns the client** | An existing account, or a new `d3v-` sponsored/trial account (§2) | | **Local or global** | One customer / internal use vs many customers — decide before creating anything (§3) | | **Redirect URLs** | Every callback host your platform serves (§5) | | **Scope set** | Exactly what the connector calls, no more (§6) | | **Does the client implement refresh tokens?** | Blocking as of 30 April 2026 (§8) | | **Is a Marketplace listing wanted or required?** | Separate track from the global client (§9) | ## Quick Start 1. Confirm a **new** client is needed — a new identifier orphans every existing customer connection (§1). 2. Sign in to, or create, the Zendesk account that will own the client; use a `d3v-` subdomain if a global client is the goal (§2). 3. **Decide local vs global before creating anything.** This is the whole task for a multi-tenant connector (§3). 4. Create the client: **Admin Center → Apps and integrations → APIs → OAuth clients → Add OAuth client** (§4). 5. Enter **every** redirect URL, newline-separated, https, absolute (§5). 6. Set allowed scopes to the exact set the connector calls (§6). 7. **Record the identifier and the secret now** — the secret is shown once and the page disappears on conversion (§7). 8. Check token expiry, refresh and rate limits against what the client actually implements (§8). 9. If global: submit the request at `https://apps.zendesk.com` → **Global OAuth** → **Request new OAuth** (§3). 10. Verify against a **second, unrelated subdomain** — never only the owning account (§10). 11. Hand the credentials over; never commit them (§11). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **OAuth clients are scoped to one Zendesk instance.** Zendesk states this plainly: to work across multiple instances you need a global OAuth client, and "for security reasons, Zendesk doesn't allow developers to create their own global OAuth clients." - **Token expiry changed on 30 April 2026.** Clients created on or after that date get an `expires_in` of 30 minutes applied automatically. Clients created before it ("legacy clients") have no default access-token expiration, and must explicitly pass `expires_in` at token creation to use the refresh-token flow at all. Access-token `expires_in` must be between 300 seconds (5 min) and 172,800 seconds (48 hours); `refresh_token_expires_in` between 604,800 seconds (7 days) and 7,776,000 seconds (90 days), defaulting to 2,592,000 seconds (30 days). - **API tokens are deprecated and being retired in three phases**: 28 July 2026 (tokens unused for 30+ days auto-deactivate; new accounts can no longer create them), 27 October 2026 (existing accounts blocked from creating new ones), 30 April 2027 (all remaining API tokens permanently deactivated). Any integration still using a customer's API token has a hard deadline, and Zendesk's developer terms already prohibit third parties from using customers' API credentials. - **`kind` (public vs confidential) is mandatory** for every new OAuth client created in Admin Center. Public clients may only use the authorization code flow; confidential clients may use either. - **Allowed scopes are enforced at the client.** If the client has an allowed-scopes list, a token request for anything outside it fails with `400 Bad Request` / `invalid_scope`. Zendesk sets this list itself for global clients, on the principle of least privilege. If Admin Center or the Marketplace portal does not look like this, stop and report what you actually see. ## 1. Decide: reuse the existing client, or register a new one The unique identifier **is** the `client_id`. A new client means a new identifier, and every existing customer connection is bound to the old one — all of them would have to re-authorize. Reuse the existing client for: adding a redirect URL, widening scopes, rotating a compromised secret, or diagnosing a failure. Register a **new** client only when the user explicitly wants one: a first client, a replacement for a compromised one, a separate client for a different product, or a deliberate local → global move. Note that the local → global conversion is itself one-way and takes the administration page away (§3, §7) — it is a planned cutover, not a tweak. Say which path you are taking before you touch anything. ## 2. The Zendesk account that owns the client - **Free trial** — 14 days, from Zendesk's registration page. Fine for a local client and for experimenting; it expires, so it is not a home for production credentials. - **Sponsored developer account** — the right home when a global client or Marketplace listing is the goal. Sign up for a trial, **put the `d3v-` prefix on the subdomain**, then submit Zendesk's Sponsored Account Request Form. Sponsored accounts do not expire and carry up to 5 agent seats on Suite Enterprise features. Zendesk suspends sponsored accounts used to provide real customer support. - **An existing production account** — normal for an established platform, and fine for a local client. You need admin rights (below). - **Sandbox** — a sandbox has its own subdomain and its own OAuth clients; a client created in a sandbox does not exist in production. Useful as the "second subdomain" for §10. **The `d3v-` prefix is not cosmetic.** Zendesk rejects global-client submissions from non-`d3v-` accounts. Adding it later means a new subdomain, so get it right at signup. **Permissions.** Creating and managing OAuth clients requires an admin, or an agent with the **Manage APIs** permission. One sharp edge: if the admin who created a client later loses those permissions, `authorization_code` tokens keep working but `client_credentials` tokens stop. Hand control back for anything only a human can do — signup, email verification, CAPTCHA, 2FA, accepting terms, submitting the sponsored-account form. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give the user an exact, ordered click path with the literal values to paste (§5 redirect URLs, §6 scope list), then continue once they report back with the identifier. ## 3. Local client vs GLOBAL client — read this before creating anything This decides whether the connector works for customer number two. **A local OAuth client** is created by an admin inside one Zendesk account, and is valid only on that account's subdomain. Right for: an internal integration against your own Zendesk, a single-customer build, or the development phase of anything else. Wrong for a multi-tenant connector — asking every customer to create their own client in their own Admin Center and hand you the secret is a support burden, and Zendesk's developer terms prohibit third parties from relying on customers' shared credentials. **A global OAuth client** is one client identifier that customers on *any* subdomain can authorize. Zendesk creates it; you request it. Zendesk's own words: "you must request a global OAuth client from Zendesk to create global OAuth tokens, and for security reasons, Zendesk doesn't allow developers to create their own global OAuth clients." **What Zendesk requires, and where requests get bounced:** | Requirement | Detail | | --- | --- | | Build first with a local client | The app must be "finished and ready to use across multiple Zendesk accounts" before you request | | `d3v-` subdomain | "Submissions from non-`d3v-` accounts aren't accepted" (§2) | | `zdg-` identifier prefix | E.g. `zdg-acmesync`. Must be representative of the product — Zendesk gives `zdg-zendesk` as acceptable and `zdg-global_client1` as not | | Production-ready identifier | The value you submit is the value you ship. It is not renamed later | | Every field filled | "Even if a field is labeled optional, it is required for global clients" — description, company, logo included | | Least-privilege scopes | You state the scopes; Zendesk configures the client's allowed-scopes list from them (§6) | **How to request:** create the fully-filled local client in the `d3v-` account, **write down the identifier and secret** (§7), then go to the Marketplace portal at `https://apps.zendesk.com` → **Global OAuth** in the left menu → **Request new OAuth**. The `d3v-` and `zdg-` values are pre-filled from the local client. Submit, and expect an email plus a ticket reference; add Zendesk's stated contact address to your allow-list so the correspondence is not lost. **What changes once it is global — plan for this, it is one-way:** - **You lose the client administration page.** Zendesk is explicit: note the secret and identifier *before* you request, because access to the administration page goes away when the local client is converted. - **Zendesk owns all subsequent edits.** Redirect URLs, name, logo, scopes — changes go through a support ticket, not a form. Get the redirect URL list complete the first time (§5). - Global clients are surfaced read-only through the API (`GET /api/v2/oauth/global_clients` on a customer account lists the global clients that account has authorized); they are not mutable by developers. - To attach the client to a Marketplace app listing later, the client must first be **claimed** in the developer portal (identifier plus the first 6 characters of the secret), then selected under the app's **Details → Compliance** tab, answering "Yes" to "Does this app use a global OAuth client?" (§9). **Scope of the global-client programme:** it covers Zendesk Support and Chat. It does not cover Zendesk Sell, and a Support global OAuth client cannot be used to build a bot — bots need their own token. If the connector spans those products, raise it before submitting rather than after. **Whatever the answer, the authorize and token hosts stay per-customer.** A global client does not give you one central host; it gives you one identifier that works on every customer's own host: ``` https://{customer_subdomain}.zendesk.com/oauth/authorizations/new # authorize https://{customer_subdomain}.zendesk.com/oauth/tokens # token exchange and refresh https://{customer_subdomain}.zendesk.com/api/v2 # API base ``` So the connector must collect each customer's subdomain up front and substitute it into all three. There is no discovery endpoint for it — it comes from the customer. ## 4. Create the client **Admin Center → Apps and integrations → APIs → OAuth clients → Add OAuth client.** | Field | What matters | | --- | --- | | **Name** | What customers see on the consent screen and in their list of third-party apps. Required for a global client | | **Description** | Shown on the consent screen. "Optional" locally, required for a global client | | **Company**, **Logo** | Same: optional locally, required for a global client. JPG/GIF/PNG, square | | **Unique identifier** | Auto-filled from the name and editable — **this is your `client_id`**. Set it deliberately; `zdg-`-prefixed for a global client | | **Client kind** | Mandatory. **Confidential** for a server-side connector that can keep a secret. Public clients are limited to the authorization code flow | | **Redirect URLs** | §5 | | **Scopes** | Allowed scopes — the ceiling on what tokens from this client may request. Leaving it empty means all scopes (§6) | | **Expire tokens** | Token expiry (§8). Behaviour depends on whether the client predates 30 April 2026 | Do not confuse this page with Admin Center's **external OAuth clients** page — that one is for Zendesk authenticating *outbound* to other services, and it is not what issues your client ID. ## 5. Redirect URLs Zendesk's stated rules: redirect URLs "must be absolute and not relative, https (unless localhost or 127.0.0.1), and newline-separated." The `redirect_uri` your authorize request sends must match a registered value **exactly** — no subdirectory matching, no trailing-slash forgiveness, no query-string additions. Register every callback host your platform serves, on its own line. For Unified.to these are one per data centre; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes: - The same `redirect_uri` must be sent again on the **token exchange**, and it must match what was sent on authorize. - The redirect URL is your platform's and does not change per customer, even though the authorize host does. - Get this list complete **before** requesting a global client. After conversion, adding a host means a support ticket (§3). - A client created only for server-side token creation (no browser flow) needs no redirect URLs at all — that is not your case for a customer-authorizes-us connector. ## 6. Scopes Space-separated in the authorize URL. Zendesk has two broad scopes and a long list of resource-specific ones: | Scope | Meaning | | --- | --- | | `read` | All GET endpoints, including sideloaded resources | | `write` | All POST, PUT and DELETE endpoints | | `impersonate` | Lets a Support **admin** act on behalf of end users. Admin-only, and a red flag in review — request only if genuinely needed | Resource-specific scopes follow `{resource}:{action}`, and as of 2026-09-20 include: `account_settings:read|write`, `ai_agents:chat`, `any_channel:write`, `apps:read|write`, `auditlogs:read`, `automations:read|write`, `brands:read|write`, `custom_objects:read|write`, `deletion_schedules:read|write`, `dynamic_content:read|write`, `groups:read|write`, `hc:read|write`, `macros:read|write`, `organizations:read|write`, `requests:read|write`, `satisfaction_ratings:read|write`, `security:read`, `sla_policies:read|write`, `targets:read|write`, `themes:read|write`, `ticket_attachments:read|write`, `ticket_views:read|write`, `tickets:read|write`, `triggers:read|write`, `users:read|write`, `web_widget:write`, `webhooks:read|write`, `zis:read|write`. **Product fact — as of 2026-09-20, Unified.to's Zendesk connector requests, depending on which objects and permissions a workspace enables:** `read`, `users:read`, `users:write`, `tickets:read`, `tickets:write`, and `hc:read`. Ticket and note reads pair `tickets:read` with `users:read`; writes add `tickets:write`; help-centre pages and spaces use `hc:read`; the broad `read` scope backs the group and messaging objects. Confirm the current set with the connector's owner before you submit anything — a global-client request freezes the allowed-scopes list, and a scope you did not ask for cannot be added without a support ticket. Two failure modes that look like Zendesk being broken: - **`400 Bad Request` / `invalid_scope`** — the client's allowed-scopes list does not include what you asked for. Fixed on the client, not in the request. For a global client that means Zendesk. - **`403 Forbidden` on a call that authorized fine** — the token's scopes are narrower than the endpoint needs (Zendesk's example: a `tickets:read` token cannot create or update tickets). Scopes cannot be changed on an issued token; a new token must be minted. And scopes never exceed what the *authorizing user's* role allows — an end-user's token succeeds on `/users/me` and fails on tickets, views and ticket fields regardless of scope. Ask for the narrow set. `read write` on everything is the kind of thing customer security teams and Marketplace review both push back on. ## 7. Capture the credentials Capture and hand over: - **Unique identifier** (the OAuth `client_id`) and **secret** (`client_secret`) - The owning subdomain, and whether the client is local or global (and, if global, the request/ticket reference) - The per-subdomain endpoints: `/oauth/authorizations/new`, `/oauth/tokens`, `/api/v2` (§3) - The exact scope strings and the client `kind` **The secret is returned in full only at creation.** It cannot be re-read afterwards — only regenerated. And for a global client, Zendesk's instruction is blunt: note the secret and identifier before you request, because you lose the administration page once the local client is converted. Record both, in a secret store, before clicking submit. Regenerating the secret invalidates the old one. Issued access tokens keep working until they expire, but every token exchange and every refresh fails until the new secret is deployed — and with 30-minute access tokens (§8), "until they expire" is half an hour. Never rotate without explicit go-ahead and a cutover plan. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. ## 8. Tokens, refresh, and rate limits **Authorization header.** `Authorization: Bearer {access_token}`. Sending an OAuth token with `Basic`, or in the API-token form, returns 401 — Zendesk lists "a valid access token sent with the wrong scheme" as a top cause. **The token exchange takes a JSON body, not form encoding.** Zendesk's documented request is a `POST` to `https://{subdomain}.zendesk.com/oauth/tokens` with `Content-Type: application/json` carrying `grant_type`, `code`, `client_id`, `client_secret` and `redirect_uri`. A client that form-encodes it because every other provider does will fail here. The same endpoint and JSON body serve `grant_type: refresh_token`. The authorization code is valid for about 120 seconds. **Expiry.** For a client created today, access tokens default to 30 minutes and refresh tokens to 30 days (§ Platform state for the exact bounds and the legacy-client rule). Establish two things separately, because they are different failures: 1. **Does the client implement the refresh grant at all?** Without it, a connector built against pre-2026 Zendesk behaviour — where tokens simply did not expire — dies 30 minutes after each customer connects. 2. **Does it store the values Zendesk returns?** The response carries `access_token`, `refresh_token`, `expires_in`, `refresh_token_expires_in`, `scope` and `token_type`. A refresh token that is never exercised within its window expires too, and the customer must re-authorize. **As of 2026-09-20, Unified.to's Zendesk connector does implement the refresh grant** (it re-posts client credentials with the refresh token), collects the customer's subdomain and substitutes it into the authorize, token and API hosts, posts the token request as JSON, and sends the scope list on the exchange as well as on authorize. It also has shared platform-managed credentials configured for Zendesk, so a workspace may be connecting through those rather than through a client you are about to create — check with the connector's owner which applies before registering anything, and before assuming a new client is even needed. **OAuth tokens vs API tokens.** API tokens are a per-account password (up to 256 active, never expiring, usable to impersonate anyone in the account including admins) sent as `Basic` with `{email}/token:{api_token}`. They are deprecated on the timeline in **Platform state**, and third-party integrations are not permitted to use a customer's API credentials at all. If anyone proposes "just have the customer paste an API token", that is the answer: not allowed, and dead by 30 April 2027. **Rate limits are the customer's, not yours.** They apply per Zendesk account and scale with the customer's plan — roughly 200/min on Team, 400/min on Growth and Professional, 700/min on Enterprise, 2,500/min on Enterprise Plus or with the High Volume API add-on; legacy Essential is 10/min. Some endpoints are tighter (incremental exports 10/min; ticket updates 30 per 10 minutes per user per ticket). Over the limit returns **429** with a **`Retry-After`** header in seconds — honour it. A customer on a small plan will rate-limit your connector no matter how the client is registered, and every other integration on their account shares the same budget. ## 9. Marketplace listing — a separate track The global OAuth client and a Marketplace listing are related but not the same thing, and conflating them wastes a cycle. The global client is requested through the Marketplace portal (§3) and can be initiated without a published listing. A listing is its own submission with its own review. If a listing is in scope, the client must be **claimed** in the developer portal (identifier + first 6 characters of the secret) before it can be associated with the app under **Details → Compliance**. Listing questionnaires that ask for security, privacy, data-residency, support-SLA or commercial commitments are business decisions — collect them from the user, do not compose them (see **Stop and ask**). ## 10. Verify end-to-end Authorizing on the account that owns a local client proves nothing about multi-tenancy — that is the one subdomain where it is guaranteed to work. Test the path a customer takes: 1. Authorize from a **second, unrelated subdomain** (a sandbox or a separate trial) through your platform's real connect flow, with the subdomain supplied the way a customer supplies it. 2. Confirm the consent screen shows the name, logo and description you registered, and the scopes you expect. 3. Confirm the token response carries a **`refresh_token`**, an **`expires_in`**, and the **`scope`** you asked for. 4. **Wait out the access token** (or force it) and exercise the refresh grant, then make a read call with the new token. This is the check that catches a connector built for never-expiring tokens. 5. Make one read call per object the connector supports, against the customer's `/api/v2` host, to shake out scopes that are narrower than the endpoints need. | Symptom | Cause | | --- | --- | | Works on your subdomain, fails on every customer's | Local client, not global — the client does not exist on their instance (§3) | | Authorize page errors or shows no consent screen | Wrong subdomain in the authorize host, or wrong `client_id` for that instance (§3) | | Redirect rejected, or the exchange fails after a good authorize | `redirect_uri` not registered exactly, or not identical between authorize and exchange (§5) | | `400 Bad Request` / `invalid_scope` | Requested scope outside the client's allowed-scopes list; for a global client, Zendesk sets it (§6) | | Token exchange rejects a well-formed request | Body form-encoded instead of JSON, or the 120-second code window elapsed (§8) | | Everything works for ~30 minutes, then 401s | Access token expired and the client does not refresh (§8, Platform state) | | 401 on every call despite a fresh token | Wrong auth scheme — must be `Authorization: Bearer …` (§8) | | 403 on writes, reads fine | Token has `…:read` but not `…:write`, or the authorizing user's role forbids it (§6) | | 403 on tickets/views for one customer only | An end user authorized, not an agent or admin (§6) | | Intermittent 429s | The customer's account-level plan limit, shared with their other integrations (§8) | | Can no longer edit name, logo or redirect URLs | The client was converted to global; edits go through Zendesk (§3) | ## 11. Hand off — never commit the secret - **Do not** write the client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the user, for their secret store or console. - If a code change is needed (a redirect host, a scope, refresh support), keep it secret-free and say what the human must set out of band. - Close with: client name and unique identifier; local or global, and the owning subdomain; where the secret was delivered; the per-subdomain authorize, token and API URL patterns; the exact scope strings; client `kind`; the token-expiry and refresh situation and who owns any remaining work; the global-client request reference and what is still pending; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the choice between a local and a global client has not been made by someone who owns the product decision (§3); the connector does not implement the refresh grant (report it — do not register and hope); a global-client request needs a `d3v-` account that does not exist yet, or a production-final identifier nobody has approved; converting an existing local client to global would re-authorize live customers; a scope outside the confirmed set is being proposed, especially `impersonate` or blanket `write`; a Marketplace or compliance form asks for security, privacy, legal, data-residency or volume claims; someone proposes collecting customers' API tokens or their own client secrets; or Admin Center and the Marketplace portal do not match the **Platform state** section above. ## References - Creating and using OAuth access tokens with the API — https://developer.zendesk.com/documentation/ticketing/working-with-oauth/creating-and-using-oauth-tokens-with-the-api/ - Creating and using OAuth access tokens (API basics) — https://developer.zendesk.com/documentation/api-basics/authentication/creating-and-using-oauth-tokens-with-the-api/ - Using OAuth authentication with your application (Admin Center walkthrough) — https://support.zendesk.com/hc/en-us/articles/4408845965210-Using-OAuth-authentication-with-your-application - Managing OAuth token access to the API — https://support.zendesk.com/hc/en-us/articles/8889508417946-Managing-OAuth-token-access-to-the-API - Set up a global OAuth client — https://developer.zendesk.com/documentation/marketplace/building-a-marketplace-app/set-up-a-global-oauth-client/ - Managing global OAuth clients and app associations — https://developer.zendesk.com/documentation/apps/app-developer-guide/managing-global-oauth-clients-and-app-associations/ - Global OAuth Clients API reference — https://developer.zendesk.com/api-reference/ticketing/oauth/global_clients/ - OAuth Clients API reference — https://developer.zendesk.com/api-reference/ticketing/oauth/oauth_clients/ - OAuth Tokens for Grant Types (full scope list, expiry bounds) — https://developer.zendesk.com/api-reference/ticketing/oauth/grant_type_tokens/ - OAuth Tokens API reference — https://developer.zendesk.com/api-reference/ticketing/oauth/oauth_tokens/ - Using OAuth to authenticate Zendesk API requests in a web app — https://developer.zendesk.com/documentation/authentication/using-oauth-to-authenticate-zendesk-api-requests-in-a-web-app/ - Working with OAuth refresh tokens — https://developer.zendesk.com/documentation/api-basics/authentication/refresh-token/ - Migrating from API tokens to OAuth access tokens — https://developer.zendesk.com/documentation/authentication/oauth-migration/ - Security and authentication — https://developer.zendesk.com/api-reference/introduction/security-and-auth/ - Rate limits — https://developer.zendesk.com/api-reference/introduction/rate-limits/ - Getting a trial or sponsored account for development — https://developer.zendesk.com/documentation/api-basics/getting-started/getting-a-trial-or-sponsored-account-for-development/ - Troubleshooting 401 and 403 errors — https://support.zendesk.com/hc/en-us/articles/10306782115610-Troubleshooting-401-and-403-Errors-on-the-Zendesk-Developer-Platform - Making API requests on behalf of end users (impersonation) — https://developer.zendesk.com/documentation/ticketing/managing-tickets/making-api-requests-on-behalf-of-end-users/ - About Zendesk sandbox environments — https://support.zendesk.com/hc/en-us/articles/6150628316058-About-Zendesk-sandbox-environments - Managing external OAuth clients (the *other* Admin Center page — not this one) — https://support.zendesk.com/hc/en-us/articles/9447118302362-Managing-external-OAuth-clients - Zendesk Marketplace developer portal — https://apps.zendesk.com - Zendesk Marketplace docs — https://developer.zendesk.com/documentation/marketplace/ ## zoho-api-console-oauth URL: https://docs.unified.to/skills/zoho-api-console-oauth/SKILL.md # Zoho API Console — shared OAuth2 registration mechanics Everything common to registering an OAuth 2.0 client for **any** Zoho service: the API Console, the client types, the data-centre model, redirect URIs, the scope grammar, refresh-token behaviour, and the per-organization limits. The product skills — Zoho CRM, Zoho Books, Zoho Recruit — assume this file and cover only what their service adds (a scope vocabulary, an organization identifier, a module model, a credit table). **Read this first, then the product skill.** If you are registering a plain Zoho client with no particular service in play, this file is the whole job. **Zoho is one console for dozens of services.** The same client ID and secret authorize CRM, Books, Recruit, Desk, People, Mail, Inventory, Invoice, Sign, Calendar, Meeting, Payments and the rest — the *scope* prefix decides which service the token reaches. So this base is not just the shared half of three skills: it is the whole registration for any Zoho service a product skill does not yet exist for. Three things here are expensive to get wrong, and none of them fail at registration time. **A customer's data centre is not knowable from their email address** — you learn it on the callback, and a token minted in the wrong one is simply invalid. **The client secret is per data centre by default**, so a Multi-DC client that works in the US fails in the EU with an error that says nothing about data centres. And **refresh tokens are capped at 20 per user per client**, with the oldest silently revoked on the 21st — which shows up months later as one customer's oldest connection dying for no visible reason. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **Client name** | No special characters except `_` and `&`, or the console rejects it (§3) | | **Homepage URL** | Mandatory for every client type except Self Client (§3) | | **Redirect URIs** | Every callback host the platform serves, exactly (§5) | | **Which Zoho account owns the client**, and its data centre | The account's own DC is where the client is registered (§4) | | **Which data centres customers are in** | Decides whether Multi DC must be enabled — and it usually must (§4) | | **Scope set** | Exactly what the caller requests — the product skill supplies the strings (§6) | | **One client for all Zoho services, or one per service?** | One client can carry scopes for several services (§1) | | **New client, or an edit to an existing one?** | A new client ID re-authorizes every customer (§1) | ## Quick Start 1. Confirm a **new client** is needed — existing connections are bound to the current client ID (§1). 2. Sign in to `https://api-console.zoho.com` with the account that should own the client (§2). 3. Create a client of type **Server-based Applications** — the only type that fits a multi-tenant connector (§3). 4. Add **every** redirect URI, exactly (§5). 5. **Enable Multi DC on the client's Settings tab** and decide whether the secret is shared or per-DC (§4). 6. Capture the client ID and the secret **for every enabled DC** (§4, §9). 7. Build the authorize URL with comma-delimited scopes, `access_type=offline` and `prompt=consent` (§6, §7). 8. On the callback, read `location` / `accounts-server` and exchange the code **at that DC**, with that DC's secret (§4). 9. Call the API at the `api_domain` returned in the token response, with the `Zoho-oauthtoken` header (§4, §8). 10. Run a real authorize → callback → refresh round trip, and if you can, one in a second DC (§11). 11. Hand the credentials over — never commit them (§12). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The console is the Zoho API Console at `https://api-console.zoho.com`.** Product docs variously call it the "Zoho Developer Console" and link to the same place. Entry point is **GET STARTED**, then a client type; once you have at least one client, the button is **ADD CLIENT** in the top right. Client ID and secret live on the client's **Client Secret** tab; Multi DC lives on its **Settings** tab. - **One client ID works across data centres — but only after Multi DC is enabled, and the secret differs per DC by default.** This is the single most important fact in this file; §4 sources it. - **Zoho's published DC list is shorter than the live one.** The OAuth multi-DC page lists eight (US, EU, IN, AU, JP, CA, SA, UK). The live endpoint `https://accounts.zoho.com/oauth/serverinfo` returned eleven on 2026-09-20 — those eight plus `ae` (UAE), `sg` (Singapore) and `inec` (`accounts.zohohq.in`). China (`accounts.zoho.com.cn`) appears in the product docs but not on the accounts page or in `serverinfo`; it is operated separately. **Read `serverinfo` at run time rather than hardcoding a table** (§4). - **Refresh tokens do not expire by time.** They are capped at **20 per user per client**, and the 21st silently invalidates the oldest (§7). - **Zoho's own docs disagree with each other on three numbers** — grant-token validity, access tokens stored per refresh token, and whether the auth header says `Bearer` or `Zoho-oauthtoken`. §7 and §8 say which to believe. - **There is no app review, no verification, no publishing state and no install cap.** A newly created Zoho client can serve production customers immediately. Nothing here waits on Zoho. Listing in the Zoho Marketplace is a separate product exercise and is not required to run an OAuth integration. If the console does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing client, or create a new one A new client means a **new client ID, and every existing customer connection is bound to the old one** — every customer re-authorizes. Reuse the existing client for: adding a redirect URI, adding or removing scopes, enabling a further data centre, or rotating a compromised secret. Create a **new** client only when the user explicitly wants one: a separate app for a different product, a different owning account, or a deliberate replacement of a compromised client. Say which path you are taking before touching anything. **One client can serve several Zoho services.** Scope strings are namespaced by service (`ZohoCRM.…`, `ZohoBooks.…`, `ZohoRecruit.…`), and a single authorization may carry scopes for more than one. Whether to run one client per service or one client for all of them is a platform decision, not a console constraint — but note the consequence: the refresh-token cap in §7 is counted **per user per client**, so collapsing many services onto one client also collapses their share of that budget. ## 2. Account and console access - **Console** — `https://api-console.zoho.com`. Sign in with the Zoho account that should own the client. Any Zoho account can register a client; there is no developer-programme application and no fee. - **The owning account's data centre is where the client is registered.** An account created at `zoho.com` registers a `us` client; an account created at `zoho.eu` registers an `eu` client. This is not a setting on the client — it follows the account, and it is why §4 exists. - **Register the client in the account you intend to keep.** The client belongs to that Zoho account. Prefer an account the organization owns rather than an individual's. Hand control back to the user for anything only a human can do: account creation, email verification, MFA, accepting terms, and any identity check Zoho inserts. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste — the redirect URIs from §5 and the scope strings from the product skill, verbatim — and continue once they report back with the client ID, the DC list, and where they stored each secret. ## 3. Choose the client type The console offers five, and the choice is not editable afterwards in any useful sense — it determines which fields exist and which flow the client can run. | Type | Flow | Gives a refresh token? | Use it when | | --- | --- | --- | --- | | **Server-based Applications** | Authorization code | **Yes**, with `access_type=offline` | A backend exchanges the code on behalf of **many** Zoho accounts. **The type for a multi-tenant connector.** | | Client-based Applications (JavaScript) | Implicit | **No** | Browser-only apps with no server. Needs JavaScript Domains as well as redirect URIs. | | Mobile-based Applications | Authorization code + PKCE | Yes | Native apps on phones and tablets | | Non-browser Applications | Device flow | Yes | Smart TVs, printers, limited-input devices | | **Self Client** | Authorization code from the console, or client credentials | Yes (authorization-code variant only) | **One** Zoho account, owned by the same people as the app. No redirect URI, no web UI, no end user. | **A multi-tenant connector needs Server-based.** Self Client is the trap worth naming, because it is the fastest path to a working token and it does not generalize: the resource owner *is* you, the grant token is generated by hand in the console for an organization you pick from a list, and there is no mechanism by which a customer's user authorizes it. It is right for a single-tenant back-end job and wrong for anything a customer connects. If someone proposes a Self Client for a product that will serve other companies, that is a design conversation, not a console setting. **JavaScript (client-based) clients cannot refresh.** The implicit flow returns an access token valid for one hour and no refresh token, so every hour the user must be re-prompted. That is a product constraint, not a bug. Mandatory fields per type (from Zoho's registration doc): | Field | Server-based | Client-based (JS) | Mobile | Non-browser | Self Client | | --- | --- | --- | --- | --- | --- | | Client Name | Y | Y | Y | Y | Y | | Homepage URL | Y | Y | Y | Y | N | | Authorized Redirect URI | Y | Y | Y | N | N | | JavaScript Domain | N | Y | N | N | N | The client name **must not contain special characters other than `_` and `&`** — the console rejects it with "Enter a valid client name". Homepage URL and redirect URIs must begin `http://` or `https://`. ## 4. The data-centre problem — read this twice Zoho runs separate, non-communicating data centres, because data-residency law requires it. Each DC holds only the data of users who registered there. **The accounts host, the token host and the API host all differ per DC**, and a token minted in one DC is meaningless in another. ### The domains Accounts hosts, from Zoho's OAuth multi-DC page: | DC | Accounts host | | --- | --- | | United States (US) | `https://accounts.zoho.com` | | Europe (EU) | `https://accounts.zoho.eu` | | India (IN) | `https://accounts.zoho.in` | | Australia (AU) | `https://accounts.zoho.com.au` | | Japan (JP) | `https://accounts.zoho.jp` | | Canada (CA) | `https://accounts.zohocloud.ca` | | Saudi Arabia (SA) | `https://accounts.zoho.sa` | | United Kingdom (UK) | `https://accounts.zoho.uk` | Note the two that break the pattern: **Canada is `zohocloud.ca`, not `zoho.ca`**, and **China is `accounts.zoho.com.cn`** — documented by the CRM and Recruit multi-DC pages but absent from the accounts page and from `serverinfo`, because the China service is operated separately. **Do not hardcode this table.** `https://accounts.zoho.com/oauth/serverinfo` returns the live map as JSON, and on 2026-09-20 it carried three locations that no doc page listed (`ae`, `sg`, `inec`). Zoho's own docs point at this endpoint for exactly this reason. API hosts follow a parallel but **not identical** pattern: `https://www.zohoapis.com`, `.eu`, `.in`, `.com.au`, `.jp`, `.ca`, `.sa`, `.com.cn`. Do not derive the API host from the accounts host by string surgery — Canada's accounts host is `zohocloud.ca` while its API host is `zohoapis.ca`. **Take the API host from the `api_domain` field in the token response** and store it per connection. Some services publish their own product hosts instead of `zohoapis.*` — the product skill says when. ### One client across data centres: what is actually true > **The client ID is common to all data centres. The client secret is per data centre by default, and you may > opt into a single shared secret. Cross-DC access works only once Multi DC is enabled on that client.** Zoho states it three times, in three places: - The OAuth multi-DC page: *"The **Client ID** will be common for all DCs, but the **Client Secret** can be either common to all the DCs or unique for each DC depending on your preference."* - The CRM and Recruit multi-DC pages: *"The client ID remains the same, and the client secret differs from one DC to another. You can also choose to have the same client secret across multiple domains based on your business needs."* - The Books OAuth page: *"If you want to use the Client ID created in one DC in other DCs, you need to enable Multi DC for that specific client ID from your developer console."* So: **you do not register a separate client per data centre.** You register once, in whichever DC the owning account lives in, and then extend that one client to the others. **To enable it:** API Console → select the client → **Settings** tab → toggle on each required DC. Each enabled DC then gets its **own** client secret, readable via **SHOW CODE** on that DC's row. To use one secret everywhere instead, select **Use the same OAuth credentials for all data centers** and confirm. **Choose deliberately, and record the choice.** A shared secret means one value to store, one rotation, and no per-DC branching in the token exchange — simpler, and what most connectors want. Per-DC secrets mean a compromise in one region does not reach the others, at the cost of a secret store keyed by DC and an exchange step that must look the secret up by the callback's `location`. **A connector built assuming one secret, pointed at a client that has per-DC secrets, fails in every DC except the one it was tested in — with `invalid_client`, which reads like a typo.** ### Discovering the customer's DC **You cannot infer it from the email address.** A `@example.com` user may be in any DC; a `.co.uk` address says nothing. Zoho tells you on the callback, and only then. 1. **Authorize at `https://accounts.zoho.com`.** The CRM and Recruit multi-DC pages both state: *"You must make the authorization request from `https://accounts.zoho.com` for the EU, AU, and IN domains. After the request is successful, the system automatically redirects you to your domain."* Zoho detects the user's DC from their login and routes them. The exception is China, which must be authorized at `https://accounts.zoho.com.cn`. 2. **Read `location` and `accounts-server` off the callback.** The redirect carries them beside the code: ``` {redirect_uri}?code={grant_token}&location=in&accounts-server=https://accounts.zoho.in ``` `location` is the DC key (`us`, `eu`, `in`, …); `accounts-server` is the fully-formed host. **Prefer `accounts-server`** — it is the value Zoho computed, and it survives new DCs being added without a code change. 3. **Exchange the code at that host**, with that DC's client secret, and store both the DC and the resulting `api_domain` on the connection. 4. **Refresh at the same host, forever.** The refresh call is not DC-agnostic either. ### What happens when a connector guesses Every failure mode here reports as something other than "wrong data centre": - **Exchanging an EU code at the US token host** → `invalid_client`. Zoho's own error table names this explicitly: *"There is a domain mismatch. You have registered the client and generated the grant token in a certain domain (US), but generating the tokens from a different domain (EU)."* It looks exactly like a mistyped secret. - **Multi-DC enabled, but the US secret sent to the EU token host** → also `invalid_client`, from Zoho's other documented cause: *"Each DC holds a unique client secret."* This is the failure that passes every US test and breaks on the first European customer. - **Multi DC never enabled** → the non-US authorization may not complete at all, or the exchange fails. Enabling it is a console toggle, not a re-registration; the client ID survives, so existing US connections are unaffected. - **Right token, wrong API host** → authentication succeeds and API calls 401 or return nothing, because the EU API host has never heard of a US organization. This is the one that gets misdiagnosed as a scope problem. **A connector that hardcodes `accounts.zoho.com` and `www.zohoapis.com` works perfectly for every US customer and fails for everyone else.** If the code under discussion does that, say so before registering anything — the console work is not what is broken. ## 5. Redirect URIs On a Server-based client: **Authorized Redirect URIs**, one entry per callback host; the console's **+** adds more. Get the list from the platform owner; for Unified.to these are one per data center: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Note these are the **platform's** regions, not Zoho's — they are unrelated to the Zoho DC list in §4, and they do not need to correspond. One Zoho client's redirect-URI list must contain every host your platform will send users back to, whatever DC those users are in. The rules, all of which surface at connect time rather than at save time: - The `redirect_uri` sent on the authorize call and on the token call must **match a registered URI**, and must be **identical on both calls**. A mismatch on authorize gives `Invalid Redirect Uri`; on the token call, `invalid_redirect_uri`. - Must begin `http://` or `https://`. There is no documented wildcard support — register each host literally. - **Redirect URIs belong to the client, and are shared across every enabled data centre.** There is no per-DC redirect list. One list serves all of them. - Changing the list later is a console edit, not a re-registration; existing tokens are unaffected. ## 6. Scope mechanics The product skill supplies the scope strings. This section is how Zoho treats them. **The grammar is `service_name.scope_name.OPERATION_TYPE`.** Three parts: - **Service name** — the Zoho service: `ZohoCRM`, `ZohoBooks`, `ZohoRecruit`, `ZohoInventory`, `ZohoCliq`, … - **Scope name** — the area within that service: `modules`, `settings`, `users`, `org`, `invoices`, `contacts`, … Some services nest one level further: `ZohoCRM.modules.leads`, `ZohoCRM.settings.fields`. - **Operation type** — `ALL`, `READ`, `CREATE`, `UPDATE`, `DELETE`. `ALL` grants all of them. ``` ZohoCRM.modules.ALL # every module, every operation ZohoCRM.modules.leads.READ # one module, read only ZohoBooks.invoices.UPDATE # one Books module, update only ZohoRecruit.settings.fields.read # nesting plus an operation ``` **Scopes are comma-delimited, not space-delimited.** This is the most common mechanical error carried over from other OAuth providers, and it fails as `Invalid OAuth scope` with no hint about the separator: ``` scope=ZohoCRM.modules.READ,ZohoCRM.settings.READ ``` Four more things worth knowing before you debug a scope error: - **There is no scope declaration step.** Unlike consoles that require scopes to be registered before they can be requested, Zoho takes whatever the authorize URL sends. Nothing in the console lists your scopes. That removes a whole class of failure — and removes the safety net, so a typo reaches the user as an error page. - **One bad scope fails the whole request.** `Invalid OAuth scope` means *the scope parameter* is invalid; it does not say which one. Bisect the list. - **Case is inconsistent in Zoho's own docs** — `ZohoRecruit.modules.all`, `ZohoRECRUIT.modules.all` and `ZohoCRM.modules.ALL` all appear. Copy the casing from the product skill or from the page you are working off, and if a scope is rejected, try the documented alternative casing before assuming the scope does not exist. - **Scopes are fixed at authorization.** To widen them you re-authorize; the existing refresh token does not gain scopes. Zoho supports **incremental authorization** — requesting more scopes later, in a second consent — for apps that want to ask only when a feature is used. ## 7. Refresh tokens: the cap, not the clock **Zoho refresh tokens do not expire by time.** Every product doc says the same thing: *"A refresh token does not expire. Use it to refresh access tokens when they expire."* There is no 90-day idle rule, no rotation, and no reissue-on-use. A stored refresh token from two years ago still works. What ends a refresh token is one of four events, and only one of them is yours: 1. **The cap.** *"At a time, a maximum of 20 active refresh tokens can be stored by a client per user … After the count reaches 20 for a user, when the client requests for an additional token, a new token will be provided and the oldest token will be invalidated."* Books states the consequence bluntly: this happens *"irrespective of whether the first refresh token is in use or not."* 2. The user revokes the app under **Connected Apps** in their Zoho account. 3. The user **changes their password** and opts to remove web sessions, or **enables MFA** and chooses to revoke connected apps. 4. You revoke it yourself: `POST {accounts-server}/oauth/v2/token/revoke?token={refresh_token}`. ### Why the cap is the headline Twenty sounds generous until you count what consumes it. **Every completed consent mints a new refresh token**, and `prompt=consent` (§ below) forces a consent every time. So: a customer who reconnects after an error, an admin testing the integration, the same human connecting their account to your sandbox and your production, another vendor using the same Zoho client — all draw on the same 20. When the 21st arrives, the oldest is revoked **silently**. Nothing tells you. Your next refresh on that connection returns `invalid_code`, months after the event that caused it, on a connection nobody touched. The customer's report is "it just stopped working"; the cause is a reconnect somebody did in a different tab. Practical consequences worth stating in the handoff: - **Do not re-authorize to recover from a transient failure.** Retry the refresh; burning a consent to fix a network blip costs a slot and revokes someone's oldest token. - **Revoke deliberately when you delete a connection**, so a dead token is not occupying a slot. - **One client shared across many Zoho services multiplies the pressure** (§1). ### Getting a refresh token at all Two parameters, both easy to omit: - **`access_type=offline`** — *"Default value: `online`."* Without it, the token response contains an access token and no refresh token, and the connection dies in an hour. - **`prompt=consent`** — `access_type=offline` returns a refresh token *only the first time* that user authorizes that client. Any later authorization returns an access token and silently no refresh token, because the user was not re-prompted. Books states the remedy exactly: *"To receive another refresh token, include `access_type=offline` and `prompt=consent` in your authorization request."* **A connector that reconnects an already-connected account and stores no refresh token is almost always missing `prompt=consent`.** And because `prompt=consent` makes every connect mint a fresh token, it is also what makes the 20-token cap a live concern. Both are correct; the tension is real; say so rather than dropping one. ### The other token limits | Limit | Value | Source of truth | | --- | --- | --- | | Access token lifetime | **1 hour** (`expires_in: 3600`) | Consistent everywhere | | Refresh tokens per user per client | **20**, oldest invalidated | Accounts token-limits page; CRM and Books agree | | Access tokens stored per refresh token | Accounts page says **10**; CRM and Books say **15** | **Assume 10.** Reuse a valid access token until it expires and the number never matters | | Access-token requests per refresh token | **10 per 10 minutes**, then `Access Denied` | Consistent | | Authorization codes per user | **10 per 10 minutes**, then `access_denied` | Consistent | | Grant-token (code) validity | Accounts and Books say **2 minutes**; CRM's validity page says **3 minutes**; CRM's and Recruit's error tables say **1 minute** | **Treat it as ~1 minute.** Exchange immediately; never queue the code | | Self-client grant token | **3 minutes** by default, extendable in the console | Self Client only | The throttle is the one that bites a healthy integration: a connector that fetches a fresh access token per API call rather than caching it for the hour will hit "You have made too many requests continuously" under normal load. That is not a rate limit on the API — it is a rate limit on token minting, and the fix is caching, not backoff. ## 8. Calling the API — the header Product APIs want Zoho's own scheme, not `Bearer`: ``` Authorization: Zoho-oauthtoken {access_token} ``` Books is emphatic — *"(Strictly follow this format.)"* — and adds that the token **cannot** be passed as a query parameter, only in the header. CRM and Recruit document the same form. **Zoho's generic accounts documentation says `Authorization: Bearer {access token}`.** That is a real contradiction in Zoho's own material, and it is the sort of thing that sends someone debugging scopes for an afternoon. **Follow the product doc.** When a call returns an authorization error and the token is demonstrably fresh, check the header prefix before anything else. Call the API at the **`api_domain`** returned in the token response, not at a host you assembled yourself (§4). ## 9. Capture the credentials At creation, the console shows the **Client ID** and **Client Secret** on the client's **Client Secret** tab. Unlike some consoles, Zoho's secret **remains readable** there afterwards — there is no one-time reveal. That lowers the stakes of a fumbled copy, and it raises the stakes of who has access to that Zoho account. Capture, into a secret store: - **Client ID** (of the form `1000.XXXXXXXX…`) - **Client secret — one per enabled data centre**, unless the client uses a shared secret (§4). Label each with its DC. An unlabelled pile of secrets is unusable. - The **client type** (Server-based) and the owning Zoho account - The **list of enabled data centres**, and whether the secret is shared or per-DC - **Authorize URL** `https://accounts.zoho.com/oauth/v2/auth` and **token URL** `{accounts-server}/oauth/v2/token` — remembering that the token URL is resolved per connection from the callback (§4) - The exact scope strings, verbatim and comma-joined - The redirect URIs as registered **Rotation.** There is no documented add-then-disable path for a Zoho client secret: regenerating replaces it, and every environment still holding the old value fails its next token exchange and refresh with `invalid_client`. Existing **refresh tokens survive** a secret change — they are bound to the client ID, not the secret — so no customer re-authorizes, but every refresh is broken until the new secret is deployed everywhere. Never rotate without a cutover plan and explicit go-ahead. With per-DC secrets, rotating one DC affects only that DC. Report a secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. ## 10. Limits are a per-organization concept Zoho's API limits are **not** per client and not per developer. They are consumed against **the customer's own organization**, by every integration that organization uses, plus its own internal automation. Two mechanisms, in every Zoho service that publishes limits: - **A daily allowance**, usually in a rolling 24-hour window, scaled by the customer's edition and user count. In CRM and Recruit this is denominated in **credits**, where different calls cost different amounts; in Books it is a plain request count. Either way the customer's plan sets it, and a Free-edition customer has an order of magnitude less than an Enterprise one. - **A concurrency limit** — how many calls may be in flight simultaneously — again by edition, typically 5 to 25. Several services add a lower **sub-concurrency** limit for expensive calls. What this means for a connector, in the customer's words rather than yours: - **You are sharing.** Your sync competes with the customer's other integrations and their own automation. "We are only making 200 calls" is not a defence if their budget is already spent. - **Throttling is theirs to raise, not yours.** The remedy is the customer buying credits or upgrading — say so plainly rather than adding retries that make it worse. - **Back off, do not re-authorize.** A throttle error is not an authorization failure, and re-running the OAuth flow in response burns a refresh-token slot (§7) for nothing. - **A small customer is the binding case.** Design the sync for the Free-edition ceiling, then let bigger customers be comfortable. The product skill has the actual numbers and the per-call costs. ## 11. Verify end-to-end Authorizing with the account that owns the client proves very little — it is in the client's own DC, and it is an admin. Test the path a customer takes: 1. Authorize from an account that is **not** the client owner, through the platform's real connect flow. 2. Confirm the token response contains a **`refresh_token`**, not just an `access_token`. 3. Confirm you captured **`location` / `accounts-server`** from the callback and **`api_domain`** from the token response, and that both were stored on the connection. 4. **Force a refresh**, at the stored accounts host, and make a real API call with the refreshed token. 5. Re-run the whole flow on an **already-connected** account and confirm a refresh token comes back again. That is the test that catches a missing `prompt=consent`. 6. **If Multi DC is enabled, authorize once from an account in a second data centre.** Nothing else proves the per-DC secret is wired correctly, and this is the failure that otherwise ships. 7. Confirm the API call uses the `Zoho-oauthtoken` header form (§8). | Symptom | Cause | | --- | --- | | `invalid_client` on the token exchange | Wrong secret — or the code was minted in one DC and exchanged in another, or the wrong DC's secret was sent (§4) | | Works for US customers, fails for everyone else | `accounts.zoho.com` / `zohoapis.com` hardcoded, or Multi DC not enabled (§4) | | `invalid_code` on the exchange | Code already used, or expired — treat validity as ~1 minute (§7) | | `invalid_code` when refreshing | Refresh token revoked: user revoked it, changed password, enabled MFA, or it was pushed out by the 20-token cap (§7) | | No `refresh_token` in the response | Missing `access_type=offline`, or a repeat authorization without `prompt=consent` (§7) | | One customer's oldest connection dies for no reason | The 20-refresh-tokens-per-user cap (§7) | | `Invalid OAuth scope` | Space-delimited instead of comma-delimited, a typo, or wrong casing (§6) | | `Invalid Redirect Uri` / `invalid_redirect_uri` | Sent URI not registered, or authorize and token calls disagree (§5) | | Token is valid but every API call is unauthorized | `Bearer` sent instead of `Zoho-oauthtoken` (§8) | | Token is valid, API returns nothing or 401 | Calling the wrong DC's API host — use `api_domain` (§4) | | "You have made too many requests continuously" | Token-minting throttle: 10 access tokens per refresh token per 10 minutes. Cache the access token for its full hour (§7) | | `429` / `TOO_MANY_REQUESTS` on API calls | The customer's organization-level credit or concurrency limit, not your client (§10) | ## 12. Hand off — never commit the secret - **Do not** write a client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or a chat channel. Values go to the user, for a secret store or console. - If a code change is needed (a redirect host, a scope list, DC-aware token exchange, `access_type` / `prompt` handling), keep it secret-free and say what the human must set out of band. - Close with: client name, owning Zoho account and its DC; client ID; client type; **which data centres are enabled and whether the secret is shared or per-DC**; where each secret was delivered; the authorize endpoint and the fact that the token endpoint is resolved per connection; the exact scope strings; the registered redirect URIs; and anything left for the user to do. - Say explicitly whether the connector **reads `location` / `accounts-server` on the callback**. If it does not, that is the next piece of work, and the credentials you just produced will only serve one region. ## Stop and ask Hand back to a human rather than guessing when: customers exist outside the client's own data centre and nobody has decided between a shared and a per-DC client secret; a client secret must be rotated on a live integration (§9); someone proposes a Self Client for a product other companies will connect (§3); the integration needs a new client ID and existing customers would have to re-authorize (§1); a customer is hitting organization-level credit limits and somebody wants you to commit to a volume or a cost (§10); a Zoho service is involved that no product skill covers and its scope vocabulary cannot be found in its own docs; or the console does not match the **Platform state** section. ## Other Zoho connectors in this family This base covers registration for every Zoho service, not only the three with product skills. Unified.to ships connectors for a wider set of Zoho products — among them Desk, People, Mail, Calendar, Meeting, Inventory, Invoice, Payments and Sign — and **they share this console, this client, this DC model and this refresh-token regime.** Two things carry over unchanged and are worth checking for any of them: the scope prefix is the service name (`ZohoDesk.…`, `ZohoPeople.…`, `ZohoMail.…`), and **some services publish their own API hosts rather than `zohoapis.*`** — Recruit's dedicated `recruit.zoho.*` domain is the documented case, and others follow the same shape. Take the host from the service's own docs and reconcile it with `api_domain` before assuming either. If the task names one of those services, work this file plus that service's own API documentation; a product skill here is not a prerequisite. ## References Verified to resolve on 2026-09-20. - Zoho API Console — https://api-console.zoho.com/ - OAuth 2.0 introduction (workflow, Bearer wording) — https://www.zoho.com/developer/oauth/introduction.html - Register your app (client types, mandatory fields) — https://www.zoho.com/developer/oauth/register-app.html - OAuth terminology — https://www.zoho.com/accounts/protocol/oauth-terminology.html - **Multi DC support — the client-ID/secret rule and the DC table** — https://www.zoho.com/accounts/protocol/oauth/multi-dc.html - Live data-centre map (JSON) — https://accounts.zoho.com/oauth/serverinfo - Data center for your Zoho account — https://help.zoho.com/portal/en/kb/accounts/manage-your-zoho-account/articles/data-center-for-zoho-account - Server-based applications (authorization code flow, PKCE, revocation) — https://www.zoho.com/accounts/protocol/oauth/web-server-applications.html - Get authorization code (`access_type`, `prompt`, `location`) — https://www.zoho.com/accounts/protocol/oauth/web-apps/authorization.html - Get access token — https://www.zoho.com/accounts/protocol/oauth/web-apps/access-token.html - Refresh access token — https://www.zoho.com/accounts/protocol/oauth/web-apps/access-token-expiry.html - **OAuth token limits (20 refresh tokens per user per client)** — https://www.zoho.com/accounts/protocol/oauth/token-limits.html - OAuth scope grammar — https://www.zoho.com/accounts/protocol/oauth/scope.html - Revoke a refresh token — https://www.zoho.com/accounts/protocol/oauth/revoke-refresh-token.html - Incremental authorization — https://www.zoho.com/accounts/protocol/oauth/incremental-authorization.html - Self client overview — https://www.zoho.com/accounts/protocol/oauth/self-client/overview.html ## zoho-books-oauth-app URL: https://docs.unified.to/skills/zoho-books-oauth-app/SKILL.md # Zoho Books OAuth2 App Registration The client registration is the base skill's job. What is specific to Books is that **a valid token is not enough to make a single successful call.** Every Books endpoint needs an `organization_id`, the token does not carry one, and a customer may have several. A connector that authorizes perfectly and then reads an empty ledger has almost always picked the wrong organization — or none. The second Books-specific thing is that **the API surface is not the same for every customer.** Books ships as country-specific editions, whole endpoint families exist only in some of them, and the edition is fixed when the organization is created. And the third is that Books shares its data model with Zoho Invoice and Zoho Inventory without sharing a scope namespace — so "we already support Books" does not mean what it sounds like. ## Built on: `zoho-api-console-oauth` **Read `zoho-api-console-oauth` first, then this file.** It owns all the console and protocol mechanics, and this file does not repeat them: - The Zoho API Console, client types, and why a multi-tenant connector needs **Server-based**, not Self Client. - **The data-centre model** — `location` / `accounts-server` on the callback, the per-DC accounts and API hosts, the Multi DC toggle, and the rule that **one client ID serves every DC while the client secret is per-DC by default**. - Redirect-URI rules and the platform's per-data-center callback list. - The generic scope grammar `service.scope.OPERATION`, and that scopes are **comma-delimited**. - `access_type=offline` plus `prompt=consent`, and the **20-refresh-tokens-per-user-per-client** cap that silently revokes the oldest. - The `Authorization: Zoho-oauthtoken {token}` header form, and taking the API host from `api_domain`. - Capturing and rotating the secret, and limits being a per-organization concept. If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Books-specific ones: | Input | Notes | | --- | --- | | **Which Books modules must the integration reach?** | Books has no documented full-access scope — you enumerate (§1) | | **Read-only, or read-write?** | Each module's operations are separate strings (§1) | | **Do target customers run more than one organization?** | Decides whether "the first org" is a bug or merely a latent one (§2) | | **Which country editions must be supported?** | Gates whole endpoint families, and cannot be changed later (§3) | | **Is the product really Books, or Invoice or Inventory?** | Different scope namespace, different API path (§4) | | **What plan are target customers on?** | Free plan is 1,000 requests/day for the whole org (§5) | ## Quick Start 1. Work the base skill: Server-based client, redirect URIs, Multi DC, secrets. 2. Enumerate the Books modules the integration touches and build the scope list from §1 — there is no shortcut string. 3. Include the settings scope; it is what lets you list organizations, and without it §2 has no starting point. 4. After the token exchange, **list the organizations and resolve `organization_id` deliberately** (§2). 5. Confirm the edition-gated endpoints your product needs exist for the target customers (§3). 6. Confirm the product is Books and not Invoice or Inventory — check the scope prefix and the API path (§4). 7. Size the sync against 100 requests/minute and the plan's daily cap (§5). 8. Run the base skill's round trip, plus the Books checks in §6. ## Books platform state (verified 2026-09-20 — re-verify before trusting) - **Current API version is v3**, at `https://www.zohoapis.{domain}/books/v3/…`. - **Eight documented data centres for Books**: `.com`, `.eu`, `.in`, `.com.au`, `.jp`, `.ca`, `.com.cn`, `.sa`. Note Books documents its API host for Canada as `www.zohoapis.ca` while the accounts host is `accounts.zohocloud.ca` — the base skill's warning about not deriving one from the other applies here directly. - **`organization_id` is required on every request** (§2). - **Books documents no `ZohoBooks.fullaccess.all` scope.** Zoho Invoice documents `ZohoInvoice.fullaccess.all` and Zoho Inventory documents `ZohoInventory.FullAccess.all`, but the Books scope table lists only the fifteen per-module families in §1. Do not assume the Books equivalent exists because its siblings' do. - **A documentation defect worth knowing:** the data-centre table on the Books OAuth page lists base API URIs ending `/billing/` — Zoho Billing's paths, pasted into the Books docs. The correct Books base path is `/books/v3/`, as the same page's own example request and the Books introduction page both show. Trust the example, not the table. - Limits are **100 requests per minute per organization** plus a daily cap by plan, plus a concurrency limit (§5). If the Books docs do not look like this, stop and report what you actually see. ## 1. The Books scope families Service name `ZohoBooks`; operation types `CREATE`, `READ`, `UPDATE`, `DELETE`, `ALL`. Fifteen documented families, each mapping to a module: | Scope name | Reaches | | --- | --- | | `contacts` | Customers and Vendors | | `settings` | Items, Expense Categories, Users, Taxes, Currencies, Opening Balances — and organization listing | | `estimates` | Quotes / Estimates | | `invoices` | Invoices | | `customerpayments` | Payments Received | | `creditnotes` | Credit Notes | | `projects` | Projects | | `expenses` | Expenses | | `salesorders` | Sales Orders | | `purchaseorders` | Purchase Orders | | `bills` | Bills | | `debitnotes` | Vendor Credits | | `vendorpayments` | Payments Made | | `banking` | Banking | | `accountants` | Accountant module — chart of accounts, journals | So a read-only invoice integration is `ZohoBooks.invoices.READ`, and a full one is `ZohoBooks.invoices.ALL`. Four things to get right: - **`settings` is doing more work than its name suggests.** It is the Items module, the Users module, tax configuration — *and* the organizations listing that §2 depends on. Zoho documents `ZohoBooks.settings.READ` as the scope for `GET /organizations`. **Practically every Books integration needs it**, including ones that never touch a setting, because without it you cannot discover the organization you must then name on every call. - **There is no documented full-access scope**, so the list is the list. Enumerate the modules the product touches; do not reach for a shortcut string that the Books docs do not define. - **The families are coarser than CRM's.** `accountants` covers the whole accountant surface; `banking` covers banking. There is no per-endpoint scoping, so a connector that needs one journal read takes the accountant module with it. Say that plainly in a customer conversation rather than implying finer control than exists. - **Operations are separate strings, and `ALL` includes `DELETE`.** An integration that creates and updates but must never delete should say so with `CREATE` and `UPDATE` rather than `ALL` — and it is worth doing in accounting software specifically, where a delete is not a soft delete in the customer's mind. ## 2. `organization_id` — required on every call This is the Books fact that breaks integrations. > *"In Zoho Books, your business is termed as an organization. If you have multiple businesses, you simply set each > of those up as an individual organization … The parameter `organization_id` along with the organization ID should > be sent in with every API request to identify the organization."* Each organization is independent: its own ID, base currency, time zone, language, contacts and reports. **The OAuth token does not identify one.** A token grants access to the organizations its user belongs to; which one a call acts on is decided by the parameter on that call. ``` GET https://www.zohoapis.com/books/v3/invoices?organization_id=10234695 ``` ### How to discover it Two documented ways, and only one of them is yours: 1. **`GET /organizations`** — returns every organization the authorizing user can reach, each with its `organization_id`, name, contact, currency, fiscal-year start, time zone, and an `is_default_org` flag. Requires `ZohoBooks.settings.READ`. **This is the connector's route**, and it is why §1 insists on the settings scope. 2. **The customer reads it out of the admin console** — organization dropdown → *Manage Organizations*. Useful when a human is configuring a connection by hand, useless for a self-serve flow. ### The multi-organization trap The listing is an array, and for a great many customers — accountants, franchises, holding companies, anyone with a second legal entity — it has more than one element. - **Picking element zero is not a selection, it is a coin flip.** The order is not documented as meaningful, and `is_default_org` marks the user's default, which is not necessarily the one the customer wants synced. - **The failure is silent and looks like a data problem.** The token is valid, the calls return `200`, and the ledger is simply the wrong company's — or empty, if the default org is a dormant one. Nobody sees an error. - **It is not fixed by re-authorizing**, so customers and support both chase the wrong thing. - **The right shape is to ask.** List the organizations after the token exchange and let the customer choose, or at minimum surface which one was chosen so a human can catch it. If the connection model has no place to store that choice, that is the gap to raise — not something to paper over with a default. - **Store the chosen ID on the connection.** Re-deriving it per call re-runs the discovery and spends requests against the customer's per-minute budget (§5). One more consequence: **the per-minute and per-day limits in §5 are per organization.** A customer with four organizations has four separate budgets, and a connector syncing all four is not competing with itself — but it is also making four times the calls, and each needs its own ID on every request. ## 3. Country editions and regions — two different things **Do not conflate the data centre with the edition.** The base skill's DC model is about *where the data lives*. The edition is about *what accounting rules the organization runs under*, and it gates endpoints. - **The edition is chosen at organization signup and is effectively permanent.** Zoho: *"The Country, Base Currency and Timezone can not be changed if you are using a country specific edition."* The remedy is a new organization, which a customer will not do for your integration. - **Taxes are handled differently in each edition**, and that reaches the API, not just the UI. - **Whole endpoint families are edition-gated**, and the Books docs label them inline. As of 2026-09-20 the docs mark, among others: tax authorities as **US and CA edition only** (with the listing endpoint marked **US edition only**), and the entire tax-exemption family — create, list, update, get, delete — as **US edition only**. Separately, the e-invoicing families on invoices and credit notes exist for the editions whose jurisdictions mandate e-invoicing. - **A call to an endpoint the edition does not have fails for that customer and only that customer.** It is not a scope problem and not a permissions problem, and it will not reproduce in your test org unless your test org is in the same edition. What to do about it: - **Ask which editions the product must support**, and check the specific endpoints you depend on against the doc labels before promising anything. - **Treat tax as the high-risk surface.** It is where the editions differ most and where getting it wrong is a compliance problem for the customer rather than a bug for you. - **Test in more than one edition** if the product claims more than one, or say plainly that it has only been proven in one. - Both the edition and the data centre are visible to the customer in the URL they use — `books.zoho.com` versus `books.zoho.in` and so on — which makes "which one are you on?" an easy question to ask them. ## 4. Books vs Invoice vs Inventory Three Zoho products share a great deal of one data model — contacts, items, estimates, invoices, payments, credit notes — and **do not share a scope namespace or an API path**: | Product | Scope prefix | API base path | Full-access scope | | --- | --- | --- | --- | | Zoho Books | `ZohoBooks.` | `/books/v3/` | Not documented | | Zoho Invoice | `ZohoInvoice.` | `/invoice/v3/` | `ZohoInvoice.fullaccess.all` | | Zoho Inventory | `ZohoInventory.` | `/inventory/v1/` | `ZohoInventory.FullAccess.all` | Note the casing difference between the two full-access scopes — `fullaccess` for Invoice, `FullAccess` for Inventory. That is Zoho's own inconsistency, reproduced here deliberately. How to think about the split: - **Books is the superset on the accounting side.** It adds bills, vendor payments, banking, the accountant module and the general ledger — surfaces Invoice does not have at all. - **Invoice is the receivables-only subset**, and the relationship is live rather than conceptual: the Books API itself exposes operations to **downgrade an organization to Zoho Invoice** and **upgrade an organization to Zoho Books**. It is the same organization either way. So a customer can move between products under a running integration, and the scope prefix that worked yesterday can be the wrong one today. - **Inventory is the stock-and-fulfilment product** — items and composite items, inventory adjustments, transfer orders, packages, shipment orders, purchase receives, sales returns — alongside a sales/purchase surface that overlaps Books. - **A token scoped for one does not reach another.** `ZohoBooks.invoices.READ` does not read Zoho Invoice's invoices. If a customer says "we use Zoho for invoicing", that is not yet enough information to pick a scope. - **They are separate connectors, not variants.** If the platform ships distinct Books, Invoice and Inventory connectors, a customer on the wrong one authorizes successfully and then finds the API path does not answer. Check the product before the scopes. ## 5. Limits are per organization Books meters **requests**, not credits — a different model from CRM and Recruit, and easier to reason about: | Plan | Requests per day | | --- | --- | | Free | 1,000 | | Standard | 2,000 | | Professional | 5,000 | | Premium / Elite / Ultimate | 10,000 | Plus, for every plan: **100 requests per minute per organization**, and a **concurrency limit** of 5 concurrent calls (Free) or 10 (paid, described as a soft limit). All of it returns `429`, with a code that tells you which limit you hit: `45` for the daily cap, `44` for the per-minute cap, `1070` for concurrency. Read the code rather than treating every `429` the same — the daily cap means stop until tomorrow, the per-minute cap means slow down, and concurrency means reduce parallelism. Three things this implies for a connector: - **1,000 requests a day is small.** A Free-plan customer with a few thousand invoices cannot be fully synced daily by a connector that pages 100 at a time and makes a follow-up call per record. Design for the Free ceiling. - **Re-discovering `organization_id` costs requests.** Cache it (§2). - **Multi-organization customers have multiple budgets** but also multiple syncs; the arithmetic does not cancel. ## Product fact — what the Books connector asks for > **As of 2026-09-20, Unified.to's Zoho Books connector requests** a per-unified-object scope set rather than one > flat list: > > - **`ZohoBooks.settings.READ` appears in essentially every set**, including read-only ones — consistent with §1 > and §2, since it is what makes the organizations listing reachable. > - **Module scopes added per object**, for example `ZohoBooks.invoices.READ`, `ZohoBooks.bills.READ`, > `ZohoBooks.salesorders.READ`, `ZohoBooks.purchaseorders.READ`, `ZohoBooks.contacts.READ`, > `ZohoBooks.expenses.READ`, `ZohoBooks.customerpayments.READ`, `ZohoBooks.accountants.READ` and > `ZohoBooks.banking.READ`. > - **Write sets enumerate `CREATE`, `UPDATE` and `DELETE` explicitly** on the relevant modules rather than using > `.ALL` — more strings, but an honest list on a consent screen. > - **Identity/login only:** `ZohoBooks.settings.READ`. > - **No full-access scope is requested**, consistent with §1: Books does not document one. > > Two things to raise with the connector's owner rather than copy: > > 1. **On connect it lists the customer's organizations and remembers the first one as the connection's default.** > For a single-organization customer that is correct and invisible. For a multi-organization customer the default > is whichever company happens to come back first, and nothing on the consent screen tells the customer which > that was. > 2. **That default is a fallback, not a lock.** An organization can also be named per request, or carried on the > object identifier itself, and those take precedence. So multi-entity access is reachable — but only for a > caller who knows to ask for it. Someone who never passes an organization gets the first one, silently and > consistently, which is the §2 trap wearing a friendlier face. > > Scope sets change. This note is dated, not live; confirm the current set with the connector's owner before > submitting anything. ## 6. Books-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Call `GET /organizations` and **look at how many came back**. If your test account has exactly one, that test has not exercised §2 — get an account with two before believing the connector is multi-entity safe. 2. Make one real read with `organization_id` on the query string, and one deliberately **without** it, to confirm you recognise the failure mode. 3. Confirm the organization actually synced is the one the customer expects, by name, not by ID. 4. Read one record from **every** module the integration claims — Books scopes are per module and fail per module. 5. If the product supports more than one country edition, exercise an edition-gated endpoint (§3) in the edition that has it and confirm you handle its absence elsewhere. 6. Check the API path is `/books/v3/` and not an Invoice or Inventory path (§4). 7. Confirm the connector caches the organization ID rather than re-deriving it per call (§2, §5). | Symptom | Cause | | --- | --- | | Token valid, every call fails or returns nothing useful | `organization_id` missing from the request (§2) | | Token valid, calls succeed, data belongs to the wrong company | Wrong organization picked from the listing (§2) | | `GET /organizations` itself fails | `ZohoBooks.settings.READ` not requested (§1) | | One module works, another is forbidden | Books scopes are per module — the second one was not requested (§1) | | An endpoint works for one customer and 404s for another | Edition-gated endpoint; the second customer's edition does not have it (§3) | | Scopes accepted, but the API path does not answer | Wrong product — Invoice or Inventory, not Books (§4) | | `429` with code `45` | Daily request cap for that plan (§5) | | `429` with code `44` | 100 requests per minute per organization (§5) | | `429` with code `1070` | Concurrency limit — reduce parallel calls (§5) | | Works in the US, fails elsewhere | Data centre, not edition — see the base skill (§3 and base §4) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - Target customers may run **multiple organizations** and there is no customer-facing way to choose which one syncs (§2). Do not ship a default and call it done. - The product must support **country editions** whose endpoint availability nobody has checked (§3) — particularly anything touching tax or e-invoicing. - It is unclear whether the integration belongs against **Books, Invoice or Inventory** (§4), or a customer may move between them. - A **Free-plan** customer's volume plainly exceeds 1,000 requests/day and someone wants a sync-frequency commitment (§5). - Anyone proposes a delete-capable scope on accounting records without an explicit owner for that decision (§1). ## Unverified as of 2026-09-20 - **Whether a `ZohoBooks.fullaccess.all` scope is accepted in practice.** The Books scope table does not list one, while Invoice and Inventory document theirs. Absence from the docs is not proof of rejection — but do not build on it without testing, and prefer the documented per-module strings. - **Whether the `/billing/` base URIs in the Books OAuth page's data-centre table are a doc defect or a live alternative.** Every other Books source, including that page's own example, uses `/books/v3/`. Treat `/books/v3/` as correct. ## References Books-specific only; the base skill carries the generic Zoho OAuth references. Verified to resolve on 2026-09-20. - **Books API introduction (`organization_id`, data centres, API call limits)** — https://www.zoho.com/books/api/v3/introduction/ - **Books OAuth (scope table, registration, token validity, Multi DC)** — https://www.zoho.com/books/api/v3/oauth/ - Organizations API (listing, upgrade/downgrade between Books and Invoice) — https://www.zoho.com/books/api/v3/organizations/ - Zoho Invoice OAuth (scope namespace, `ZohoInvoice.fullaccess.all`) — https://www.zoho.com/invoice/api/v3/oauth/ - Zoho Inventory OAuth (scope namespace, `ZohoInventory.FullAccess.all`) — https://www.zoho.com/inventory/api/v1/oauth/ - Country-specific edition (country, currency and timezone are fixed) — https://www.zoho.com/us/books/kb/general/different-country-edition.html ## zoho-crm-oauth-app URL: https://docs.unified.to/skills/zoho-crm-oauth-app/SKILL.md # Zoho CRM OAuth2 App Registration Registering the client is the easy part and the base skill covers it. What is specific to CRM is **the scope vocabulary, and the fact that the thing you are scoping is not fixed.** CRM's data model is whatever the customer made it: standard modules they may have renamed, custom modules you have never heard of, and fields that differ per org. A scope list written against one customer's CRM is a guess about the next one's. Two CRM-specific traps beyond that. **Every environment is a different organization** — a token for Production is not a token for Sandbox, and there is no flag to switch. And **the limits are denominated in credits, not calls**, against the customer's org, where a single unlucky operation can cost 500. ## Built on: `zoho-api-console-oauth` **Read `zoho-api-console-oauth` first, then this file.** It owns all the console and protocol mechanics, and this file does not repeat them: - The Zoho API Console, client types, and why a multi-tenant connector needs **Server-based**, not Self Client. - **The data-centre model** — `location` / `accounts-server` on the callback, the per-DC accounts and API hosts, the Multi DC toggle, and the rule that **one client ID serves every DC while the client secret is per-DC by default**. - Redirect-URI rules and the platform's per-data-center callback list. - The generic scope grammar `service.scope.OPERATION` and the fact that scopes are **comma-delimited**. - `access_type=offline` plus `prompt=consent`, and the **20-refresh-tokens-per-user-per-client** cap that silently revokes the oldest. - The `Authorization: Zoho-oauthtoken {token}` header form, and taking the API host from `api_domain`. - Capturing and rotating the secret, and limits being a per-organization concept. If you are reading only this file, you are missing all of the above — including the single most common cause of a Zoho CRM integration that works in testing and fails for a European customer. ## Inputs to collect before you start The base skill lists the console inputs. These are the CRM-specific ones: | Input | Notes | | --- | --- | | **Which CRM modules must the integration reach?** | Decides module-level versus `modules.ALL` (§1) | | **Are custom modules in scope?** | They need their own scope and cannot be named in advance (§2) | | **Read-only, or read-write?** | `READ` versus `ALL` per module (§1) | | **Does it need field or layout metadata?** | Almost always yes, and that is a separate scope family (§1) | | **Will it use COQL or the Bulk APIs?** | Each is its own scope, not implied by `modules` (§1) | | **Production only, or Sandbox / Developer too?** | Separate organizations, separate tokens, separate hosts (§4) | | **What edition are the target customers on?** | Sets their credit ceiling, which your sync design has to fit (§5) | ## Quick Start 1. Work the base skill: Server-based client, redirect URIs, Multi DC, secrets. 2. Settle the module list with the user, then build the scope set from §1 — narrowest that does the job. 3. Add the metadata scope; a connector that maps fields needs it and will otherwise fail on the second call (§1). 4. If custom modules matter, add the custom-module scope and plan to **discover** modules at run time (§2). 5. Pick and pin an API version explicitly; do not let it float (§3). 6. Decide whether Sandbox or Developer environments are in scope — if so, they are separate connections (§4). 7. Size the sync against the customer's credit ceiling, not against yours (§5). 8. Run the base skill's round trip, plus the CRM checks in §6. ## CRM platform state (verified 2026-09-20 — re-verify before trusting) - **v8 is the current documented API version.** The documentation trees for `v2`, `v2.1`, `v3`, `v4`, `v5`, `v6`, `v7` and `v8` all still served pages on 2026-09-20; `v9` did not exist. The base path is `{api_domain}/crm/{version}/…`. - **Zoho published no Zoho CRM API end-of-life date that could be verified on 2026-09-20.** A widely-cited "V2 end-of-life extended to 31 December 2026" announcement is **Zoho Projects**, not Zoho CRM — it is signed by the Zoho Projects team. Do not repeat it as a CRM fact. See "Unverified" below. - **Limits are credits, not calls**, over a rolling 24-hour window, plus a concurrency limit and a sub-concurrency limit for expensive calls. Free edition is 5,000 credits/day (§5). - **Three environments** — Production, Sandbox, Developer — each with its own API host prefix and its own tokens (§4). - `X-API-CREDITS-REMAINING` appears in responses once the org has spent 50% or more of its daily allowance. If the CRM docs do not look like this, stop and report what you actually see. ## 1. The CRM scope families CRM's scope vocabulary has seven families. The service name is `ZohoCRM`, and the operation types are `ALL`, `READ`, `CREATE`, `UPDATE`, `DELETE`. | Family | What it reaches | Shape | | --- | --- | --- | | **`modules`** | Record data — Leads, Contacts, Accounts, Deals, and everything else | `ZohoCRM.modules.ALL`, or `ZohoCRM.modules.{module}.{op}` | | **`settings`** | Configuration and metadata: fields, layouts, modules, custom views, related lists, roles, profiles, tags, territories, currencies, macros, variables, custom links and buttons, tab groups | `ZohoCRM.settings.ALL`, or `ZohoCRM.settings.{area}.{op}` | | **`users`** | The org's users | `ZohoCRM.users.ALL` | | **`org`** | Organization-level details | `ZohoCRM.org.ALL` | | **`bulk`** | The asynchronous Bulk Read / Bulk Write APIs | `ZohoCRM.bulk.ALL`, `.READ`, `.CREATE` | | **`coql`** | The SQL-like query API | `ZohoCRM.coql.READ` | | **`notifications`** | Change notifications (webhooks) | `ZohoCRM.notifications.{op}` | **The module family nests one level; the others mostly do too.** `ZohoCRM.modules.leads.READ` scopes one module; `ZohoCRM.settings.fields.READ` scopes one settings area. Zoho publishes the documented module scope names — `approvals`, `leads`, `accounts`, `contacts`, `deals`, `campaigns`, `tasks`, `cases`, `events`, `calls`, `solutions`, `products`, `vendors`, `pricebooks`, `quotes`, `salesorders`, `purchaseorders`, `invoices`, `custom`, `dashboards`, `notes`, `activities`, `search`, `services`, `appointments`, `appointments_rescheduled_history` — and the documented settings areas: `ALL`, `territories`, `custom_views`, `related_lists`, `modules`, `variables`, `tags`, `tab_groups`, `fields`, `layouts`, `macros`, `custom_links`, `custom_buttons`, `roles`, `profiles`, `currencies`. Four things to get right: - **Metadata is a separate scope, and you almost certainly need it.** Anything that maps CRM fields to a unified model reads field metadata, which is `ZohoCRM.settings.fields.READ` — *not* covered by any `modules` scope. A connector that requests only module scopes authorizes cleanly and then fails on its first metadata call. Layouts (`settings.layouts`) are the same story for anything reading pipelines or stages. - **COQL and Bulk are not implied by `modules`.** If the connector queries with COQL, `ZohoCRM.coql.READ` must be in the list even though every record it returns is a module record. Same for the Bulk APIs. - **`modules.ALL` is a big ask and an easy sell.** It covers every module including custom ones, and it is what a general-purpose connector ends up wanting. It also means a customer's security review sees "full access to all CRM data". Where the product genuinely only touches four modules, name those four — it is a materially easier conversation and it is free to do. - **Write access has no middle gear in practice.** `ZohoCRM.modules.leads.CREATE` and `.UPDATE` exist, but a connector that creates, updates *and* reads a module usually ends up requesting `.ALL` rather than three strings. That is a legitimate simplification; just be aware `.ALL` includes `DELETE`. **Zoho's published scope list is not exhaustive.** Scope names appear in the wild — and in working connectors — that the scopes page does not list. If a scope you believe in is rejected with `Invalid OAuth scope`, the published list is the wrong place to conclude it does not exist; check the specific API's own documentation page, which states its required scope at the top of each endpoint. If a scope you did *not* find documented is in use somewhere, treat it as unverified rather than as evidence the page is wrong. ## 2. Modules, custom modules, and API names **CRM's module list is per-customer.** Standard modules can be renamed, disabled, or hidden by profile; custom modules are created by the customer and named by them. Nothing about a specific customer's module set is knowable at registration time. - **Address everything by `api_name`, never by label.** Every module, field and related list has an `api_name` that Zoho generates internally and that survives the customer renaming the label. Labels are display text and will change under you. Two documented quirks: the Events module's singular and plural labels are **Meeting** and **Meetings**, and standard modules' API names cannot be altered — only custom ones can. - **Discover the modules at run time.** `GET /settings/modules` returns the list for that org, and it is the only correct source. It needs `ZohoCRM.settings.ALL` or `ZohoCRM.settings.modules.{op}`. Building a connector against a hardcoded module list is building against your own test org. - **Custom modules need `ZohoCRM.modules.custom`** — or `modules.ALL`. There is no way to scope one specific custom module at registration time, because you do not know its API name until the customer connects. This is the practical reason most CRM connectors end up at `modules.ALL`: per-module scoping and custom-module support are in tension, and only one of them can be decided in advance. - **The module list also tells you what is usable.** The metadata response flags each module — whether records can be created, whether it can be converted, its generated type (default, web, custom, linking), and whether it is reachable by API at all. Read those flags rather than assuming. - **Field-level access is not in OAuth.** A user's CRM profile and role decide which fields and records they can see. The token is bounded by the *scope* **and** by the authorizing user's permissions, and the narrower of the two wins. "The scope is right but the data is missing" is usually a profile problem in the customer's CRM, fixable only by them. ## 3. API versions The version sits in the path — `{api_domain}/crm/{version}/…` — and on 2026-09-20 every tree from `v2` to `v8` still served documentation. **v8 is the current one.** What the version actually changes, and what it does not: - **It does not change OAuth.** The scope strings, the console, the DC model, the header form and the refresh regime are the same across versions. A v2 integration and a v8 integration use identical credentials. - **It changes endpoints, payload shapes and available features.** v8 added, among others, related-records counts, rich-text field reads, data-sharing-rule management, workflow and action management, recycle-bin operations and record-level email sharing. Older versions simply do not have those endpoints. - **Pin it.** Put a specific version in the base path and change it deliberately. A connector that builds its path from a variable someone can edit will one day be running two versions at once against different customers. - **Match the docs to the version you run.** Zoho keeps the old documentation trees live, which is helpful and also means it is easy to read a v2 page while running v8. Check the version in the URL of the page you are quoting. ## 4. Production, Sandbox and Developer are different organizations CRM has three environments, and they are isolated at the token level: | Environment | API host pattern | | --- | --- | | Production | `https://www.zohoapis.{domain}` | | Sandbox | `https://sandbox.zohoapis.{domain}` | | Developer | `https://developer.zohoapis.{domain}` | Zoho is explicit: *"the access and refresh token generated for a user becomes organization-specific in an environment. Thus, you cannot use tokens generated for an organization in one environment to make API calls to the organization in another environment."* Consequences that shape the integration, not just the test plan: - **A Sandbox connection is a separate connection.** Separate authorization, separate refresh token, separate stored API host. There is no environment parameter and no way to point a Production token at Sandbox. - **The user picks the org during consent.** If the authorizing user has more than one organization across environments, Zoho shows a picker before the consent screen and the grant token is bound to their choice. If they have exactly one, it is chosen silently. **So a customer can connect the wrong org without noticing**, and the symptom is an authorization that works perfectly against an empty or unfamiliar dataset. Confirm the org in your post-connect check rather than trusting the flow. - **Every consent consumes a refresh-token slot** (base skill §7). Testing across environments with the same Zoho user burns through the 20 faster than anyone expects. - Sandbox is an admin-managed copy of configuration and optionally data, rebuilt from Production on demand — so its module set and field set drift from Production between rebuilds. A mapping verified in Sandbox is evidence, not proof. ## 5. Credits, not calls CRM meters **credits** in a rolling 24-hour window, against the customer's organization. Most calls cost 1 credit; expensive ones cost far more. | Edition | Allowed credits per 24h | Ceiling | | --- | --- | --- | | Free | 5,000 | 5,000 | | Standard / Starter | 50,000 + (users × 250) + add-ons | 100,000 | | Professional | 50,000 + (users × 500) + add-ons | 3,000,000 | | Enterprise / Zoho One | 50,000 + (users × 1,000) + add-ons | 5,000,000 | | Ultimate / CRM Plus | 50,000 + (users × 2,000) + add-ons | Unlimited | Trial editions get the same limits as the paid edition they are trialling. **The costs that surprise people** (1 credit unless listed): | Operation | Credits | | --- | --- | | Insert / Update / Upsert | 1 per 10 records (max 100 records per call, so max 10) | | Add / Remove tags | 1 per 50 records | | Get IDs of deleted records; related-records count | 2 | | COQL query | 1 (LIMIT ≤ 200), 2 (≤ 1,000), 3 (≤ 2,000) | | Records fetched with a custom-view id | 3 | | Convert Lead | 5 | | Send Mail | 20 | | Create / Update / Delete a custom field | 10 **per field** | | **Record count in a module** | **50** | | **Bulk Read Initialize** | **50** | | Merge records; mass change owner; mass delete by view | 50 | | Mass Convert Leads | 200 | | **Bulk Write Initialize** | **500** | | Create custom module; transfer records and delete user | 500 | Read that table as a design brief. **"How many records are in this module?" costs 50 credits** — a naive sync that asks before each page can spend a Free-edition customer's entire day on counting. **Bulk Write Initialize costs 500**, so bulk is cheap per record and expensive per job: batch hard, never per-record. And writes are metered per ten records, so 100-record pages are ten times cheaper than 10-record pages for identical data. **Concurrency is a second, independent limit**: 5 (Free) / 10 (Standard) / 15 (Professional) / 20 (Enterprise) / 25 (Ultimate) simultaneous calls per org per app. A separate **sub-concurrency limit of 10**, for all editions, applies to the expensive calls — COQL, Composite, Convert Lead, Send Mail, sorted or custom-view reads, and multi- record writes. Exceeding either returns `TOO_MANY_REQUESTS`. Zoho is explicit that there is no per-minute rate limit: throughput is bounded by concurrency, so the correct lever is fewer parallel calls, not sleeping between them. Watch `X-API-CREDITS-REMAINING`, which Zoho starts returning once the org has spent 50% of its daily allowance. And note that the customer's own Deluge functions and other integrations draw on the same budget — you are a guest. ## Product fact — what the CRM connector asks for > **As of 2026-09-20, Unified.to's Zoho CRM connector requests**, per unified object rather than as one flat list: > > - **A metadata-and-query baseline on nearly every scope set:** `ZohoCRM.settings.fields.READ`, > `ZohoCRM.coql.READ` and `ZohoCRM.users.READ`. Field metadata and the COQL query API are treated as foundational > — consistent with §1, where neither is implied by a module scope. > - **One module scope per object**, read sets using `.READ` and write sets using `.ALL` on the same module: > `ZohoCRM.modules.contacts`, `.accounts`, `.deals`, `.leads`, `.notes`. Pipelines additionally request > `ZohoCRM.settings.layouts.READ` and a pipeline settings scope. > - **Identity/login only:** `ZohoCRM.users.READ`. > - **Metadata write** escalates the field scope to `ZohoCRM.settings.fields.ALL`. > > Three things to raise with the connector's owner rather than copy: > > 1. **It does not request `ZohoCRM.modules.ALL`.** Per-module scoping is deliberate and is the easier customer > conversation — but it means **custom modules are out of reach** (§2), and adding them later is a re-consent for > every customer. > 2. **At least two of the scope strings in use do not appear on Zoho's published scopes page** — an activity-style > module scope and a pipeline settings scope. Either the published list is incomplete (likely; see §1) or those > strings are historical. Confirm before reusing them in a new registration. > 3. **The connector pins the CRM path to a specific API version** (v8 at the time of writing) on top of the > `api_domain` returned at token exchange, and **derives the accounts host from the callback's DC parameters** — > so it is DC-correct by construction. Its in-console registration help link, however, still points at an older > API version's page. > > Scope sets change. This note is dated, not live; confirm the current set with the connector's owner before > submitting anything. ## 6. CRM-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Call `GET /settings/modules` and confirm the org's actual module list comes back — this proves the settings scope and tells you what you are really integrating with. 2. Read one record from each module the integration claims, using its `api_name`. 3. Read **field metadata** for one module. This is the call that fails when only module scopes were requested. 4. If custom modules are in scope, test against a real custom module in a real customer-shaped org — not a standard module renamed. 5. Confirm the authorizing user landed in the **organization you expect** (§4), especially if they have more than one. 6. If COQL or Bulk are used, exercise one of each; their scopes are separate and fail separately. 7. Check a response for `X-API-CREDITS-REMAINING` and sanity-check your sync's credit cost against §5. | Symptom | Cause | | --- | --- | | Auth succeeds, field-metadata call fails | `ZohoCRM.settings.fields.READ` not requested (§1) | | Auth succeeds, COQL or Bulk calls fail | Those are separate scope families, not implied by `modules` (§1) | | A custom module is invisible | `modules.custom` or `modules.ALL` not requested (§2) | | A standard module is invisible | Disabled in that org, or hidden by the authorizing user's profile (§2) | | Scope is right but records or fields are missing | The authorizing user's CRM profile/role, not OAuth (§2) | | `INVALID_OAUTHTOKEN` | Access token expired, or pushed out by the per-refresh-token access-token cap — reuse the token for its full hour (base skill §7) | | Everything authorizes but the data is unfamiliar or empty | Connected to the wrong organization or the wrong environment (§4) | | `TOO_MANY_REQUESTS` | Concurrency or sub-concurrency, not the daily budget — reduce parallelism (§5) | | Credits exhausted early in the day | A per-page count or per-record write pattern; or the customer's other integrations (§5) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - The choice between per-module scopes and `modules.ALL` is open — it is a customer-trust decision and a custom-module capability decision at once, and changing it later re-consents every customer (§1, §2). - Anyone wants a commitment about **custom module support** without a plan for run-time module discovery (§2). - Sandbox or Developer environments are in scope and nobody has decided whether they are separate connections (§4). - A customer is hitting credit limits and the answer would be a volume or cost commitment (§5). - Someone cites a **Zoho CRM API end-of-life date** — verify it against a Zoho CRM source before acting; the widely circulated one is Zoho Projects' (see Platform state). ## Unverified as of 2026-09-20 - **No Zoho CRM API version deprecation or end-of-life date could be confirmed from a Zoho CRM source.** The v2–v8 documentation trees all serve. Treat any specific CRM sunset date as unverified until it is found on a Zoho CRM page. - **The Zoho CRM V8 announcement carries no visible release date**, so "since when" questions about v8 features are unanswered here. - **Zoho's published CRM scope list appears incomplete** (§1). Scope strings in production use are absent from it. ## References CRM-specific only; the base skill carries the generic Zoho OAuth references. Verified to resolve on 2026-09-20. - Zoho CRM V8 API home — https://www.zoho.com/crm/developer/docs/api/v8/ - OAuth 2.0 overview — https://www.zoho.com/crm/developer/docs/api/v8/oauth-overview.html - Register client (client types, mandatory fields, errors) — https://www.zoho.com/crm/developer/docs/api/v8/register-client.html - Authorization request (org picker, `location`, `accounts-server`) — https://www.zoho.com/crm/developer/docs/api/v8/auth-request.html - Access & refresh tokens (per-DC accounts URLs, sandbox/developer hosts) — https://www.zoho.com/crm/developer/docs/api/v8/access-refresh.html - Token validity — https://www.zoho.com/crm/developer/docs/api/v8/token-validity.html - **Scopes** — https://www.zoho.com/crm/developer/docs/api/v8/scopes.html - **API limits (credits, concurrency, sub-concurrency)** — https://www.zoho.com/crm/developer/docs/api/v8/api-limits.html - Multi DC support — https://www.zoho.com/crm/developer/docs/api/v8/multi-dc.html - Modules API (`api_name`, module discovery) — https://www.zoho.com/crm/developer/docs/api/v8/modules-api.html - Field metadata API — https://www.zoho.com/crm/developer/docs/api/v8/field-meta.html - COQL overview — https://www.zoho.com/crm/developer/docs/api/v8/COQL-Overview.html - Sandbox — https://www.zoho.com/crm/developer/sandbox.html ## zoho-people-oauth-app URL: https://docs.unified.to/skills/zoho-people-oauth-app/SKILL.md # Zoho People OAuth2 App Registration The client registration is the base skill's job. What is specific to People is that **there is almost nothing to scope.** CRM scopes a module, Books scopes a ledger area; People has one `forms` scope that reaches every form in the account — the Employee form, the Asset form, and whatever the customer built last quarter. You cannot narrow it and you cannot enumerate it in advance, because forms are customer-created objects with customer-chosen link names. What a token actually returns is decided after consent, by the customer's form configuration and the authorizing user's role. Two more People-specific things. **The published scope table is not the authority, and Zoho says so on the page itself** — scopes in live use are documented only on the endpoint pages that need them. And **People is three APIs wearing one product name**: a legacy forms API, a `v2` leave tracker, and a `v3` tree, all served from the same host, all current, with no migration notice and no version in the base path you register against. ## Built on: `zoho-api-console-oauth` **Read `zoho-api-console-oauth` first, then this file.** It owns all the console and protocol mechanics, and this file does not repeat them: - The Zoho API Console, client types, and why a multi-tenant connector needs **Server-based**, not Self Client. - **The data-centre model** — `location` / `accounts-server` on the callback, the per-DC accounts hosts, the Multi DC toggle, and the rule that **one client ID serves every DC while the client secret is per-DC by default**. - Redirect-URI rules and the platform's per-data-center callback list. - The generic scope grammar `service.scope.OPERATION`, and that scopes are **comma-delimited**. - `access_type=offline` plus `prompt=consent`, and the **20-refresh-tokens-per-user-per-client** cap that silently revokes the oldest. - The `Authorization: Zoho-oauthtoken {token}` header form, and taking the API host from `api_domain`. - Capturing and rotating the secret, and limits being a per-organization concept. Two of those need a People-shaped footnote rather than a repeat. **Taking the host from `api_domain` is not enough here** — People does not live on the host `api_domain` names (§4). And the base's warning that Zoho's scope casing drifts between pages does not apply to the People *prefix*, which is `ZOHOPEOPLE` in capitals on every Zoho page checked; People drifts on the scope *name* instead (§2). If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the People-specific ones: | Input | Notes | | --- | --- | | **Which HR areas must the integration reach?** | Forms, leave, attendance, time tracking are separate scope families (§2) | | **Read-only, or read-write?** | `forms` has per-operation strings; most other families only publish `.ALL` (§2) | | **Does the integration create or update records?** | A write-only scope set does not include read, and most write flows read back (§2) | | **Will it touch custom forms?** | It already does — `forms` cannot exclude them, and it cannot name them either (§1) | | **Which endpoint generation is the integration built on?** | Legacy, v2 and v3 coexist and are scoped and shaped differently (§3) | | **Do you have a form and field permission map from the customer?** | Decides what the token can actually see (§1) | | **What plan are target customers on?** | The API is not sold below Essential HR, and the daily cap scales by plan (§5) | ## Quick Start 1. Work the base skill: Server-based client, redirect URIs, Multi DC, secrets. 2. Settle which HR areas are in play, then build the scope set from §2 — and take the scope string from the endpoint page you intend to call, not from the scope table. 3. If anything creates or updates, add the matching read operation explicitly; write does not imply read (§2). 4. Pin the endpoint generation per call family and write it down — there is no single version in the base path (§3). 5. Resolve the API host to `people.zoho.{domain}`; do not call the host `api_domain` returns (§4). 6. Read the organization's date format once at connect and express every date parameter in it (§4). 7. Discover the customer's forms at run time rather than hardcoding link names (§1). 8. Size the sync against the **per-endpoint** threshold and its five-minute lock, not against a daily total (§5). 9. Run the base skill's round trip, plus the People checks in §6. ## Platform state (verified 2026-09-20 — re-verify before trusting) - **Two scope tables exist and they are identical** — `…/people/api/scopes.html` and `…/people/api/v3/scopes.html` list the same seven families. Neither is exhaustive (§2). - **The API is a paid feature.** Zoho states it is "available in Essential HR, Professional, Premium, and Enterprise plans" — on both the legacy and the v3 overview pages, which are otherwise word-for-word identical and mention no version at all. - **Three endpoint generations serve simultaneously**: legacy forms (`/api/forms/…`), a v2 leave tracker (`/api/v2/leavetracker/…`) and a v3 tree (`/api/v3/…`). **No Zoho People deprecation or end-of-life notice could be found on a Zoho People page** (§3, and see "Unverified"). - **There is no full-access scope.** No `ZohoPeople.fullaccess.all` equivalent is documented. The widest grant is the family-level `.ALL` — and `ZOHOPEOPLE.forms.ALL` is effectively that for HR record data (§2). - **Limits are plain call counts with a per-endpoint throttle**, not credits: a per-plan daily cap plus a per-user- licence allowance, and every endpoint page carries its own "Threshold Limit … | Lock period" pair (§5). - **People publishes no multi-DC or domain-specific-API-URL page**, unlike CRM, Recruit, Mail, Meeting, Sign and Bookings. The host pattern in §4 is derived from the documented US host plus live probing, not from a Zoho table. - **Authentication failures on the forms family arrive as HTTP `400`, not `401`.** Zoho's own status-code page defines `400` as "The request **or the authentication** considered is invalid" (§5). If the People docs do not look like this, stop and report what you actually see. ## 1. The data model is forms, and the customer owns it Zoho People has no fixed object catalogue. Everything — employees, departments, designations, assets, exit interviews, HR cases — is a **form**, and forms are things the customer creates and configures. `GET /api/forms` returns the account's list, each entry carrying `displayName`, an `iscustom` flag, an `isVisible` flag, a `PermissionDetails` block with separate `Add` / `Edit` / `View` levels, and — the field that matters — a **`formLinkName`**. Every record call is addressed by that link name: `/api/forms/{formLinkName}/getRecords`, `/api/forms/json/{formLinkName}/insertRecord`, `/api/forms/{formLinkName}/getDataByID`. Zoho's admin guide is explicit about why it exists: *"The Form Link name is a unique name associated with each form that helps during integration. While integrating with third-party applications, you may have more than one form with the same name. In this case, the system will take the Form Link Name to sync with third-party applications."* The customer types that value in when they create the form. Four consequences, and they are the whole reason this skill exists: - **Scope granularity stops at `forms`.** There is no `ZOHOPEOPLE.forms.employee.READ`. Zoho's own Insert Record page demonstrates the scope `ZOHOPEOPLE.forms.CREATE` against a form called `test_form`, and the Adding Employees page demonstrates the *same string* against `employee`. One grant, every form. **So a connector that only needs department names is asking for the same access as one that reads salary history**, and a customer's security review will read it that way. There is no narrower ask available to offer them. - **What the scope yields is decided by permissions you cannot see at registration.** Form-level permissions are set per role, and Zoho documents a separate field-level layer on top: *"To configure permissions for fields within a form, go to the corresponding service settings page and navigate to Permissions > Field Permissions."* Roles range from a team member limited to "My Data" up to an administrator with all data. The error codes exist specifically for this — `7040` permission denied to view records, `7038` to add, `7039` to edit, `7037` for the action generally. **A correct scope plus a restricted authorizing user is the single most common People support ticket, and it looks exactly like a broken integration.** - **Discover forms at run time.** The list is per-customer and includes custom forms you have never seen. Hardcoding link names builds against your own trial account; `7011 Invalid form name` is what the next customer gets. The `isVisible` and `PermissionDetails` values on each entry tell you what is actually usable — read them rather than attempting every form and interpreting the failures. - **Standard forms are not immutable.** A customer can clone a system form, add fields, or extend a service, and the extra fields come back in the record payload as ordinary keys. A mapping that assumes a fixed field set is a mapping against one org. **Ask the customer for a form and field permission map before promising a field.** It is a five-minute question and it is the difference between a scoped integration and an open-ended one. ## 2. The scope families — and why the scope table is not the authority Service name `ZOHOPEOPLE`, capitalised. Operation types `CREATE`, `UPDATE`, `READ`, `DELETE`, `ALL`. The published table lists seven families: | Family | Documented operation types | Reaches | | --- | --- | --- | | **`forms`** | `ALL`, `CREATE`, `READ`, `UPDATE` | Every form and every record in it — employees, departments, designations, assets, custom forms (§1) | | **`leave`** | `ALL`, `READ`, `CREATE`, `UPDATE` | Leave requests, leave types, balance and booked reports | | **`attendance`** | `ALL` (plus `CREATE` and `UPDATE` on endpoint pages) | Check-in/out, entries, bulk import, regularization | | **`timetracker`** | `ALL` (plus `READ` on endpoint pages) | Timesheets, jobs, projects, job schedules | | **`employee`** | `ALL` | "Employee related operations" — no endpoint page found that requires it (see "Unverified") | | **`dashboard`** | `ALL` | "Dashboard related operations" | | **`automation`** | `ALL` | "Automation related operations" | **The table is incomplete, and the page admits it.** Under "Scope name" the scopes page says: *"It may vary based on the API you are using. You can find the appropriate scope on the corresponding API sample URLs in our help documentation."* That is Zoho naming the endpoint page as the source of truth over its own table. Treat it that way. Two families verified in the docs are absent from the table entirely: - **`ZOHOPEOPLE.organization.READ`**, required by the Get Organization Info API — the call that returns the org's date format (§4), which a great many integrations cannot function without. - **`ZOHOPEOPLE.hrprocess.READ`**, required by the v3 Get HR Process API. And the table's own example text contradicts its own rows: it illustrates the grammar with `ZOHOPEOPLE.timesheet.READ` and then lists the family as **`timetracker`**. The Get Job Schedule endpoint page resolves it — `ZOHOPEOPLE.timetracker.ALL (or) ZOHOPEOPLE.timetracker.READ`. **Use `timetracker`.** Four things to get right: - **Take the string from the endpoint page you will call.** Not from the table, not from another product's skill, not from a connector you found. Every People endpoint page states its scope at the top; that is the contract. - **Write does not imply read, and most write flows read.** `ZOHOPEOPLE.forms.CREATE` creates a record and returns an identifier — nothing else. Any flow that confirms the write, re-fetches the created record, or resolves a lookup value needs `ZOHOPEOPLE.forms.READ` in the *same* consent. A scope set built as "write" alone authorizes cleanly and then fails on the read-back with a permission error that reads like a role problem. - **Most families publish only `.ALL`.** Only `forms` and `leave` document the full operation spread. For `attendance` and `timetracker` the narrower strings exist on endpoint pages but not in the table, so a read-only ask for those areas is something to verify against the endpoint page before promising it to a customer. - **There is no full-access scope to reach for.** `ZOHOPEOPLE.forms.ALL` is the nearest thing and it is a very large ask: create, read, update and delete on every form in the HR system. Where read is enough, say `READ`. ## 3. Three API generations, none of them in the base path Zoho People serves three endpoint families concurrently, from one host, with no version segment in the part you register: | Generation | Shape | Example | Scope stated on the page? | | --- | --- | --- | --- | | **Legacy forms** | `/api/forms/…`, `/api/forms/json/…/insertRecord`, `/api/attendance/…`, `/api/timetracker/…` | `/api/forms/employee/getRecords` | Usually yes | | **v2 leave tracker** | `/api/v2/leavetracker/…` | `/api/v2/leavetracker/leaves/records` | **No** | | **v3** | `/api/v3/…` | `/api/v3/leave-tracker/leaves`, `/api/v3/attendance/entries/{id}`, `/api/v3/organization` | Yes | What to know before you pick one: - **The v3 documentation tree is not a v3 API.** `…/people/api/v3/overview.html` is word-for-word identical to the unversioned overview and does not mention a version, a base path, or a migration. Only the individual v3 endpoint pages are genuinely new. There is no v3 index that tells you which calls have a v3 form. - **The two leave generations differ in spelling as well as shape.** v2 is `leavetracker`, v3 is `leave-tracker`; v2 takes `from` / `to` / `startIndex` / `dataSelect`, v3 takes `from_date` / `to_date` / `offset` / `data_select` and adds sorting and department, location and leave-type filters. **These are not aliases.** A hyphen typed into a v2 path is a 404, and a v2 parameter name sent to v3 is a missing-mandatory-parameter error. - **v2 pages state no scope at all.** Both Zoho pages documenting the v2 leave records endpoint omit the Scope line that every other People page carries. The v3 equivalent states `ZOHOPEOPLE.leave.READ`, and that is the string to use — but note you are inferring it, which is the one place in this file where the endpoint page could not be the authority. - **`dataSelect` defaults to `MINE` on the v2 leave endpoint.** So an integration that authorizes as an HR admin and forgets the parameter reads *that admin's own leave* and nothing else. It returns `200`, it returns records, and it is wrong. v3's `data_select` defaults to `ALL` instead — a silent behaviour change between generations on the same logical call. Set it explicitly on both. - **Pin the generation per call family and write it down.** There is no single version to pin, so "which version is this integration on?" has no one answer; it has one answer per endpoint family, and that is what has to be recorded. **No Zoho People end-of-life or deprecation notice was found.** A "V2 API end-of-life extended to 31 December 2026" announcement circulates widely and is signed by **the Zoho Projects team** — it is a Zoho Projects notice and says nothing about Zoho People. Do not repeat it as a People fact. ## 4. Hosts, paths, and the organization date format ### The host `api_domain` gives you is the wrong one The token response's `api_domain` is the generic Zoho APIs host. **Zoho People is not served there.** Every People endpoint page documents the product host `people.zoho.{domain}`, and calling the generic host with a People path does not reach the product. The People hosts verified live on 2026-09-20 — all ten answered a v3 request with a Zoho People JSON auth error, proving the product is served there: ``` people.zoho.com people.zoho.eu people.zoho.in people.zoho.com.au people.zoho.jp people.zohocloud.ca people.zoho.sa people.zoho.uk people.zoho.ae people.zoho.com.cn ``` `people.zoho.ca` does not resolve. **Zoho publishes no People multi-DC table**, so this list is empirical: derive the People host from the callback's data-centre key, keep the mapping in one place, and **fail loudly on a key you do not recognise rather than defaulting to the US host** — a silent US default sends a European or Emirati customer's HR data requests to a data centre their account does not exist in, and the result is an authorization error that nobody reads as a region problem. ### `/people/api/` and `/api/` are the same thing Zoho's docs are inconsistent within single pages: the Fetch Forms page gives the Request URL as `https://people.zoho.com/people/api/forms` and the sample request as the same, while the Organization, Insert Record and v2 leave pages give `https://people.zoho.com/api/…`, and the v3 leave page prints both forms — the documented URL with `/people` and the sample without. **Verdict: both work.** Probed unauthenticated on 2026-09-20, `/people/api/forms` and `/api/forms` returned the identical error body, and that body echoes the path the server resolved: `"uri":"/api/forms"` in both cases. The same held for `/people/api/v3/organization` versus `/api/v3/organization` and for the v2 leave path. The server normalises the `/people` prefix away. Pick one, use it consistently, and do not treat a doc page's prefix as meaningful. ### Every date is in the organization's format This is the People gotcha with no analogue in the sibling products. Date parameters are not ISO — Zoho's attendance page says *"Specify the date in organisation date format"* and the v3 attendance page says punch times are *"in organization date and time format"*. The format is a per-organization setting. - **Read it once and cache it on the connection.** `GET /api/v3/organization` returns the org's `DateFormat`; the call needs `ZOHOPEOPLE.organization.READ` (§2) and carries a threshold of 20 requests. - **It is a Zoho format token string, not your date library's.** Expect values like `dd/MM/yyyy`; translating those tokens into whatever your platform parses with is a real conversion step, not a copy. - **Ambiguous dates are the failure mode.** `03/04/2026` parses without error in either convention and is wrong in one of them. Nothing errors; a record lands in the wrong month. Several endpoints accept an explicit `dateFormat` parameter — use it wherever it exists rather than relying on the org default. - **A non-admin authorizing user may not be able to read the org record at all.** Plan a fallback format and treat the lookup as best-effort, not as a connect-time hard dependency. ## 5. Limits, and the error shapes People meters **calls**, not credits. Two independent mechanisms. **A per-plan daily cap, plus a per-user-licence allowance:** | Plan | Allowance | Daily ceiling | | --- | --- | --- | | Essential HR | 250 calls / user licence | 5,000 calls/day | | Professional | 250 calls / user licence | 10,000 calls/day | | Premium | 250 calls / user licence | 15,000 calls/day | | Enterprise | 500 calls / user licence | 25,000 calls/day | There is no free tier here — the API is not part of the plans below Essential HR at all. **A per-endpoint threshold with a lock period**, printed on each endpoint page as *"Threshold Limit: N requests | Lock period: M minutes"*, and defined there as *"Threshold Limit - Number of API calls allowed within a minute. Lock Period - Wait time before consecutive API requests."* Observed values on 2026-09-20: | Endpoint | Threshold / minute | Lock | | --- | --- | --- | | Get Organization Info; v3 Edit Attendance Entry | **20** | 5 minutes | | Fetch Forms; Fetch Record; Get Related Records; Custom Views; v3 Get Leave Requests; v3 Get HR Process | **30** | 5 minutes | | Get Job Schedule | **50** | 5 minutes | | Insert Record | **100** | 5 minutes | | Update Record; Adding Employees | **300** | 5 minutes | | Bulk Records (`getRecords`); Search Records; Fetch Single Record | **400** | 5 minutes | Read that as a design brief, because it inverts the usual intuition: - **The throttle is per endpoint, so a total call budget tells you nothing.** Paging bulk records at 400/min is comfortable; looping the same volume through Fetch Forms at 30/min is not. The binding constraint is whichever single endpoint your sync leans on hardest. - **Metadata calls are the tightest.** Fetch Forms is 30/min and Get Organization Info is 20/min — the two calls a connector makes to *discover* what it is working with. Cache both on the connection; re-deriving them per sync is how a connector locks itself out of the calls it needs before it reads a single record. - **The penalty is a lock, not a retry-after.** Five minutes of waiting, not a slot freeing up next second. A tight retry loop against a locked endpoint does not recover; it extends the outage. - **`429` is ambiguous.** Zoho's status-code page defines it as *"The number of API requests for the 24 hour period is exceeded **or** the concurrency limit of the user for the app is exceeded"* — but the limits page publishes no concurrency number, and neither does any endpoint page. So a `429` may be the daily cap, the per-endpoint threshold, or an unpublished concurrency ceiling, and the response does not say which. ### Three error envelopes from one product People does not have one error shape. Probed on 2026-09-20, the same host returned three: | Family | Shape | Status | | --- | --- | --- | | Legacy forms, timetracker | `{"response":{"message":…,"uri":…,"errors":{"code":7202,…}}}` | **400** | | v3 | `{"code":"INVALID_AUTHTOKEN","message":…}` | **401** | | v2 leave tracker | `{"error":{"code":7019,…},"uri":…}` | **400** | **An expired or wrong token on the forms family is an HTTP `400`, not a `401`.** Zoho's status-code page states it as design: `400` is *"The request or the authentication considered is invalid."* Any error handler that treats `401` as "refresh the token" and `400` as "the caller sent something wrong" will retry a dead token forever on the forms family and never refresh it. Key off the `7202` code, not the status. The People error-code list is worth reading once in full; the ones that matter for an OAuth conversation are `7202` invalid auth token, `7037`/`7038`/`7039`/`7040` the permission-denied family (§1), `7011` invalid form name, `7019` missing parameter, and `7024` no records found. ## Product fact — what the People connector asks for > **As of 2026-09-20, Unified.to's Zoho People connector requests** a per-unified-object scope set rather than one > flat list, and all five strings are real — but two of them are not on Zoho's scope table: > > - **Employee, department and designation reads:** `ZOHOPEOPLE.forms.READ` plus `ZOHOPEOPLE.organization.READ`. > The second is the date-format lookup (§4) and is **absent from both published scope tables** — it appears only > on the Get Organization Info endpoint page. > - **Employee, department and designation writes:** `ZOHOPEOPLE.forms.CREATE` **alone**, with no read operation. > - **Leave:** `ZOHOPEOPLE.leave.READ` plus `ZOHOPEOPLE.organization.READ`. > - **Time tracking:** `ZOHOPEOPLE.timetracker.READ` — **also absent from the scope tables**, which list only > `timetracker.ALL`; it is documented on the Get Job Schedule page (§2). > - **No full-access scope is requested**, consistent with §2: People does not document one. > > Behaviour worth knowing, all of it consistent with this file: > > - It takes OAuth **client ID and secret per connection** rather than operating one platform-wide Zoho app, so each > customer's People account can be wired to its own console client. > - It resolves the API host to the `people.zoho.*` product host (§4) rather than trusting the generic host from the > token response, and it re-resolves after every refresh — the refresh response carries the generic host and > would otherwise overwrite a correct one. > - It reads and caches the organization's date format at connect time and **soft-fails** when the authorizing user > cannot read it, falling back to a fixed format (§4). > - It sends the leave endpoint's data selector explicitly rather than accepting the `MINE` default (§3). > - It treats a successful HTTP status with a business-error body as a failure on the forms family, which is the > right shape given §5. > > Three things to raise with the connector's owner rather than copy: > > 1. **The write scope set has no read operation in it.** A consent requested for writes alone carries > `ZOHOPEOPLE.forms.CREATE` and nothing else, while the create flow re-fetches the new record to return it. That > read needs `ZOHOPEOPLE.forms.READ` in the same grant (§2). It is invisible whenever a read set was requested > alongside — which is the common case, and is why it survives. > 2. **The data-centre map omits the UAE**, which is live for Zoho People (§4), and an unmapped region falls through > to the **US host** rather than failing. That is the silent-wrong-region failure §4 warns about. > 3. **Credentials fall back to another Zoho product's app** when per-connection credentials are absent, rather than > refusing. A token minted under a client whose consent was granted for a different Zoho service is not a People > token, and the resulting failure names neither the client nor the product. > > Scope sets change. This note is dated, not live; confirm the current set with the connector's owner before > submitting anything. ## 6. People-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Call `GET /api/forms` and **look at the response, not just the status**. It proves the `forms` scope, and it gives you the account's real `formLinkName` list, `iscustom` flags and `PermissionDetails` (§1). 2. Read one record from each form the integration claims, **by link name**, on an account with at least one custom form. A trial org with only stock forms has not tested §1. 3. **Authorize as a restricted, non-admin user** and confirm you can tell a permission failure (`7037`–`7040`) apart from a scope failure. This is the test that predicts your support load. 4. Call `GET /api/v3/organization`, confirm the date format comes back, and confirm your code converts the Zoho format tokens rather than passing them through (§4). 5. Send one date parameter in the **wrong** convention deliberately and confirm you notice. If nothing errors, the integration has an ambiguity bug it will not report (§4). 6. Exercise **each endpoint generation** the integration uses — a legacy forms call, a v2 leave call, a v3 call — and confirm the parameter names and the leave data selector are right for each (§3). 7. Confirm the API host is `people.zoho.{domain}` and not the host the token response named (§4). 8. Hammer one **metadata** endpoint past 30 calls in a minute and confirm you get the five-minute lock and back off rather than retrying (§5). 9. Present a stale token to a **forms** endpoint and confirm the handler refreshes on the `400`/`7202` pair rather than only on `401` (§5). | Symptom | Cause | | --- | --- | | Auth succeeds, every forms call is `400` with code `7202` | The token is invalid or expired — People reports auth failures as `400` on this family (§5) | | Handler never refreshes an expired token on forms calls | It keys off `401`; the forms family returns `400` (§5) | | Auth succeeds, a form returns `7011 Invalid form name` | Hardcoded link name; this customer's form is named differently or does not exist (§1) | | Auth succeeds, records come back empty or `7040` | The authorizing user's role, or form/field permissions — not the scope (§1) | | Some fields are missing for one customer only | Field Permissions on that form, or a form the customer customized (§1) | | Create works, then the read-back fails | Write scope requested without `forms.READ` in the same consent (§2) | | `Invalid OAuth scope` on a scope you read in Zoho's docs | Taken from the scope table rather than the endpoint page — or `timesheet` used where the family is `timetracker` (§2) | | Leave list returns only the connecting admin's own leave | v2 `dataSelect` left at its `MINE` default (§3) | | A leave endpoint 404s | `leavetracker` versus `leave-tracker` — the v2 and v3 paths are spelled differently (§3) | | v3 call rejects a parameter that works on v2 | v3 renamed them (`from_date`, `offset`, `data_select`) (§3) | | Token valid, People path does not answer | Calling the generic Zoho APIs host instead of `people.zoho.{domain}` (§4) | | Works for US customers, fails for one region | That region is missing from the host map and fell back to the US host (§4) | | Dates land in the wrong month, no error | Organization date format not read, or read and not converted (§4) | | Requests start failing for five minutes at a time | A per-endpoint threshold lock, not the daily cap — and retrying extends it (§5) | | `429` with no further detail | Could be the daily cap, the endpoint threshold, or an unpublished concurrency limit; Zoho does not distinguish (§5) | ## Stop and ask Beyond the base skill's list, hand back to a human when: - A customer's security review objects to `forms` access and somebody wants a narrower scope offered — **there is none** (§1), and that is a product conversation, not a console setting. - Nobody has a form and field permission map from the customer and someone wants a field-level commitment (§1). - A scope string cannot be found on any endpoint page, only inferred from the table or from another integration (§2) — the `employee`, `dashboard` and `automation` families are all in this state. - The integration will create or update and the scope set has not been checked for a matching read (§2). - The endpoint generation for a call family has not been decided, or a customer is on an older one (§3). - Customers exist in a data centre that is not in the host map, and somebody proposes defaulting rather than failing (§4). - A **Zoho People** deprecation date is being cited — verify it against a Zoho People page first; the widely circulated one belongs to a different Zoho product (§3). - Sync volume must be committed to and nobody has mapped it onto the **per-endpoint** thresholds rather than the daily cap (§5). - The customer is below Essential HR — they do not have API access to sell you (Platform state). ## Unverified as of 2026-09-20 - **No endpoint page requiring `ZOHOPEOPLE.employee.*`, `ZOHOPEOPLE.dashboard.*` or `ZOHOPEOPLE.automation.*` could be found.** All three are on the scope table with a one-line description and nothing else. They may be live, or historical; do not build a consent screen around them without testing. - **The v2 leave endpoints publish no scope.** `ZOHOPEOPLE.leave.READ` is inferred from the v3 equivalent and from the Booked and Balance report, which does state it (§3). - **No Zoho People API deprecation, sunset or end-of-life date exists on a Zoho People page.** Three generations serve concurrently with no migration notice (§3). - **Zoho documents no concurrency number for People**, although the status-code page names a concurrency limit as a cause of `429` (§5). - **Whether the People Sandbox is reachable by API is undocumented.** Zoho describes it as Enterprise-only, replicating configuration and optionally "the first 30 records from the selected forms", deployed to production through a change tracker — but says nothing about an API host, a separate token, or whether OAuth reaches it. - **Zoho publishes no People multi-DC page.** The host list in §4 is live-probed, not documented. ## References People-specific only; the base skill carries the generic Zoho OAuth references. Verified to resolve on 2026-09-20. - API overview (plan availability) — https://www.zoho.com/people/api/overview.html - V3 API overview (identical text, no version stated) — https://www.zoho.com/people/api/v3/overview.html - OAuth steps (authorize and token URLs, comma-separated scopes) — https://www.zoho.com/people/api/oauth-steps.html - **OAuth scopes table** — https://www.zoho.com/people/api/scopes.html - **OAuth scopes table (V3 tree)** — https://www.zoho.com/people/api/v3/scopes.html - **API limits (per-plan daily cap and per-licence allowance)** — https://www.zoho.com/people/api/api-limits.html - **Status codes (400 covers authentication; 429 is ambiguous)** — https://www.zoho.com/people/api/status-codes.html - **Error codes (7202, 7037–7040, 7011, 7019)** — https://www.zoho.com/people/api/error-codes.html - **Fetch Forms API (`formLinkName`, `iscustom`, `PermissionDetails`)** — https://www.zoho.com/people/api/forms-api/fetch-forms.html - Get Bulk Records (`/forms/{formLinkName}/getRecords`, 200-record cap) — https://www.zoho.com/people/api/bulk-records.html - Search Records — https://www.zoho.com/people/api/forms-api/search-record.html - Insert Record (same `forms.CREATE` scope against a custom form) — https://www.zoho.com/people/api/insert-records.html - Update Record — https://www.zoho.com/people/api/update-records.html - Adding Employees (same scope against a stock form) — https://www.zoho.com/people/api/adding-employees.html - **Get Organization Info (`ZOHOPEOPLE.organization.READ`, the date format)** — https://www.zoho.com/people/api/Organization/Get-details-api.html - Fetch Leave Records API V2 (`dataSelect` defaults to `MINE`, no scope stated) — https://www.zoho.com/people/api/get-records-v2.html - Get Leave Record API (the same v2 endpoint, documented at a different path) — https://www.zoho.com/people/api/get_record.html - **Get Leave Requests API v3 (`leave-tracker`, renamed parameters)** — https://www.zoho.com/people/api/v3/leave-tracker/get-leave.html - Leave Booked and Balance Report — https://www.zoho.com/people/api/leave/reports/bookedandbalance.html - Attendance Entries (organisation date format) — https://www.zoho.com/people/api/attendance-entries.html - Edit Attendance Entry v3 — https://www.zoho.com/people/api/v3/attendance/entries-edit.html - Get Job Schedule (`ZOHOPEOPLE.timetracker.READ`) — https://www.zoho.com/people/api/timesheet/get-job-schedule.html - Get HR Process v3 (`ZOHOPEOPLE.hrprocess.READ`) — https://www.zoho.com/people/api/v3/get-hrprocess.html - **Creating and editing forms (Form Link Name, custom forms)** — https://www.zoho.com/people/help/adminguide/permissions-forms.html - Setting up permissions (form-level and field-level) — https://help.zoho.com/portal/en/kb/people/administrator-guide/common-settings/articles/setting-up-permissions-zoho-people - Zoho People Sandbox (Enterprise only, configuration + 30 records) — https://help.zoho.com/portal/en/kb/people/administrator-guide/operations/data-administration/articles/sandbox ## zoho-recruit-oauth-app URL: https://docs.unified.to/skills/zoho-recruit-oauth-app/SKILL.md # Zoho Recruit OAuth2 App Registration The client registration is the base skill's job. What is specific to Recruit is that **its documentation contradicts itself more than any other Zoho product's**, in exactly the two places that decide whether a connection works: the spelling of module scope names, and which host the API lives on. Both fail with errors that point somewhere else. Beyond that, Recruit is an ATS with two quite different shapes — staffing agency and in-house corporate HR — and the module set differs between them. A scope list that assumes Clients and Vendors exist is a scope list written for an agency, and it will look broken on a corporate customer. ## Built on: `zoho-api-console-oauth` **Read `zoho-api-console-oauth` first, then this file.** It owns all the console and protocol mechanics, and this file does not repeat them: - The Zoho API Console, client types, and why a multi-tenant connector needs **Server-based**, not Self Client. - **The data-centre model** — `location` / `accounts-server` on the callback, the per-DC accounts hosts, the Multi DC toggle, and the rule that **one client ID serves every DC while the client secret is per-DC by default**. - Redirect-URI rules and the platform's per-data-center callback list. - The generic scope grammar `service.scope.OPERATION`, and that scopes are **comma-delimited**. - `access_type=offline` plus `prompt=consent`, and the **20-refresh-tokens-per-user-per-client** cap that silently revokes the oldest. - The `Authorization: Zoho-oauthtoken {token}` header form. - Capturing and rotating the secret, and limits being a per-organization concept. If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the console inputs. These are the Recruit-specific ones: | Input | Notes | | --- | --- | | **Staffing agency customers, corporate HR customers, or both?** | Decides which modules exist to scope (§3) | | **Which Recruit modules must the integration reach?** | Candidates and Job Openings are rarely the whole list (§2) | | **Read-only, or read-write?** | `.READ` versus `.ALL` per module (§1) | | **Are custom modules in scope?** | Needs its own scope and run-time discovery (§3) | | **Does it need field metadata?** | A separate scope family, and almost always yes (§1) | | **Does it need resumes or attachments?** | Confirm against the endpoint's own documented scope (§3) | | **What edition are the target customers on?** | Sets their credit ceiling (§5) | ## Quick Start 1. Work the base skill: Server-based client, redirect URIs, Multi DC, secrets. 2. Settle the module list with the user, remembering it depends on the customer's edition (§3). 3. Build the scope set from §1 — and **copy the module-name spellings from the endpoint you will call**, not from the scopes overview (§1 is about why). 4. Add the field-metadata scope; a connector that maps fields needs it (§1). 5. Decide the API host deliberately: the dedicated Recruit domain or `zohoapis` (§4). This is a real fork. 6. Size the sync against the credit table, noting that a plain record read costs three (§5). 7. Run the base skill's round trip, plus the Recruit checks in §6. ## Recruit platform state (verified 2026-09-20 — re-verify before trusting) - **Current API version is v2**, at `/recruit/v2/…`. There is no v3 documentation tree. - **Two host families are documented, both by Zoho.** The Recruit v2 announcement gives a **dedicated domain** `https://recruit.zoho.com/recruit/v2/` and its per-DC siblings; the Recruit multi-DC page gives `https://www.zohoapis.eu/recruit/v2/Candidates`; and the token response's `api_domain` is a `zohoapis` host. §4. - **Recruit's own docs give three different data-centre lists.** The v2 announcement names US, EU, CN, IN, JP — no Australia. The multi-DC page names US, AU, EU, IN, CN, JP — no Canada or Saudi Arabia. The access-and-refresh-token page names US, AU, EU, IN, CA, SA, CN, JP. **Use the base skill's list and `serverinfo`**, not any of these. - **Module scope names appear in both singular and plural forms in Zoho's own examples**, and the service name appears as both `ZohoRecruit` and `ZohoRECRUIT`. §1. - **A Recruit-specific token limit:** *"You can only generate a maximum of five refresh tokens in a minute."* This is on top of the base skill's 20-per-user cap, not instead of it. - Limits are **credits** in a rolling 24-hour window, plus concurrency — and Recruit's concurrency is documented **per user per app**, not per org (§5). If the Recruit docs do not look like this, stop and report what you actually see. ## 1. The Recruit scope families — and the spelling problem Service name `ZohoRecruit`; operation types `ALL`, `READ`, `CREATE`, `UPDATE`, `DELETE`. Six families: | Family | Reaches | Documented members | | --- | --- | --- | | **`modules`** | Record data | `modules.all` plus one per module (see §2) | | **`settings`** | Configuration and metadata | `settings.all`, `settings.custom_views`, `settings.related_lists`, `settings.modules`, `settings.fields`, `settings.layouts` | | **`users`** | The account's users | `users.all` | | **`org`** | Organization details | `org.all` | | **`bulk`** | Bulk read/write APIs | `bulk.all`, `bulk.read`, `bulk.create` | | **`notifications`** | Change notifications | `notifications.read`, `.create`, `.update`, `.delete` | As in CRM: **field metadata is a separate scope** (`ZohoRecruit.settings.fields.…`) and is not implied by any module scope. A connector that maps Recruit fields to a unified model needs it, and will otherwise authorize cleanly and fail on the metadata call. ### The inconsistency, stated plainly Recruit's documentation spells module scope names three different ways, on pages that are all current: - The **OAuth overview's scope table** lists them **singular**: `modules.candidate`, `modules.jobopening`, `modules.client`, `modules.contact`, `modules.interview`, `modules.application`. - The **same page's worked examples** use **plural**: `ZohoRecruit.modules.candidates.ALL`, `ZohoRecruit.modules.jobopenings.ALL`. - The **Get Records page** lists them **singular** again — `candidate, application, jobopening, interview, client, contact, review, department, task, event, vendor, campaign, submission, custommodule` — and writes the service name as **`ZohoRECRUIT`**. - The **Modules API page** cites a scope in a fourth vocabulary entirely: `ZohoRecruit.setup.operation.all`, where neither `setup` nor `operation` appears in any scope table. Operation-type casing is equally mixed: `.ALL`, `.all`, `.READ`, `.read` all appear in Zoho's examples. **How to work with this rather than around it:** 1. **Take the spelling from the documentation page for the endpoint you are actually calling.** Each Recruit API page states its required scope at the top; that is closer to the implementation than the overview table. 2. **When a scope is rejected, try the other number and the other casing before concluding the scope does not exist.** `Invalid OAuth scope` does not say which member of a comma-separated list was wrong, so bisect (base skill §6). 3. **Test the exact string.** A scope set that has not been through a real authorize call is a hypothesis. This is the product where that matters most. 4. **`modules.all` sidesteps the whole problem** — at the cost of requesting every module. That is a real trade and §2 has the terms. ## 2. Modules: the ATS object model The documented module scope names, from the OAuth overview's table: `modules.all`, `candidate`, `client`, `contact`, `jobopening`, `campaign`, `task`, `event`, `call`, `interview`, `custom`, `notes`, `assessment`, `candidatestatus`, `jobopeningstatus`, `todo`, `custommodule`, `vendors`, `application`, `referral`, `department`, `assessment_submission`, `forecast`. The ATS-shaped ones, and what an integration usually needs: - **Candidates** — the people. The core object, and the one with the most custom fields in any real deployment. - **Job Openings** — the requisitions. API name `Job_Openings`, so the plural-with-underscore form, not the label. - **Applications** — the association between a candidate and a job opening. **This is a module in its own right**, and it is the one people forget. A connector scoped to candidates and job openings has both endpoints of the relationship and not the relationship: no pipeline, no stage, no per-requisition status. - **Interviews** — scheduled interviews, distinct from Events. - **Candidate Statuses** and **Job Opening Statuses** — the status vocabularies. If the product surfaces a pipeline stage as anything other than a raw string, it needs these, and they are separate scopes. - **Assessments** and **Assessment Submissions** — separate modules, separately scoped. - **Clients**, **Contacts**, **Vendors** — the agency-side objects (§3). - **Departments**, **Referrals**, **Campaigns**, **Tasks**, **Events**, **Calls**, **Notes**, **To-Dos**, **Forecast** — the rest of the surface. **Address everything by `api_name`.** Recruit generates an API name internally for every module, field and related list, precisely so that a customer relabelling something does not break integrations. Standard modules' API names cannot be changed; custom ones can. **Discover the module list at run time.** `GET /settings/modules` returns the modules for that account, each with flags worth reading before you assume anything: `creatable`, `deletable`, `editable`, `convertable` (for example, whether Candidates can be converted into Contacts), `generated_type` (default, web, custom, linking) and **`api_supported`**, which Recruit documents as `false` for modules *"currently not accessible by APIs"*. That last one has no analogue in a scope list: a module can exist, be scoped, and still not be reachable. **Custom modules** need `modules.custommodule` (or `modules.custom`, per the overview table — the two names appear in different places, which is §1 again) or `modules.all`. As in CRM, you cannot scope a *specific* custom module in advance, because you do not know its API name until the customer connects. ## 3. Staffing agency versus corporate HR Zoho markets Recruit in two shapes, and they are not the same product from an integration's point of view: - **Staffing agency** — the recruiter works for many client companies. Clients, client contacts, vendors, submissions and placements are first-class. - **Corporate HR / in-house** — the recruiter works for one company, hiring into it. There is no client; the hiring-manager relationship is internal. Consequences: - **`modules.client`, `modules.contact` and `modules.vendors` are agency concepts.** Requesting them against a corporate customer is at best noise on the consent screen and at worst a module that returns nothing while your code waits for it. - **Zoho does not publish a module-availability table by edition** that could be verified on 2026-09-20. So the module-discovery call in §2 is not a nicety — **it is the only reliable way to know what this customer has.** Build the integration to enumerate, not to assume. - **Decide which shape the product targets before choosing scopes**, and say so in the handoff. "Supports Zoho Recruit" means something different to an agency and to an in-house team. **Resumes and attachments** are the other ATS-specific surface, and the one to be most careful about: they are the highest-value data in an ATS and the most sensitive. Confirm the exact scope from the attachment endpoint's own documentation page before requesting anything — a general attachments page could not be located under the v2 guide on 2026-09-20 (see "Unverified"), so do not infer the scope from a module name. ## 4. The API host — a genuine fork Zoho documents two different hosts for the same Recruit API, and the token response suggests a third answer: | Source | Host | | --- | --- | | Recruit "What's New in API v2" | `https://recruit.zoho.com/recruit/v2/` — *"a dedicated domain … helpful in serving CORS requests"*, with `recruit.zoho.eu`, `recruit.zoho.in`, `recruit.zoho.jp`, `recruit.zoho.com.cn` | | Recruit Modules API and Get Records pages | `https://recruit.zoho.com/recruit/v2/…` | | Recruit Multi DC page | `https://www.zohoapis.eu/recruit/v2/Candidates`, `https://www.zohoapis.com.cn/recruit/v2/Candidates` | | The token response | `api_domain`, a `zohoapis` host | The base skill's general rule — *take the host from `api_domain`* — is the right default for most Zoho services and is **contradicted by Recruit's own endpoint documentation**, which uses the dedicated domain throughout. What to do: - **Pick one, deliberately, and record why.** Both appear in current Zoho documentation. This is not a case where one is obviously stale. - **Map the DC to the host yourself if you use the dedicated domain**, because `api_domain` will not give you a `recruit.zoho.*` value. That means maintaining a DC-to-host map, with Recruit's three conflicting DC lists as the hazard (Platform state) — resolve it against the base skill's list and `serverinfo`, not Recruit's pages. - **The failure is quiet.** A valid token against the wrong host is not an authorization error; it is a 404, an unexpected redirect, or an empty result, and it gets misdiagnosed as a scope problem every time. - Whichever you choose, **store the resolved host on the connection** rather than reassembling it per call. ## 5. Credits, and concurrency counted per user Recruit meters **credits** in a rolling 24-hour window, like CRM but with its own table: | Edition | Allowed credits per 24h | Ceiling | | --- | --- | --- | | Free | 5,000 | 5,000 | | Standard | 5,000 + (users × 250) + add-ons | 100,000 | | Professional | 10,000 + (users × 500) + add-ons | 500,000 | | Enterprise / Zoho One / People Plus | 15,000 + (users × 1,000) + add-ons | 1,000,000 | Credit costs (1 unless listed): | Operation | Credits | | --- | --- | | Get Users / Roles / Profiles; module list; field metadata; module metadata | 1 | | Get IDs of deleted records | 2 | | **Records fetched with a custom-view id** | **3** | | Add / Remove tags | 1 per 50 records | | Insert / Update | 1 per 10 records (max 100 per call, so max 10) | | **Bulk Read Initialize** | **50** | | **Bulk Write Initialize** | **500** | **Read the "Get Records API" line carefully.** Recruit's own limits page opens by saying *"for a 'Get Records API', 3 credits will be reduced for a single API call"*, while the cost table attributes the 3 credits to reads that use a custom-view id. The two readings differ by a factor of three for the most common call an ATS integration makes, and Recruit does not reconcile them. **Budget at 3 and be pleasantly surprised** — on the Free edition's 5,000, the difference is between roughly 1,600 and 5,000 reads a day. **Concurrency is documented per user per app**, not per org: 5 (Free) / 10 (Standard) / 15 (Professional) / 20 (Enterprise, Zoho One, People Plus), with a **sub-concurrency limit of 10** for all editions covering sorted or custom-view reads, multi-record writes, and searches invoked from functions. That is a meaningful difference from CRM, where the same limit is per org — but do not over-read it: the credit budget is still the account's, so parallelising across users buys concurrency, not allowance. Zoho states there is no per-minute rate limit; throughput is bounded by concurrency. ## Product fact — what the Recruit connector asks for > **As of 2026-09-20, Unified.to's Zoho Recruit connector requests**, for essentially every unified object it > supports — candidates, jobs, applications, application statuses, interviews, scorecards, documents, activities, > companies, contacts, groups, events — **the same three scopes**: > > - `ZohoRecruit.modules.all` > - `ZohoRecruit.users.READ` > - `ZohoRecruit.settings.fields.read` > > with a couple of variants escalating the users scope to `ZohoRecruit.users.ALL`. There is no per-module scoping. > > Its **identity/login probe** requests `ZohoRecruit.modules.candidate.ALL` — full read-write on the entire candidate > module merely to identify the connecting user. > > Four things to raise with the connector's owner rather than copy: > > 1. **`modules.all` for everything** takes §1's spelling problem off the table and puts "full access to all > recruiting data" on the consent screen instead. Given §3 — that the module set varies by edition and must be > discovered anyway — that is a defensible trade, but it is a trade, and a security-conscious customer will ask. > 2. **The requested strings mix casing** — `.all`, `.READ` and `.read` all appear across the set. That is Zoho's > inconsistency being mirrored rather than resolved, and it is a reason not to treat these strings as a canonical > reference. > 3. **A whole-module grant as a login scope** is worth questioning on its own terms. A narrower identity scope, if > one works, is a better consent screen. > 4. **It deliberately ignores the `api_domain` from the token response** and maps the customer's data centre to a > dedicated Recruit host instead — the §4 fork, resolved in favour of the dedicated domain. Its DC map also > carries a couple of region keys that are not Zoho DC codes, which is worth a look. > > Scope sets change. This note is dated, not live; confirm the current set with the connector's owner before > submitting anything. ## 6. Recruit-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Call `GET /settings/modules` and read the returned list — including each module's `api_supported` flag (§2). This is what tells you what this customer actually has. 2. Read one record from **Candidates** and one from **Job Openings** using their API names. 3. Read one **Application**, and confirm you can traverse it to both the candidate and the job opening. This is the test that catches a scope set covering the two ends and not the link (§2). 4. Read **field metadata** for Candidates. This fails when only module scopes were requested (§1). 5. Confirm which **host family** you are on (§4), and that the connection stores it. 6. If both staffing and corporate customers are in scope, test against **one of each** — or state plainly that only one shape has been proven (§3). 7. If custom modules are in scope, test against a real one, not a renamed standard module. 8. Sanity-check the sync's credit cost against §5, budgeting reads at 3 credits. | Symptom | Cause | | --- | --- | | `Invalid OAuth scope` on a scope string from the docs | Singular/plural or casing mismatch — try the other form (§1) | | Auth succeeds, field-metadata call fails | The field-metadata scope was not requested (§1) | | Candidates and jobs work; pipeline or stage is missing | Applications and the status modules are separate scopes (§2) | | A module returns nothing for one customer and data for another | Edition difference — agency versus corporate (§3) | | A module is listed but unreachable by API | Its `api_supported` flag is `false` (§2) | | Token is valid; calls 404 or return empty | Wrong host family — dedicated `recruit.zoho.*` versus `zohoapis` (§4) | | Works in the US, fails elsewhere | Data centre; use the base skill's list, not Recruit's three conflicting ones (§4, base §4) | | Refresh fails in bursts during onboarding | Five refresh tokens per minute, on top of the 20-per-user cap (Platform state, base §7) | | `TOO_MANY_REQUESTS` | Concurrency or sub-concurrency, not the daily budget — reduce parallelism (§5) | | Credits gone by mid-morning | Reads may be costing 3 each; check the pattern against §5 | ## Stop and ask Beyond the base skill's list, hand back to a human when: - It is unclear whether the product targets **staffing agencies, corporate HR, or both** (§3) — it changes the module list, the scope list and what "supported" means. - Someone wants `modules.all` replaced with per-module scopes without a plan for the spelling inconsistency and for custom modules (§1, §2). - The **host question** in §4 has not been settled and recorded — do not leave it to whichever page someone read last. - **Resumes or attachments** are in scope and the exact endpoint scope has not been confirmed from that endpoint's own page (§3). This is candidate PII; guessing is not acceptable. - A customer is hitting credit limits and the answer would be a volume or sync-frequency commitment (§5). ## Unverified as of 2026-09-20 - **The correct spelling of several module scope names.** Zoho's pages disagree (§1), and only a live authorize call settles it. Nothing here should be treated as the canonical list. - **Whether the 3-credit cost applies to all record reads or only to custom-view reads** (§5). Recruit's limits page supports both readings. - **Which modules exist in which edition.** No Zoho page publishing a module-availability table by edition could be located (§3). Discover at run time. - **The scope required for resume and attachment endpoints.** No attachments page could be located under the v2 developer guide (§3). - **Which host family Zoho considers canonical** (§4). Both appear in current documentation. ## References Recruit-specific only; the base skill carries the generic Zoho OAuth references. Verified to resolve on 2026-09-20. - Zoho Recruit API home — https://www.zoho.com/recruit/developer-guide/apiv2/ - **OAuth 2.0 overview (scope families and the module scope table)** — https://www.zoho.com/recruit/developer-guide/apiv2/oauth-overview.html - Register client — https://www.zoho.com/recruit/developer-guide/apiv2/register-client.html - Access & refresh tokens (per-DC accounts URLs, five-per-minute limit) — https://www.zoho.com/recruit/developer-guide/apiv2/access-refresh.html - Multi DC support — https://www.zoho.com/recruit/developer-guide/apiv2/multi-dc.html - **API limits (credits, concurrency per user, sub-concurrency)** — https://www.zoho.com/recruit/developer-guide/apiv2/limits.html - Modules API (`api_name`, `api_supported`, module discovery) — https://www.zoho.com/recruit/developer-guide/apiv2/modules-api.html - Get Records (module scope names, `ZohoRECRUIT` casing) — https://www.zoho.com/recruit/developer-guide/apiv2/get-records.html - What's New in API v2 (the dedicated `recruit.zoho.*` domain) — https://www.zoho.com/recruit/developer-guide/apiv2/whats-new.html ## zoom-oauth-app URL: https://docs.unified.to/skills/zoom-oauth-app/SKILL.md # Zoom OAuth2 App Registration Get a working Zoom OAuth2 client — a developer-enabled Zoom account, a **General app** on the Zoom App Marketplace, an OAuth allow list, a scope set, and a client ID and secret — for a platform that connects many customers' Zoom accounts. Three things about this portal cost more than they look. **Every app has two credential pairs**, development and production, and they are not interchangeable — half of all "invalid_client" reports are a development secret pointed at production traffic. **The app's management type (admin-managed vs user-managed) decides which scopes exist at all**, and changing it later resets the scope selection. And **an unreviewed app cannot serve customers**: a private app tops out at 10 account-level installs, a beta app's sharing link lives four weeks at a time, and only a *published* app — listed or unlisted, both of which go through Marketplace review — can be installed by the general public. The fourth thing is the scope vocabulary. Zoom has two of them (classic and granular), they **cannot coexist in one app**, and any newly created app gets granular. A scope list inherited from an older integration will not paste in. ## Inputs to collect before you start Ask in one batch. Never invent. | Input | Notes | | --- | --- | | **App name**, short/long description, company name, developer contact name and email | Contact details are required to activate the app | | **Which Zoom account owns the app** | Needs owner, admin, or the developer role (§2) | | **Admin-managed or user-managed?** | Decides the available scopes; expensive to change later (§3) | | **Redirect URL + every OAuth allow-list entry** | One redirect per environment, plus the allow list (§4) | | **Scope set** | What the connector actually calls, and which scopes are optional (§5) | | **Deauthorization notification endpoint URL** | Required for production apps; you must delete user data on receipt (§7) | | **Distribution intent** | Internal, private, beta, published-listed, published-unlisted (§7) | | **Logo, Terms of Use, Privacy Policy, support and documentation URLs** | Required to publish (§7) | ## Quick Start 1. Confirm a **new app** is needed — existing connections are bound to the current client ID (§1). 2. Sign in to the Marketplace with an account that has developer permissions (§2). 3. **Develop → Build an app → General app**, then set the management type (§3). 4. Enter the redirect URL and fill the **OAuth allow list** (§4). 5. Select granular scopes and write a justification for each one — reviewers read these (§5). 6. Capture **both** credential pairs and note which is which; store the `api_url` rule (§6). 7. Decide distribution, register the deauthorization endpoint, and start review if customers are external (§7). 8. Verify with a **second** Zoom account, and force two consecutive refreshes (§8). 9. Hand the credentials over — never commit them (§9). ## Platform state (verified 2026-09-20 — re-verify before trusting) - **The app types you can create today** are **General app** (user-authorizes-you OAuth, what a multi-tenant connector needs), **Server-to-Server OAuth** (one account's own data, `account_credentials` grant, no user interaction), plus SDK, Zoom Apps and Connector app variants of the same build flow. A General app is separately configured as **admin-managed** (account-level) or **user-managed** — that is a setting on the app, not a distinct app type. - **JWT apps are gone.** New JWT apps stopped being creatable on **June 1, 2023**; Zoom deactivated all JWT apps on **September 8, 2023** (moved from September 1). If a runbook tells you to create a JWT app, the runbook is three years stale — the replacement is Server-to-Server OAuth or a General app. - **Granular scopes shipped 2024-03-21 and are what any new app gets.** Zoom's own wording: "**Newly created apps** use granular scopes" — new build-flow apps, new Server-to-Server apps, and new apps on the legacy build flow. Apps created before that release use **classic scopes** and may migrate. **Classic and granular scopes do not exist in the same app**: an app on one vocabulary never sees the other in the build flow. - **There is no published deadline for classic scopes.** Zoom's migration guide says existing apps "can continue to use classic scopes, but we encourage you to migrate them," and no changelog entry names a retirement date. Treat any specific sunset date you are told as unverified until someone shows you Zoom's own announcement — and treat the absence of a deadline as a reason to migrate on your schedule, not as permission to ignore it (§5). - **Access tokens live one hour. Refresh tokens expire after 90 days and rotate on every refresh** — "You should always use the latest refresh token for the next refresh request." - **Private and beta apps do not go through review, and are capped**: a private *account-level* app maxes out at **10** additions, a private *user-level* app at **100**, and a beta app's shared authorization URL runs **4 weeks** with at most two further 4-week extensions (§7). If the Marketplace does not look like this, stop and report what you actually see rather than clicking on. ## 1. Decide: reuse the existing app, or register a new one A new app means a **new client ID, and every existing customer connection is bound to the old one** — every customer would have to re-authorize. Reuse the existing app for: adding an allow-list entry, adding or removing a scope, rotating a compromised secret, migrating classic scopes to granular, or diagnosing an authorization failure. Register a **new** app only when the user explicitly wants one: a replacement for a compromised app, a separate app for a different product, or a deliberate move from a user-managed to an admin-managed posture (which is also a re-authorization event for everyone). Say which path you are taking before you touch anything. ## 2. Account: sign in, and the developer permission Sign in at `https://marketplace.zoom.us/`. Creating apps requires being the **account owner**, an **account admin**, or a user assigned the **Zoom for developers** role — an admin grants it under **User Management → Roles → Role Settings → Advanced features**, checking **View** and **Edit** for *Zoom for developers*. **The Marketplace only shows the `Developer` link when it detects those permissions.** If the link is missing, that is the diagnosis: it is a permissions problem on the Zoom account, not a browser or account-type problem. Do not hunt for another route. Hand control back to the user for anything only they can do: account signup and email verification, 2FA enrollment, accepting the Marketplace Developer Agreement, or granting themselves the developer role. Do not retry a blocked step in a loop. **If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the user an exact, ordered click path with the literal values to paste (§4 allow list, §5 scope list), then continue once they report back with the client ID. ## 3. Create the app, and the management-type decision **Develop → Build an app → General app → Create.** Then, on **Basic Information**: - **Select how the app is managed** — the one choice on this page that shapes everything downstream: - **Admin-managed**: account admins add and manage the app; depending on scope it reaches the data of all users on the account. This is what an admin-installs-once connector needs, and it is the only way to use `:admin` scopes. - **User-managed**: individual users add the app; it reaches only that user's authorized data. Zoom's own warning is worth repeating: "The app management type affects the features and scopes available to your app. If you change the app management type later on, make sure you reconfirm the selected features and scopes." In practice, flipping it mid-project means re-selecting scopes and re-testing — decide it now, from whether the customer's admin or the customer's individual users will click Allow. - **App Credentials** — the build flow generates **two** pairs: development credentials for building and testing, production credentials for after you publish. They are different values. Label them the moment you copy them; a development secret used against production traffic fails at the token exchange with a generic client error and looks exactly like a typo. **Server-to-Server OAuth is the other app type** and it is not an alternative for a multi-tenant connector. It authenticates with **account ID + client ID + client secret** using the `account_credentials` grant, issues a one-hour token with **no refresh token**, and the credentials belong to *one* Zoom account — so serving N customers would mean N customers each creating their own app and handing you three secrets. It is the right answer only when the user is integrating their *own* Zoom account. Account admins authorize which scopes a Server-to-Server app may use, and the token stops working the moment the app is deactivated. ## 4. Redirect URL and the OAuth allow list Two separate fields, and people fill in only the first: - **OAuth redirect URL (required)** — where Zoom sends the authorization code. - **OAuth allow lists (required)** — "any unique URLs that Zoom should allow as valid redirects for your OAuth flows." Accepts either the complete URL (`https://subdomain.domain.tld/path/to/oauth/callback`) or the base URL without path or query (`https://subdomain.domain.tld`). For a platform that serves several data centers, put **every** callback host in the allow list. For Unified.to these are one per data center; confirm the current list with the platform owner rather than assuming: ``` https://api.unified.to/oauth/code # us (default) https://api-eu.unified.to/oauth/code # eu https://api-au.unified.to/oauth/code # au https://api-dev.unified.to/oauth/code # dev ``` Notes that decide whether a connect flow works: - Two optional hardening toggles change matching: **Use Strict Mode URL** allows only redirects that match the valid redirect URLs exactly, and **Subdomain check** allows only redirects matching the subdomain of the valid URLs. Turning either on after customers are live can break hosts that were previously tolerated — change them deliberately, not as a tidy-up. - The user-facing failure from the authorize endpoint reads `Invalid redirect: `. It appears at connect time, not at save time, so nothing in the portal tells you the app is broken. - **Loopback redirects (`http://127.0.0.1:{port}/...`) are for PKCE-enabled and native clients only**, and Zoom matches them ignoring *only* the port — scheme, host literal, path, query, fragment and user info must match exactly. A confidential server-side app must use HTTPS redirects; Zoom rejects loopback for it. Note it must be the numeric literal, never `localhost`. - **Custom URL schemes are supported only for Meeting SDK apps** in the build flow. A desktop connector that wants `myapp://callback` needs a different design. PKCE is available and optional here, not mandatory: turning on **Use Public Client OAuth** yields a separate **public client ID** that needs no secret, and the confidential `client_id:client_secret` Basic flow keeps working alongside it. If you enable public-client PKCE on an app you intend to publish, expect to justify it — Zoom's reviewers ask whether you use the confidential flow, the public PKCE flow, or both. ## 5. Scopes ### Classic and granular - **Classic scopes** are the older macro scopes (`user:read`, `phone:read:admin`, `meeting:write:admin` …), split into user-level, admin-level and Master-level. Admin-level scopes go to account-level apps, and **the user adding the app must be an account admin or owner** to be granted admin capabilities. Master-level scopes can only be granted to an account-level app by account owners. - **Granular scopes** are `:::` — four actions (`read`, `write`, `update`, `delete`) where classic had two, and an access suffix that is empty (user), `:admin`, or `:master`. Example: `user:read:list_users:admin`. - A given app has **one** vocabulary. New apps get granular. Migration is a button on the **Scope** page of the **Development** tab, then the same button on **Production**; Zoom infers the granular equivalents from the APIs and events your classic scopes reached, and you prune the list before testing. Re-review is **not** required to migrate or to *reduce* scopes; adding a new granular scope **does** require re-submission before it takes effect in production. - Migration is not a token event for people already connected: existing users keep working, their tokens keep returning **classic** values in the `scope` field, and they are not asked to re-authorize. New authorizations — and any existing user who re-authorizes — get granular values, and the old token is invalidated. **If your client parses the `scope` string, fix that before migrating**; it is the one documented breaking change. ### Required vs optional, and how the authorize URL must match Granular scopes can be marked **Optional** in the build flow. Which scopes the consent screen demands then depends on how you build the authorize URL: - **Basic query** — send no `scope` parameter and the user sees the app's configured required and optional scopes. - **Advanced query** — send `scope=` (required for this attempt) and `optional_scope=` (optional for this attempt) to override the build-flow defaults per authorization. Every value must already be added to the app. - **`include_granted_scopes`** returns a token carrying the user's previously authorized scopes as well, which is how you add a capability later without dropping what you already had. A connector that sends no `scope` parameter is using the basic query, so **the app's configured scope list *is* the consent screen**. Adding a scope in the portal silently widens what every new customer is asked for. ### Writing scope justifications Each scope takes a **Scope Description** explaining why the app needs it. This is not busywork: "When you add a scope, you are actually submitting a request to the Zoom Security Review team… If the Zoom Security Review team determines the requested scope is not necessary for your app, they may reject the scope request." The Marketplace Security Review explicitly "verifies that the app only requests the minimum necessary OAuth scopes" and asks developers to remove unused ones. Request the narrow set, and write one concrete sentence per scope naming the feature it powers. ### Product fact — what the Zoom connector asks for > **As of 2026-09-20, Unified.to's Zoom connector requests**, by object and direction: > > - **Users (read):** `user:read:user:admin`, `user:read:list_users:admin` — **(write):** `user:write:admin` > - **Groups (read):** `group:read:admin`, `group:read:list_groups:admin` — **(write):** `group:write:admin` > - **Meetings (read):** `meeting:read:list_meetings` — **(write):** `meeting:write:meeting`, > `meeting:update:meeting`, `meeting:delete:meeting` > - **Webinars (read):** `webinar:read:list_webinars` — **(write):** `webinar:write:webinar`, > `webinar:update:webinar`, `webinar:delete:webinar`, plus the registrant and panelist write/update/delete scopes > - **Team Chat channels (read):** `team_chat:read:list_user_channels`, `team_chat:read:user_channel`, > `team_chat:read:list_members` > - **Team Chat messages (read):** `team_chat:read:list_user_messages`, `team_chat:read:user_message` — > **(write):** `team_chat:write:user_message`, `team_chat:update:user_message`, `team_chat:delete:user_message` > - **Contacts (read):** `team_chat:read:list_contacts`, `team_chat:read:contact` > - **Identity / login only:** `user_profile`, `user_info:read`, `user:read` > > Two things to raise with the connector's owner before you configure an app, rather than assuming either way: > > 1. **The set mixes vocabularies.** Most entries are granular (four parts), but the identity/login set and the user > and group write entries are classic-shaped (`user:read`, `user:write:admin`, `group:write:admin`). Since an app > is on *one* vocabulary and a newly created app is granular, those classic names will not be selectable in the > build flow, and an advanced authorize query carrying them against a granular app will not match. > 2. **Most read scopes carry `:admin`**, so the app must be **admin-managed** and the person clicking Allow must be > an account admin or owner. A user-managed configuration will authorize and then 403. > > The connector authorizes without sending a `scope` parameter — so the consent screen is whatever the app is > configured with — exchanges the code as a form POST with Basic auth, and stores the region base URL that Zoom > returns with the token (see §6). > > Scope sets change. This note is dated, not live; confirm with the connector's owner before submitting anything. ## 6. Capture the credentials From the app's **Basic Information → App Credentials** page. Capture, per environment: - **Client ID** and **client secret** — **development** and **production** are separate pairs; record which is which - **Public client ID**, if public-client OAuth is enabled (no secret; PKCE only) - Endpoints: `https://zoom.us/oauth/authorize` and `https://zoom.us/oauth/token` - The **Secret Token** for webhook verification, if you subscribe to events - For a Server-to-Server app only: the **account ID** as well The secret is re-readable in the portal by anyone with developer permissions on that Zoom account, and regenerable. Regenerating invalidates the old secret: existing access tokens run out their hour, but every code exchange and refresh fails until the new value is deployed. Never rotate without explicit go-ahead and a cutover plan. **The `api_url` rule.** The token response carries an `api_url` naming the region that serves that user — Australia, Canada, the EU, India, Saudi Arabia, Singapore, the UK, the US, or a vanity host. Zoom says you may keep calling the global `https://api.zoom.us` regardless, but storing `api_url` per connection and calling the customer's region is the documented pattern and the faster one. Remember the `/v2/` path segment: the returned value is a host, not a base path. **Token lifetimes, stated once.** Access token: one hour (`expires_in` 3600, sometimes 3599 — do not parse it as a constant). Refresh token: **90 days, and rotated on every refresh**. Two consequences that produce most "it stopped working" reports: a client that keeps replaying the original refresh token fails on the *second* refresh, and a connection that sits idle for 90 days is dead and needs the customer back in the authorize flow. Server-to-Server apps have no refresh token at all — you request a fresh one-hour token whenever you need it. Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript and can be regenerated, then move on. ## 7. Limits, review, and listing **Distribution is the decision that gates everything else.** The types, and who can install: | Type | Review | Who can install | Distribution | | --- | --- | --- | --- | | **Internal** | No standard Marketplace review | Users on your own Zoom account only | Published but restricted to your account | | **Private** | None | Users on your own Zoom account only | Shared authorization URL; **max 10 additions account-level, 100 user-level** | | **Beta** | Sharing outside your account needs Zoom's approval | Limited external testers | Authorization URL, **4 weeks**, plus at most two 4-week extensions | | **Published (Listed)** | Full review | Anyone | Discoverable in the Marketplace | | **Published (Unlisted)** | Full review, plus complete listing metadata | Anyone with the link | Not shown in Marketplace search | **A multi-tenant connector needs a published app** — listed or unlisted. Private and beta are testing postures with hard caps, and the beta authorization URL stops admitting *new* users when it expires (people who already added the app keep working). Requests to share a beta URL outside your account get an answer from the review team in **3–4 business days**. Unlisted still fails review without full listing metadata, because that metadata is shown in the active-apps notification and on users' added-apps page. **What review actually covers**, in order: submission completeness (metadata, technical design, security information); branding and name distinctness; functionality, usability and compliance (they install, configure and use the integration); then the Security Review — a technical design review, an **OAuth scope evaluation** against least privilege, and security testing against the OWASP Top 10 including web application scanning, manual misuse testing and vulnerable-library checks. Sharing a beta URL externally additionally wants "supporting evidence of your app's security practices" — Zoom names a Secure SDLC, SAST and/or DAST scanning, and a third-party penetration test. **Compliance attestations, penetration-test scheduling and legal commitments are decisions for a human**; collect what exists, do not draft claims. Publishing also obliges you to provide, at minimum, **Terms of Use, a Privacy Policy, self-serve documentation, and a support URL**. **The deauthorization endpoint is not optional for a production app.** When a user removes the app, Zoom POSTs a deauthorization event to the notification endpoint URL you registered on the App Listing page, and "apps must delete all associated user data" on receipt. Zoom recommends verifying those requests with the supported webhook verification methods — an unsecured endpoint is a denial-of-service target. Decide who owns that endpoint and the deletion it triggers *before* submitting; it is a product commitment, not a form field. **Rate limits are per Zoom account, shared across every app installed on it, and set by the customer's plan** — so a customer on Free gets 4/second and 6000/day on Light APIs while Business+ gets 80/second, and your connector is competing with everything else that account has installed. Categories are Light, Medium, Heavy and Resource-intensive (each endpoint's docs name its label), enforced per second and/or per day; Pro and Business+ add combined daily caps of 30,000 and 60,000 requests for heavy plus resource-intensive calls. Over-limit is **HTTP 429** — back off and retry, never re-authorize. Separately: **Zoom may disable an app with a high error-to-request ratio**, which makes sloppy retry logic an existential risk rather than a nuisance. ## 8. Verify end-to-end Authorizing on the Zoom account that owns the app proves little. Test the path a customer takes: 1. Authorize from a **second Zoom account** through your platform's real connect flow, with the role customers will use — an admin if the app is admin-managed, a plain user if it is user-managed. 2. Confirm the token response carries a **refresh token** and an **`api_url`**, and that API calls go to that host with `/v2/` in the path. 3. Force a **refresh**, then force a **second** refresh using the token returned by the first. That is what catches a client that ignores rotation, and it is the single most valuable check on this platform. 4. Make one real read call and confirm the granted scopes match what you requested — an optional scope the user declined fails later at the API, not at consent. 5. Confirm the deauthorization endpoint receives an event when you remove the app from the test account. | Symptom | Cause | | --- | --- | | `Invalid redirect: ` at authorize | Redirect not in the OAuth allow list, or Strict Mode / Subdomain check now excludes it (§4) | | Loopback redirect rejected | App is a confidential client without PKCE, or the URI used `localhost` instead of `127.0.0.1` (§4) | | Token exchange fails with a generic client error | Development credentials used against production, or vice versa (§3) | | Second refresh fails, first one worked | Client is replaying the original refresh token instead of the rotated one (§6) | | Connection dies after a quiet quarter | 90-day refresh token expiry — the customer must re-authorize (§6) | | Auth succeeds, admin endpoints 403 | App is user-managed, or the installer is not an account admin/owner (§3, §5) | | 403 `authenticated user has not permitted access to the targeted resource` | Acting on another user's data without that user's shared access permissions (§8 note below) | | Scope not selectable in the build flow | Classic name on a granular app, or vice versa — they never coexist (§5) | | `429` on a customer who was fine yesterday | Account-wide, plan-based rate limit shared with their other apps (§7) | | New customers cannot add the app; existing ones fine | Private/beta install cap reached, or the beta authorization URL expired (§7) | **Shared access permissions**, worth knowing before you chase a 403: a Zoom user may hold privileges over other users (scheduling privilege, a role), and separately chooses whether your app may use them. If the app touches another user's data and the adding user either lacks that privilege or has not granted your app shared access, Zoom returns 403 with "authenticated user has not permitted access to the targeted resource." **Your app cannot detect whether shared access was granted** — there is no flag to read. The only remedy is to point the customer at Zoom's help article on allowing apps access to shared access permissions. ## 9. Hand off — never commit the secret - **Do not** write the client secret (or the webhook secret token) into source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the user, for the secret store or console. - If a code change is needed (an allow-list host, a scope, refresh-token rotation handling), keep it secret-free and say what the human must set out of band. - Close with: app name and type (General app) and management type; which Zoom account owns it; **both** client IDs labelled development and production; where each secret was delivered; the authorize and token endpoints; the exact scope strings and which are optional; the `api_url` handling; the distribution choice and review status; who owns the deauthorization endpoint; and anything left for the user to do. ## Stop and ask Hand back to a human rather than guessing when: the client does not store **rotated** refresh tokens (report it — the app will register fine and fail on the second refresh); nobody has decided **admin-managed vs user-managed**; nobody owns the **deauthorization endpoint** and the data deletion behind it; the app needs to serve external customers and nobody has committed to **Marketplace review**; a review form asks for compliance, penetration-test, legal or volume claims; someone proposes **migrating classic scopes to granular** on a live app whose client reads the `scope` string; someone wants to enable **Strict Mode** or **Subdomain check** on an app with live customers; or the Marketplace does not match the **Platform state** section above. ## References Official Zoom docs only. Every URL verified to return 200 on 2026-09-20. - Zoom App Marketplace — https://marketplace.zoom.us/ - Create an OAuth app (General app build flow) — https://developers.zoom.us/docs/integrations/create/ - OAuth 2.0: tokens, refresh, revoke, deauthorization, PKCE, loopback — https://developers.zoom.us/docs/integrations/oauth/ - OAuth scopes overview (classic vs granular, optional scopes, authorize queries) — https://developers.zoom.us/docs/integrations/oauth-scopes-overview/ - Granular scopes reference — https://developers.zoom.us/docs/integrations/oauth-scopes-granular/ - Classic scopes reference — https://developers.zoom.us/docs/integrations/oauth-scopes/ - Migrate from classic to granular scopes — https://developers.zoom.us/docs/integrations/migrate/ - Granular and optional scopes released (changelog, 2024-03-21) — https://developers.zoom.us/changelog/platform/granular-and-optional-scopes-announcement/ - Server-to-Server OAuth — https://developers.zoom.us/docs/internal-apps/s2s-oauth/ - Create a Server-to-Server OAuth app — https://developers.zoom.us/docs/internal-apps/create/ - Before you build: app types, audience, discoverability — https://developers.zoom.us/docs/build-flow/before-you-build/ - App distribution — https://developers.zoom.us/docs/distribute/ - App review process — https://developers.zoom.us/docs/distribute/app-review-process/ - Sharing private and beta apps (install caps, URL expiry) — https://developers.zoom.us/docs/distribute/sharing-private-and-beta-apps/ - Security requirements and technical design — https://developers.zoom.us/docs/distribute/security-requirements/ - Rate limits — https://developers.zoom.us/docs/api/rate-limits/ - Using Zoom APIs (regional base URLs, shared access permissions, `me`) — https://developers.zoom.us/docs/api/using-zoom-apis/ - Webhooks and event verification — https://developers.zoom.us/docs/api/webhooks/ - Marketplace events, including app deauthorized — https://developers.zoom.us/docs/api/marketplace/events/ - JWT app type deprecation (changelog) — https://developers.zoom.us/changelog/platform/jwt-app-type-deprecation/ - Deadline change for JWT app type deprecation (changelog) — https://developers.zoom.us/changelog/platform/deadline-change-for-jwt-app-type-deprecation/ ## zoom-phone-oauth-app URL: https://docs.unified.to/skills/zoom-phone-oauth-app/SKILL.md # Zoom Phone OAuth2 App Registration **Zoom Phone is not a separate developer portal, a separate app, or a separate credential.** It is a scope family on the same Zoom Marketplace app, reached with the same client ID and the same one-hour tokens. There is nothing to register here that `zoom-oauth-app` does not already cover. What Zoom Phone adds is a **licensing precondition that no OAuth configuration can satisfy**: the customer's account must be on a qualifying plan *and* carry a Zoom Phone license, or every `/v2/phone/**` call returns 403 with a perfectly valid token. That, and a call-log API generation that was sunset in 2025, are the two things that cost time. ## Built on: `zoom-oauth-app` **Read `zoom-oauth-app` first, then this file.** It owns all the Marketplace mechanics, and this file does not repeat them: - Creating a **General app** (vs Server-to-Server OAuth, and why JWT apps are gone), and the admin-managed vs user-managed choice that decides which scopes exist. - Redirect URL and the **OAuth allow list**, Strict Mode and Subdomain check, the platform's per-data-center callback list, loopback and PKCE rules. - **Classic vs granular scopes**: the two vocabularies never coexist in one app, new apps get granular, the migration button, optional scopes and the `scope` / `optional_scope` / `include_granted_scopes` authorize parameters. - The **two credential pairs** (development and production), the `api_url` region rule, one-hour access tokens, and 90-day refresh tokens that **rotate on every refresh**. - **Distribution and review**: private/beta install caps (10 account-level, 100 user-level, 4-week beta URLs), what Marketplace review asks for, the mandatory deauthorization endpoint, and account-wide plan-based rate limits. If you are reading only this file, you are missing all of the above. ## Inputs to collect before you start The base skill lists the app inputs. These are the Phone-specific ones: | Input | Notes | | --- | --- | | **Does the customer's account hold a Zoom Phone license?** | Blocking — no scope or app setting substitutes for it (§1) | | **What account plan?** | Pro / Business / Enterprise / Education gates differ per endpoint (§1) | | **Account-wide phone data, or one user's?** | Decides `:admin` scopes and therefore admin-managed (§2) | | **Are call recordings and transcripts in scope?** | Separate scopes, separate admin privilege, separate account settings (§4) | | **Which call-log generation?** | The originals were sunset 2025-06-18 (§3) | ## Zoom Phone platform state (verified 2026-09-20 — re-verify before trusting) - **Every Zoom Phone endpoint lists a Zoom Phone license as a prerequisite**, alongside an account-plan floor that varies by endpoint — commonly "A Business or Enterprise account", sometimes "A Pro or higher account plan", and for the dashboard call-log metrics "Business or Education account". The API reference states it plainly: "You'll receive a `403` error message if you have not set up Zoom Phone." - **Granular `phone:` scopes are `phone::[:admin]`** — for example `phone:read:list_users:admin`, `phone:read:call_log:admin`, `phone:read:call_recording`. The classic equivalents are the macro scopes `phone:read`, `phone:read:admin`, `phone:write:admin`, plus the older `phone_call_log:read[:admin]` and `phone_recording:read[:admin]`. - **The first-generation call-log APIs were sunset on 2025-06-18** — "Get account's call logs" and "Get call log details". The account, user, sync and delete call-log endpoints all now carry a deprecation marker, and the replacements live under call **history**. - **Zoom Phone has its own rate-limit table**, separate from the general Zoom API one and with no Free tier: Light 20/s (Pro) and 40/s (Business+), Medium 10/s and 20/s, Heavy 5/s and 10/s, Resource-intensive 5/min and 10/min, with a daily allowance of 15,000 (Pro) or 30,000 (Business+) shared between heavy and resource-intensive calls. - **A separate Zoom Phone Master API exists** for master accounts managing sub-accounts, behind `:master` scopes that only an account owner can grant to an account-level app. If the Phone docs do not look like this, stop and report what you actually see. ## 1. The license precondition This is the whole difference between Zoom Phone and the rest of the Zoom API, and it is invisible from the app configuration: - The app can be perfectly registered, the scopes approved, the token valid, and **every phone call still 403s** because the authorizing account has no Zoom Phone license — or has one but is on a plan below the endpoint's floor. - It is a **per-customer** condition, so a connector that works for nine customers can fail wholesale for the tenth, with nothing in your logs distinguishing it from a permissions bug. - There is no remedy on your side. The answer is "your Zoom account needs a Zoom Phone license on a qualifying plan," and it is a purchase, not a setting. Check this before scoping any Zoom Phone work: ask whether the *test* account has a Phone license. Without one you cannot verify a single endpoint, and an app that passes review on meeting scopes tells you nothing about Phone. ## 2. Admin vs user scopes The `:admin` suffix is doing more work here than usual: - **`phone:...:admin` scopes read the whole account's phone data** — every user, every call, every recording. They require an **admin-managed (account-level) app**, and the person clicking Allow must be an account owner or admin. Several endpoints additionally demand "Account owner or a role with Zoom Phone management", which is a Zoom role-based-access setting on the customer's side, not a scope. - **User-level `phone:` scopes** (no suffix) see only the authorizing user's own phone data, and those endpoints accept `me` in place of a user ID. - Mixing them does not widen anything: a user-managed app cannot hold `:admin` scopes at all, and an account-level app whose installer is a plain user authorizes fine and then 403s on admin endpoints. **The shared-access 403 lands squarely on Zoom Phone.** Zoom's list of paths that return "authenticated user has not permitted access to the targeted resource" names `/v2/phone/**` (user-level) explicitly: if your app touches a user other than the one who added it, and that user either lacks the privilege or has not granted your app shared access permissions, you get a 403 you **cannot detect in advance**. See the base skill's §8. ## 3. Call logs: two generations, one set of scope names The trap is that the old and new endpoints share granular scope names, so **the scope list does not tell you which generation you are calling**. - **Sunset 2025-06-18**: the account-wide call-log list and the call-log detail endpoint. The user-level call-log list, sync and delete endpoints are likewise marked deprecated. - **Current**: the call-**history** endpoints — an account-wide list, a single-call lookup, user-level history, sync and delete, plus a client-code patch. - Both generations sit behind `phone:read:list_call_logs:admin` (list) and `phone:read:call_log:admin` (single), with classic `phone:read:admin` / `phone_call_log:read:admin` as the pre-granular equivalents. So a scope audit will look clean on a connector still calling a dead endpoint. - Separately, the **dashboard** call-log metrics endpoints share those same scope names again while returning monthly metrics filtered by date, site and MOS — a different shape of data for a different question. - The structural point worth passing on: for inbound calls the list response is often only "a small fraction of how the call routes," because auto receptionists, phone menus, call queues and shared lines each add components. Full call accounting means a **second call per call** to the detail endpoint. Budget that against the rate limits in the platform-state section — the list endpoints are labelled HEAVY. ## 4. Recordings and transcripts - Account-wide recording listing uses `phone:read:list_call_recordings:admin` (classic `phone_recording:read:admin`) and requires "A Pro or higher account plan", a Zoom Phone license, and **account owner or admin privileges**. Per-user recording listing has its own scope pair, and the per-recording fetch and download use `phone:read:call_recording[:admin]`. - **Transcripts are a separate scope**, `phone:read:recording_transcript[:admin]`, and a separate download endpoint that 302s to a JSON transcript file. Documented failure codes cover a recording that was never transcribed, an admin who disabled transcription, and one still being processed — all of which are normal states, not bugs. - **Recording access narrows below the scope.** One recording lookup notes that "the current recording must belong to the receiver and call queue for it to be available": holding the scope does not entitle a token to every recording that exists. - **On consent:** Zoom Phone carries account-level recording-consent and notification settings — an explicit-consent ("Press 1") option and recording-start prompts, configurable separately for inbound and outbound calls, and lockable by an admin. These govern whether calls get recorded at all and what callers hear; they are **not** an extra OAuth consent your app grants or requests. I found no documented requirement for a second authorization step beyond the recording scopes themselves — but recording law is jurisdictional, and whether a customer may hand you call audio is a question for them and their counsel, not something to settle from the API docs. ## Product fact — what the Zoom Phone connector asks for > **As of 2026-09-20, Unified.to's Zoom Phone connector requests** (read-only; no write scopes at all): > > - **Phone users:** `phone:read:list_users:admin` > - **Calls:** `phone:read:call_log:admin`, `phone:read:list_call_logs:admin` > - **Recordings:** `phone:read:list_call_recordings:admin`, `phone:read:call_recording:admin`, > `phone:read:recording_transcript:admin` > - **Identity / login only:** `user_profile`, `user_info:read`, `user:read` > > Three things to raise with the connector's owner before configuring an app: > > 1. **Every phone scope carries `:admin`**, so this is an admin-managed app installed by an account owner or admin, > on an account with a Zoom Phone license. There is no user-managed variant. > 2. **The identity/login scopes are classic-shaped** while the phone scopes are granular — and the two vocabularies > cannot coexist in one app (base skill, §5). Confirm which vocabulary the app is actually on. > 3. **This connector is flagged as requiring a custom API domain (CNAME) for its OAuth callback**, unlike the main > Zoom connector. Settle the hostname before registering redirect URLs, because changing the callback host later > means re-doing the OAuth allow list. > > It is also configured for Zoom Phone's page-token pagination rather than the offset paging the general Zoom > connector uses, and it reads the region base URL returned with the token. > > Scope sets change. This note is dated, not live; confirm with the connector's owner before submitting anything. ## Phone-specific end-to-end checks On top of the base skill's authorize → refresh round trip: 1. Confirm the **test account holds a Zoom Phone license** before concluding anything from a 403. 2. Make one real call to the phone-user list — it returns only users assigned a Phone license, so an empty list on a licensed account is itself a finding. 3. Fetch call history, not the sunset call-log endpoints, and confirm you are on the current generation. 4. If recordings matter, fetch a recording **and** a transcript — transcripts fail independently of recordings. 5. Watch for `429` against the **Zoom Phone** table, not the general one; the shared heavy/resource-intensive daily cap is the one that bites a backfill. | Symptom | Cause | | --- | --- | | Every `/v2/phone/**` call 403s, token is valid | No Zoom Phone license, or account plan below the endpoint's floor (§1) | | Phone works for most customers, 403 for one | That account's licensing or plan — not your app (§1) | | Auth succeeds, admin phone endpoints 403 | User-managed app, or the installer is not an account owner/admin (§2) | | 403 `authenticated user has not permitted access to the targeted resource` | Shared access permissions on a user other than the installer (§2) | | Call-log endpoint returns nothing / is gone | Calling the generation sunset on 2025-06-18 (§3) | | Inbound calls look truncated or mis-attributed | List response holds only the top-level leg; the detail call carries the routing (§3) | | Recording listed but transcript download errors | Never transcribed, transcription disabled by admin, or still processing (§4) | | Scope held but a specific recording is unavailable | Recording ownership narrows access below the scope (§4) | | `429` during a backfill | Phone's own rate-limit table and its shared heavy/resource-intensive daily cap | ## Stop and ask Beyond the base skill's list, hand back to a human when: - Nobody can confirm the customer's (or the test account's) **Zoom Phone license and plan** — register nothing and promise nothing until that is answered. - The product needs **account-wide** phone data but the install story is individual users self-connecting: `:admin` scopes and an admin installer are not optional (§2). - **Call recordings or transcripts** are in scope and nobody has established who is accountable for the legality of storing call audio and its retention (§4). - A connector is still calling the **sunset call-log endpoints** — fixing that is a code change with a behaviour difference, not a scope change (§3). - Master-account / sub-account phone data is wanted, which means `:master` scopes and an account owner's grant. - The Zoom Phone docs do not match the **platform state** section above. ## References Phone-specific only; the base skill carries the general Zoom OAuth and Marketplace references. Every URL verified to return 200 on 2026-09-20. - Zoom Phone developer overview — https://developers.zoom.us/docs/phone/ - Zoom Phone API reference (per-endpoint prerequisites, classic and granular scopes, rate-limit labels) — https://developers.zoom.us/docs/api/phone/ - Understand Zoom Phone call logs (the 2025-06-18 sunset notice and call-flow structure) — https://developers.zoom.us/docs/phone/understanding-call-logs/ - Zoom Phone webhook events — https://developers.zoom.us/docs/api/phone/events/ - Zoom Phone Master API — https://developers.zoom.us/docs/api/phone/ma/ - Rate limits, including the Zoom Phone table — https://developers.zoom.us/docs/api/rate-limits/ - Using Zoom APIs — shared access permissions and the `/v2/phone/**` 403 — https://developers.zoom.us/docs/api/using-zoom-apis/ --- # Tutorials ## Build a simple Javascript app that calls the Unified.to API URL: https://docs.unified.to/tutorials/build-a-simple-javascript-app # Build a simple Javascript app that calls the Unified.to API In this tutorial, you’ll build a simple Javascript application that calls the Unified API to list candidates from Lever, an applicant tracking system (ATS). It assumes you have basic knowledge of: - HTML - Javascript - Web development By the end of this tutorial, you’ll be able to: 1. Activate integrations in Unified.to 2. Create connections to third-party API providers 3. Embed Unified.to's Authorization component to display integrations in your app 4. Call the Unified API and display the results ## Background [Unified.to](https://unified.to/) is a platform that provides a unified (i.e. single) API for integrating various software applications. In this tutorial, we'll connect to Lever, an Applicant Tracking System (ATS) used by recruiters and talent teams. An ATS like Lever helps companies manage their hiring process by organizing candidate information, job postings, and application workflows. However, oftentimes app developers will want to integrate with multiple vendors. Integrating directly with multiple platforms can be challenging due to differences in APIs and data models. This is where [Unified.to](https://unified.to/) comes in. It provides a standardized way to interact with multiple platforms through a unified API. This means you can write code once and integrate with multiple systems without having to learn each individual API. ## Prerequisites Before we begin, make sure you have: 1. [Signed up](https://unified.to/) for a Unified.to account 2. Completed our [Get Started](https://app.unified.to/start) guide in the app (optional, but recommended) 3. Downloaded a code editor of your choice, like VS Code 4. Installed NPM and Node.js on your system ## Project setup We've prepared a simple JavaScript and HTML application for you to build upon, which can find [here](https://github.com/unified-to/unified-ats-tutorial). This will serve as the foundation for our tutorial and we recommend that you download it and follow along. Run the commands below: ```js [shell] // inside the folder where you want to download the repo git clone git@github.com:unified-to/unified-javascript-tutorial.git cd unified-javascript-tutorial npm i ``` The app should now be available on `localhost:1234`. This repository contains a basic structure for a single-page application that we'll develop with Unified.to. Take a moment to familiarize yourself with the files we’ll be working with: - `index.html`: The main HTML file for our application. - `app.js`: Where we'll add our JavaScript code. It contains placeholders and predefined variables for your convenience. - `.env`: Where you’ll add sensitive information, like your API token. Open the folder in your preferred code editor. ## Step 1: Retrieve your workspace ID and API token from Unified.to You can find these values in your developer account under [_Settings > API Keys_](https://app.unified.to/settings/api). Record your Workspace ID and API Token in the `.env` file. ```js [.env] UNIFIED_WORKSPACE_ID = your_workspace_id_here; UNIFIED_API_KEY = your_api_token_here; ``` Now that you've updated `.env`, we can start our app. Run the following from the command line: ```js [shell] npm run start ``` You can view your app on `localhost:1234`. ## Step 2: Activate an integration in the Sandbox environment The Sandbox environment is where you can activate, explore, and try out integrations in a safe and non-destructive environment. We're going to call the ATS endpoint by the end of this tutorial, so let's activate an ATS integration. ### Check that you are in the Sandbox environment 1. Navigate to [_Integrations_](https://app.unified.to/integrations) 2. Look for the environment selector near the top of the page. Click on it and select 'Sandbox'. Any subsequent actions you take will happen in the sandbox environment. ### Activate Lever 1. On the same page (Active Integrations), search for 'Lever' by typing it into the search box. 2. Click on the Lever card to open its details page. 3. Leave the default settings as-is. Notice that since we’re in the Sandbox environment, the credentials are pre-filled with mock values. Lever uses OAuth 2 by default for authorization. 4. At the bottom of the page, click 'Activate’. The Lever integration is now activated in your Sandbox environment. This allows you to make test API calls and get a feel for the Unified API without needing to connect to a real Lever account. ## Step 3: Add the Authorization embedded component to your app The Authorization embedded component displays a list of your activated integrations. Your customers can use it to authorize access to third-party vendors for your app. We'll dynamically inject the widget script into the DOM using JavaScript, which allows us to use the environment variables we defined earlier. 1. Open `app.js` and add the following code: ```js [app.js] function embedAuthorizationComponent() { const script = document.createElement('script'); script.src = `https://api.unified.to/docs/unified.js?wid=${UNIFIED_WORKSPACE_ID}&did=unified_widget&env=Sandbox`; script.async = true; script.onload = () => { console.log('Unified.to component script loaded successfully'); }; script.onerror = () => { console.error('Failed to load Unified.to component script'); }; // The script will search the DOM for a div with the id "unified_widget" document.body.appendChild(script); } // Inject the widget on page load document.addEventListener('DOMContentLoaded', () => { embedAuthorizationComponent(); }); ``` 2. In `index.html`, add the following under `
` e.g: ```html [index.html]
``` Now, when your application loads, it will dynamically inject the [Unified.to](https://unified.to/) widget into the DOM. Refresh the page, you should see the widget will appear displaying a list of activated integrations. ## Step 4: Handle and store the connection ID When a user clicks on the Lever integration in the Unified.to widget, a new page will open where they will be asked to grant access to Lever before being redirected back to your app. The redirect URL contains important parameters, including `id` , which represents the Connection ID. We need to store this ID for making subsequent calls to the Unified API. Add the following code to `app.js` to handle the connection process: ```js [app.js] function handleConnectionCallback() { const urlParams = new URLSearchParams(window.location.search); const connectionId = urlParams.get('id'); if (connectionId) { console.log('New connection created:', connectionId); // Store the connectionId for later use localStorage.setItem('unifiedConnectionId', connectionId); } else { console.log('No connection ID found in URL parameters'); } } // Execute the handler when the page loads window.addEventListener('load', handleConnectionCallback); ``` **Warning:** Storing the Connection ID in `localStorage` is used here for simplicity and learning purposes only. In a production environment, you should securely store this information server-side, associated with the user's account. Refer to our guide on [How to Associate a Connection ID with your End-User](https://docs.unified.to/guides/how_to_associate_a_connection_id_with_your_end_user) for best practices. ## Step 5: Create and verify the connection Let’s walk through creating a connection to confirm that it works. ### Create the Connection 1. In the widget, click on the Lever integration. 2. **Authorization Flow**: - A new page will open with a disclaimer that you are on the Test plan. - Click **Continue** to grant access to Lever. Note: The authorization flow is simplified in the Sandbox environment. In Production, your user will be shown the scopes you are requesting and asked to grant you access. 3. After granting access, you'll be automatically redirected back to your app. ### Verify the Connection Let's verify that the connection was created successfully: 1. **Check the URL**: - Look at the URL in your browser's address bar. - You should see a parameter `id` in the URL. This is your Connection ID. - For example, the URL might look like: `http://localhost:1234?id=abc123...` 2. **Console Output**: - Open your browser's developer tools. - Go to the Console tab. - You should see a message similar to: `New connection created: abc123...` - This confirms that our `handleConnectionCallback` function has captured the Connection ID. 3. **Verify Local Storage** (Optional): - In the developer tools, go to the Application tab. - Under Storage, select Local Storage and your domain. - You should see an item named `unifiedConnectionId` with the value matching the ID from the URL. ## Step 6: Set up API call to the Unified ATS Endpoint In this step, we'll implement the API call to fetch candidate data from the Unified ATS endpoint. ### Implement the API call function In `app.js`, add the following function to fetch ATS candidate data from the ATS endpoint: ```js [app.js] async function fetchATSCandidates(connectionId) { const options = { method: 'GET', url: `https://api.unified.to/ats/${connectionId}/candidate`, headers: { authorization: `bearer ${UNIFIED_API_KEY}`, }, params: { limit: 20, offset: 0, }, }; try { const response = await axios.request(options); return response.data; } catch (error) { console.error('Error fetching ATS candidates:', error); return null; } } ``` **API Reference:** [List all candidates](/ats/candidate/List_all_candidates) Notice that we don't need to specify `Sandbox` as an environment parameter in the API call - the connection we created is part of the Sandbox environment, so any data that comes back from it will be synthetic. ### Fetch and display candidates in the UI In `index.html`, add the following under `
` e.g: ```html [index.html]
    ``` ### Implement the click handler Add the following code to your `app.js` file to handle the button click and display the results: ```js [app.js] function displayCandidates(candidates) { const candidatesList = document.getElementById('candidates-list'); candidatesList.innerHTML = '

    Candidates:

    '; candidates.forEach((candidate) => { const candidateElement = document.createElement('li'); candidateElement.className = 'candidate-item'; candidateElement.innerHTML = `
    ${candidate.name || 'Candidate'} avatar

    ${candidate.name || 'N/A'}

    ${ candidate.emails?.[0]?.email || 'No email provided' }

    `; candidatesList.appendChild(candidateElement); }); } document.getElementById('fetchCandidatesBtn').addEventListener('click', async () => { const connectionId = localStorage.getItem('unifiedConnectionId'); if (!connectionId) { console.error('No connection ID found. Please connect to Lever first.'); return; } const candidates = await fetchATSCandidates(connectionId); if (candidates) { displayCandidates(candidates); } else { console.log('No candidates found or error occurred'); document.getElementById('candidatesList').innerHTML = '

    No candidates found or an error occurred.

    '; } }); ``` ## Step 7: Call the Unified API Now, let's test our application to ensure everything is working correctly. Click **Fetch Candidates**. You should see a list of candidates appear on your screen. Since we're working in the Sandbox environment, these will be mock candidates generated by Unified.to. ![Results of calling the API](/images/screenshots/tutorial-result.png) **Troubleshooting**: If you don't see any candidates or encounter an error, check the browser console for any error messages or verify that your Unified API Key is correctly set in the `.env` file. ## Summary Congratulations! You've built a Javascript app that can call the Unified.to API and fetch candidates from Lever. In this tutorial, you saw how to: - Activate integrations in Unified.to - Create connections by granting access to third-party vendors - Add the [Unified.to](https://unified.to/) Embedded Widget to your app - Make API calls to our unified API ## Next Steps From here, you can start adding integrations from [Unified.to](https://unified.to/) into your own project. The following resources can help you on your way: - Use one of our [SDKs](/reference/sdks) - Get familiar with [webhooks](/reference/webhooks) - See how to customize the auth flow with our [Customize your authorization flow with the Unified API](/tutorials/customize-auth-flow) tutorial Happy building! ## Customize your authorization flow with the Unified API URL: https://docs.unified.to/tutorials/customize-auth-flow # Customize your authorization flow with the Unified API In this tutorial, you'll build a Javascript app that asks users to grant access to their third-party accounts with the Unified API. Instead of using the Authorization embedded component as in the [starter tutorial](/tutorials/build-a-simple-javascript-app), we'll implement this authorization flow from scratch. This work flow is intended for experienced developers who want more control over the authorization process or need to customize the user experience. By the end of this tutorial, you'll be able to: 1. Fetch and display a list of available integrations from Unified.to 2. Create an authorization URL for a selected integration 3. Handle the authorization callback and create a connection 4. Use the connection to fetch data from an API provider ## Prerequisites Before we begin, make sure you have: 1. [Signed up](https://unified.to/) for a Unified.to account. 2. Completed our [Get Started](https://app.unified.to/start) guide in the app. 3. Downloaded a code editor of your choice, like VS Code. 4. Installed NPM and Node.js on your system. ## Project setup We'll use the same project structure as the previous tutorial. Start by cloning the repository and installing dependencies: ```js [shell] git clone git@github.com:unified-to/unified-javascript-tutorial.git cd unified-javascript-tutorial npm i ``` The app should now be available on `localhost:1234`. Open the folder in your preferred code editor. ## Step 1: Retrieve your workspace ID and API token 1. Log in to your Unified.to account 2. Go to [Settings > API Keys](https://app.unified.to/settings/api) 3. Copy your Workspace ID and API Token 4. Open the `.env` file in your project and add these values: ```js [.env] UNIFIED_WORKSPACE_ID = your_workspace_id_here; UNIFIED_API_KEY = your_api_token_here; ``` Now that you've updated `.env`, we can start our app. Run the following from the command line: ```js [shell] npm run start ``` You can view your app on `localhost:1234`. ## Step 2: Activate an integration in the Sandbox environment _If you've already activated some ATS integrations in the Sandbox environment, skip ahead to step 3._ The Sandbox environment is where you can activate, explore, and try out integrations in a safe and non-destructive environment. We're going to call the ATS endpoint by the end of this tutorial, so let's activate an ATS integration. ### Check that you are in the Sandbox environment 1. Navigate to [_Integrations_](https://app.unified.to/integrations) 2. Look for the environment dropdown near the top of the page. Click on the dropdown that contains 'ENV' and select 'ENV: SANDBOX'. Any subsequent actions you take will happen in the Sandbox environment. ### Activate Lever 1. On the same page (Active Integrations), search for 'Lever' by typing it into the search box. 2. Click on the Lever card to open its details page. 3. Leave the default settings as-is. Notice that since we’re in the Sandbox environment, the credentials are pre-filled with mock values. Lever uses OAuth 2 by default for authorization. 4. At the bottom of the page, click 'Activate’. The Lever integration is now activated in your Sandbox environment. This allows you to make test API calls and get a feel for the Unified API without needing to connect to a real Lever account. ## Step 3: Fetch and display available integrations Time to get coding! First, let's write a function to fetch all of the available and activated integrations from your workspace. Add the following to `app.js`: ```js [app.js] async function fetchIntegrations() { try { const response = await axios.get('https://api.unified.to/unified/integration/workspace', { params: { workspace_id: UNIFIED_WORKSPACE_ID, env: 'Sandbox', categories: 'ats', }, headers: { Authorization: `Bearer ${UNIFIED_API_KEY}`, }, }); return response.data; } catch (error) { console.error('Error fetching integrations:', error); return []; } } ``` **API reference**: [Get all integrations](/unified/integration/Returns_all_integrations) ### Render integrations in the UI We also need to display the integrations in the UI. We'll do this programatically by passing the response from the above function to `displayIntegrations()`. When the app loads, we'll fetch the integrations and then render them on the page. Add the following to `app.js`: ```js [app.js] function displayIntegrations(integrations) { const integrationsListElement = document.getElementById('integrations-list'); integrations.forEach((integration) => { const listItem = document.createElement('div'); listItem.innerHTML = ` `; integrationsListElement.appendChild(listItem); }); } document.addEventListener('DOMContentLoaded', async () => { const integrations = await fetchIntegrations(); displayIntegrations(integrations); }); ``` Now, let's update the view by creating a container to hold the integrations. In `index.html`, add the following under `
    ` e.g: ```html [index.html]

    Available Integrations

      ``` At this point, you should see a list of integrations displayed on the page (if you're just starting out, then the list may only contain one item, but that's OK!) ## Step 4: Create an authorization URL Now that we're displaying our integrations to our users, let's ask them to grant us access to their third-party accounts when they click on an integration. A few things will happen: - When a user clicks on an integration, they will be redirected to the authorization page for that integration. - The user will be asked to authorize your app with the third-party provider. - After granting access to your app, a connection will be created. The user will be redirected to your `success_redirect` URL. Add the following to `app.js`: ```js [app.js] function createAuthLink(integrationType) { const baseUrl = 'https://api.unified.to/unified/integration/auth'; const params = new URLSearchParams({ redirect: '1', env: 'Sandbox', success_redirect: window.location.href, failure_redirect: window.location.href, scopes: 'ats_candidate_read', state: 'abc-123-def-456', }); return `${baseUrl}/${UNIFIED_WORKSPACE_ID}/${integrationType}?${params.toString()}`; } ``` **API reference**: [authorize new connection via authorization URL](https://docs.unified.to/unified/integration/Authorize_or_re-authorize_a_connection) ### Breaking down the authorization URL API call Let's review the structure of the API call we made to create an authorization URL: `https://api.unified.to/unified/integration/auth/{workspace_id}/{integration_type}?{query_parameters}` - `workspace_id`: Your Unified.to workspace ID - `integration_type`: The name of the integration (e.g., 'lever') - Query parameters: - `redirect=1`: Indicates that this is a redirect request. The server will return a `302` status code and redirect the user. We recommend that you use this method as it is more secure and robust. - `env=Sandbox`: Specifies the environment (use 'Sandbox' for testing) - `success_redirect`: URL to redirect after successful authorization - `failure_redirect:` URL to redirect after failed authorization - `scopes`: Comma-separated list of required scopes (e.g., 'ats_candidate_read'). It's a best practice to always include the scopes you need. - `state`: Data that you want to send back to your success URL in the URL parameters. A common use case is to put your user ID here so that after they have completed the auth flow, you can use the user ID to associate them with the connection you just made. - `connection_id` and `reauth_token` (optional, always together): re-authorize an existing connection in place instead of creating a new one. Create the `reauth_token` from your backend first. See [Re-authorize an existing connection](/concepts/embedded-components#re-authorize-an-existing-connection). #### Restrict redirect hosts with `trusted_domains` A forwarded authorization link can be edited so the new connection `id` is sent to someone else's site. To prevent this, set `trusted_domains` on your workspace to the hostnames allowed for `success_redirect` and `failure_redirect` (for example `app.yourcompany.com` or `localhost:3000`). Use a user API key that belongs to a workspace admin, and include your workspace `name`: ```http PUT https://api.unified.to/unified/workspace Authorization: Bearer YOUR_API_KEY { "name": "Your workspace", "trusted_domains": ["app.yourcompany.com"] } ``` Entries are saved as lowercase hosts. Wildcards (`*.yourcompany.com`) are rejected, so list each host. When the list is set, Unified returns `400` for a redirect to any other host, including the page the link was opened from when you omit `success_redirect` or `failure_redirect`. The Unified dashboard is always allowed, so you can still test authorization from the app. If `trusted_domains` is empty or unset, redirect hosts are not restricted. The same list applies to [sign-in](/auth/overview). Treat the `id` on your `success_redirect` as untrusted input: confirm the connection belongs to your workspace (for example with `GET /unified/connection/{id}`) and matches the user who started the flow (via `state`) before you store it. ### Update the display logic for integrations We need to update the `displayIntegrations()` function we created earlier. Replace it with the following: ```js [app.js] function displayIntegrations(integrations) { const integrationsListElement = document.getElementById('integrations-list'); integrations.forEach((integration) => { const listItem = document.createElement('div'); const authLink = createAuthLink(integration.integration_type); listItem.innerHTML = ` ${integration.integration_type} `; integrationsListElement.appendChild(listItem); }); } ``` **API reference**: [Create an authorization URL](/unified/integration/Authorize_or_re-authorize_a_connection) (a.k.a. create a connection indirectly). When you click on one of your integrations now, you'll be taken to an authorization page. In the next step, we'll handle what happens after an integration is authorized. ## Step 5: Handle the authorization callback After a user successfully authorizes your application, they'll be redirected to your app with some new parameters in the URL. One of these parameters is `id`, which represents the ID of the newly created connection. We will need to store this connection ID in order to use it in future API calls. Create a function to handle the connection ID: ```js [app.js] function handleAuthCallback(connectionId) { localStorage.setItem('unifiedConnectionId', connectionId); // Clear the URL parameters window.history.replaceState({}, document.title, window.location.pathname); } ``` Update the `DOMContentLoaded` event listener to call this function when the page is loaded and a connection ID is present i.e. when the user is redirected to your app. ```js [app.js] // Add the following to the `DOMContentLoaded` event listener const urlParams = new URLSearchParams(window.location.search); const connectionId = urlParams.get('id'); if (connectionId) { handleAuthCallback(connectionId); } ``` **Note**: In a "real" app, you should store the connection ID in your own database. ## Step 6: Use the connection to call the Unified API Let's update our code to fetch candidates using the connection we just created. (Fun fact: From this point onwards, the code you're seeing is the same as the code from the starter tutorial). ```js [app.js] async function fetchATSCandidates(connectionId) { const options = { method: 'GET', url: `https://api.unified.to/ats/${connectionId}/candidate`, headers: { authorization: `bearer ${UNIFIED_API_KEY}`, }, params: { limit: 20, offset: 0, }, }; try { const response = await axios.request(options); return response.data; } catch (error) { console.error('Error fetching ATS candidates:', error); return null; } } ``` **API Reference:** [List all candidates](/ats/candidate/List_all_candidates) Notice that we don't need to specify `Sandbox` as an environment parameter in the API call - the connection we created is part of the Sandbox environment, so any data that comes back from it will be synthetic. Finally, let's display the candidates in the UI. Add the following code to your app: ```js [app.js] function displayCandidates(candidates) { const candidatesList = document.getElementById('candidates-list'); candidatesList.innerHTML = '

      Candidates:

      '; candidates.forEach((candidate) => { const candidateElement = document.createElement('li'); candidateElement.className = 'candidate-item'; candidateElement.innerHTML = `
      ${candidate.name || 'Candidate'} avatar

      ${candidate.name || 'N/A'}

      ${ candidate.emails?.[0]?.email || 'No email provided' }

      `; candidatesList.appendChild(candidateElement); }); } document.getElementById('fetchCandidatesBtn').addEventListener('click', async () => { const connectionId = localStorage.getItem('unifiedConnectionId'); if (!connectionId) { console.error('No connection ID found. Please connect to Lever first.'); return; } const candidates = await fetchATSCandidates(connectionId); if (candidates) { displayCandidates(candidates); } else { console.log('No candidates found or error occurred'); document.getElementById('candidatesList').innerHTML = '

      No candidates found or an error occurred.

      '; } }); ``` In `index.html`, add the following lines under `
      ` e.g: ```html [index.html]
        ``` With all the pieces in place, you can now give this auth flow a try. Click on an integration, authorize your app, and then click on **Fetch candidates** to see what happens. ## Summary Congratulations! You've successfully built your own custom authorization flow to creates connections with the Unified API. This tutorial demonstrated how to: 1. Fetch and display available integrations 2. Redirect users to an authorization page for a selected integration 3. Handle the auth callback and create a connection 4. Use the connection to fetch and display data from a third-party API provider By implementing this authorization flow yourself, you have more control over the user experience and can customize it to fit your application's needs. Happy building! --- # API Reference ## Data Types URL: https://docs.unified.to/reference/datatypes # Data Types | Type | Description | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | string | a string | | number | either an integer or double/float | | array | a list / array of one data type | | boolean | true or false | | object | any object structure | | date | Dates/Timestamps use UTC time and are formatted as ISO 8601 strings (without milliseconds). eg. `2015-04-16T09:14:57Z` | | ID | All object IDs are 24 byte hexadecimal strings that are created by our MongoDB servers and represent a [ObjectId](https://docs.mongodb.com/manual/reference/method/ObjectId/). | | undefined | some values could be `undefined` if no information is available for that field | ## Deprecations URL: https://docs.unified.to/reference/deprecations # Deprecations The API signals deprecations in machine-readable ways so that your code — and AI agents building against the API — can detect them automatically, rather than relying on you to read a changelog. ## Response headers When you call a deprecated endpoint, the response carries standard deprecation headers: | Header | Standard | Description | | ------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `Deprecation` | [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745){:target="\_blank"} | Present with the value `true` when the endpoint is deprecated. | | `Sunset` | [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594){:target="\_blank"} | An HTTP date after which the endpoint may stop responding. Present only when a date has been set. | | `Link` | — | A link to the replacement, with `rel="successor-version"`, e.g. `Link: ; rel="successor-version"`. | Watch for the `Deprecation` header in your responses and migrate to the resource named by the `Link` successor before the `Sunset` date, if one is given. ## Specification flags Deprecated operations and fields are also marked in our [OpenAPI and Swagger specifications](/reference/sdks) with the standard `deprecated: true` flag. Code generators (including the ones that build our [SDKs](/reference/sdks)) surface these as deprecation warnings, so deprecated fields and methods are flagged in your editor at build time. Avoid deprecated operations and fields in new code, and prefer the successor the specification or the `Link` header points to. ## Working with Custom & Original Fields URL: https://docs.unified.to/reference/fields # Working with Custom & Original Fields When working with integrations, you often need control over which fields to receive or send. This guide explains how to work with both standard and custom fields. The same fields options apply to webhook payloads, for native and virtual webhooks. ## Choosing how to access vendor-specific data The unified API reads and writes the fields in Unified.to's data model. For data outside that model, use these options in order: 1. **Metadata API.** Where an integration supports it, vendor custom fields are available through the Metadata API (see [Metadata](/metadata/overview)). Check the integration's Feature Support tab. 2. **Raw fields.** Read the vendor's original data and write vendor-specific values using `raw`, as described below. 3. **Passthrough.** For vendor endpoints that aren't part of the unified API, use Passthrough (see [Passthrough](/passthrough/overview)). Contact us before building on Passthrough, since most gaps can be closed by adding the capability to the unified API. The outer limit is the vendor's own API: if the vendor doesn't support a write, no route through Unified.to will. ## Requesting Specific Fields By default, API calls return all available fields for an object. However, you can optimize your requests by specifying only the fields you need using the `fields` parameter: Some upstream APIs also return **slow fields** that require additional API calls per record (for example, Greenhouse application `offers` or `raw.offers`). These can be excluded or included by default at the workspace level and overridden per request using `fields`. For details, see [Slow fields](/reference/slow-fields). ``` GET /crm/{connection_id}/contact?fields=id,name,emails ``` ## Accessing Original Data (Raw Fields) ### Reading Original Data Sometimes you need access to the original, unmodified data from an integration (for example, to access platform-specific fields). To receive this data: 1. Include `raw` in your `fields` parameter 2. The response will include a `raw` object containing the original data from the source ``` GET /crm/{connection_id}/contact?fields=id,name,emails,raw ``` ## Writing Original Data When creating or updating records, you can include integration-specific data that isn't part of our unified data model: ``` { "name": "John Doe", "raw": { "custom_integration_field": "value" } } ``` ## Accessing the original ID of the object Usually, Unified.to will return the object's ID in the `id` field. But sometimes, Unified.to is required to transform that original ID with additional information. If you require the original object's ID (for example, using our Passthrough API), it will be available in the `raw.__id` field. ## Working with Custom Original Fields Some CRM platforms (like HubSpot and Salesforce) require specific fields to be explicitly included in the API call i.e., they won't be returned if you only added `raw` to the request parameters. If your app requires specific fields in this way ("custom fields"): 1. Include `raw` in your fields parameter 2. Add the specific custom fields you need with the `raw.` prefix For example, to request custom fields named `leadFunnelStage` and `customerSegment`: ``` GET /crm/{connection_id}/contact?fields=id,name,raw,raw.leadFunnelStage,raw.customerSegment ``` ## Mock API Server URL: https://docs.unified.to/reference/mock # Mock API Server Unified.to has a "mock server" that uses pre-generated synthentic data that emulates the data available in any of our integrations. ## How to call the mock server You can call any REST API endpoint with a `X-Mock: 1` header. Make sure to have an empty `Authorization` header and use URL IDs and payload data that are properly formatted (e.g. ID=5de520f96e439b002043d8dc). ## Sandbox environment You can also use the "Sandbox" environment when making API calls as that will also use the Mock API Server. `?env=Sandbox` ## Pagination URL: https://docs.unified.to/reference/pagination # Pagination All integrations use the same pagination parameters. But not all integrations support all of the pagination parameters. Please see the Feature Support tab in the integration's page in app.unified.to. ## Limit By default, most list endpoints return a maximum of 100 records per page. You can change the number of records on a per-request basis by passing a `limit` parameter (in the request URL parameters for REST). Example: `limit=50` However, you can't exceed 100 records per page on most endpoints. You will know that there aren't any more records, when the number of returned results is less than your requested `limit`. ## Offset When the response exceeds the requested results, you can paginate through the records by padding a `offset` parameter that specifies the position of the first result. This parameter is zero-based regardless of integration's paging mechanism. ## Total Count When the underlying integration reports a total count for a list, that total is returned in the `X-Total-Count` response header. The response body itself stays a plain array of records; the total is a header only. Not every integration reports a total, so the `X-Total-Count` header is only present when the count is known. When it is absent, keep paging until the number of returned results is less than your requested `limit` (see [Limit](#limit) above) to detect the last page. The value is reported by the integration and reflects the count on the provider's side. It is a hint, not a guarantee: when Unified.to applies additional filtering to the page (for example, dropping records that don't match a requested field), the number of records you actually receive can be smaller than `X-Total-Count`. Treat it as an approximate upper bound for progress indicators rather than an exact record count, and still rely on the paging rule above to detect the last page. ## Filtering with Updated Since, Start, or End You can filter the list by specifying the minimum updated date `updated_gte`. This parameter returns lists that have their updated date at of greater than the specific value. For some integrations, you can also use `start_gte` and `end_le` to filter results for that timeframe. All dates are in a `YYYY-MM-DDTHH:MM:SSZ` format. You can leave off the time and the timezone if you like. The default timezone is `UTC`. ## Filtering with Query You can filter the list by specifying a `query` parameter. The search will be integration-specific, but will only be used to filter by `email` or `name`. eg. `query=John` `query=john@example.com`. If you require additional filtering, you must synchronize your customer's data into your own database where you would then be able to filter on any field. Unified.to STRONGLY recommends that you synchronize data if you plan on doing queries. It will be much faster, you will be able to query using whatever criteria you want, and the queries will be consistent and not integration-specific. We recommend using our webhooks for synchronization. ## Sorting & Ordering You can sort the results the `sort` parameter. Valid options include `name`, `updated_at`, and `created_at`. You can order order the results with the `order` parameter which only has two options: `asc` and `desc`. ## Rate limits URL: https://docs.unified.to/reference/rate_limits # Rate limits Rate limiting is an important aspect of working with APIs that helps maintain system stability and ensure fair usage across all users. This guide explains how rate limiting works at Unified.to and how it affects your API usage. ## Background Historically, rate limiting has been a necessary part of API design to: - Prevent server overload from too many simultaneous requests - Ensure fair resource distribution among all users - Protect APIs from abuse or unintended high-volume usage In Unified.to's case, we act as a real-time intermediary between your application and various API platforms. While Unified.to has its own set of rate limits, we also need to respect and enforce the limits set by each API platform to maintain stable integrations. ## How are rate limits determined at Unified.to? Rate limits at Unified.to are determined by two factors: 1. **SaaS platform limits**: Each underlying platform (like HubSpot, Salesforce, etc.) has its own rate limits that determine how many API calls you can make within a given time period. These limits vary by platform and by your customer's plan with that platform. These are the primary limiting factor as we must respect these limitations to maintain stable integrations. 2. **Unified.to limits**: We also implement our own rate limits to ensure fair usage across all users. However, these are generally more generous than the platform-specific limits. When you exceed Unified.to's limit, the 429 response has the message "Too many requests to unified.to API; max requests per minute: N", where N is your plan's limit. You can find the specific rate limits for each integration under the **Feature Support** tab on https://app.unified.to/. When you hit a rate limit, you'll receive a 429 (Too Many Requests) response. ## Unified.to limits by plan Unified.to's limit is measured in requests per minute and shared across all connections in your workspace. It is not applied per connection. | Plan | Requests per minute | | ----- | ------------------- | | Test | 500 | | Grow | 5,000 | | Pro | 7,500 | | Scale | 10,000 | In most cases, customers reach an integration's rate limit rather than Unified.to's. ## Handle rate limits yourself Unified.to doesn't queue or retry synchronous API requests on your behalf. When making direct API calls, you'll need to implement your own rate limit handling strategy. The most common approach is to use a backoff and retry mechanism: ### Backoff and retry strategy When you receive a rate limit error (HTTP 429), your application should: 1. Temporarily pause making requests 2. Wait for a period of time 3. Retry the request The waiting period typically follows an exponential backoff pattern, where each subsequent retry waits longer than the previous one. For example: - First retry: Wait 1 second - Second retry: Wait 2 seconds - Third retry: Wait 4 seconds - Fourth retry: Wait 8 seconds This exponential increase helps prevent overwhelming the API while still maintaining functionality. ### Best practices for handling rate limits - Add randomness (jitter) to your retry delays to prevent multiple clients from retrying simultaneously - Set a maximum number of retry attempts - Log rate limit occurrences to help identify patterns and adjust your strategy - Consider implementing a request queue to manage high-volume operations ## Use webhooks to avoid worrying about rate limits While you can implement your own rate limit handling, we strongly recommend using our webhooks instead. Here's why: - **Easier and faster integration build time**: Unified.to manages all rate limiting, backoff, and retry logic for you. You can also get all existing data via an initial sync, too - **Efficient resource usage**: Instead of polling for changes, you receive updates only when they occur - **Reliable delivery**: We'll keep trying to deliver webhook events even if your endpoint is temporarily unavailable - **Automatic scaling**: Our webhook system automatically adjusts to rate limits across different providers You can read more about our webhooks retry mechanism [here](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks#a-note-on-our-webhook-retry-mechanism). ## Related resources - [Understanding webhooks](https://docs.unified.to/reference/webhooks) - [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks) - [Pagination](https://docs.unified.to/reference/pagination) ## REST API URL: https://docs.unified.to/reference/rest # REST API This API is organized around [REST](http://en.wikipedia.org/wiki/Representational_State_Transfer){:target="\_blank"} and utilizes predictable resource-oriented URLs, accepts various request formats, returns responses in multiple formats, and uses standard HTTP response codes, authentication, and verbs. It provides predictable URLs for accessing resources and uses built-in HTTP features to receive commands and return responses. This makes it easy to communicate with from a wide variety of environments, from command-line utilities to gadgets to the browser URL bar itself. The API accepts JSON in requests and multiple content types for responses, including JSON, CSV, XML, NDJSON, and SCIM+JSON. Only the UTF-8 character encoding is supported for both requests and responses. ## Authentication Send your [API token](https://app.unified.to/settings/api){:target="\_blank"} in the `Authorization` HTTP header for each API request. Example: `Authorization: Bearer eyJhbGciOiJIUzI1NiIXVCJ9` All API requests must be made over [HTTPS](http://en.wikipedia.org/wiki/HTTP_Secure){:target="\_blank"}. API requests made over plain HTTP or without authentication will fail. Furthermore, all connections must support the TLS 1.2 version for their SSL/HTTPS protocol. Find your workspace API Token at [app.unified.to/settings/api](https://app.unified.to/settings/api){target="\_blank"} **Warning:** Using a service API key on your workspace is a security issue, because there is no audit trail of who performed an action made with that key. Every request is attributed to the key rather than to an individual person, so you cannot trace which team member made a change. ## API URLs Unified has two separate data-regions around the world currently. To use the API URL in a data-region, you must register an account with our application in that data region. | Region | API URL | Application URL | | ------------- | ------------------------- | ------------------------- | | North America | https://api.unified.to | https://app.unified.to | | Europe | https://api-eu.unified.to | https://app-eu.unified.to | | Australia | https://api-au.unified.to | https://app-au.unified.to | ## HTTP Methods | Method | Description | | ------------------- | ------------------------------------------------------- | | **GET** | Retrieve a single resource or get an array of resources | | **PUT** / **PATCH** | Update a single resource partially or fully | | **POST** | Create a single resource | | **DELETE** | Remove a single resource | ## Request Format This API accepts [JSON](https://www.json.org/){:target="\_blank"} payloads. You must supply a `Content-Type: application/json` header in PUT and POST requests. The documented JSON properties for this API are case sensitive. For example, `id` and `Id` would not constitute the same property. ## Response Format This API can return responses in multiple formats. You can specify your preferred response format by setting the `Accept` header in your request. The supported response formats are: | Format | Header | | ------------------------------- | ------------------------------- | | JSON (default) | `Accept: application/json` | | NDJSON (Newline Delimited JSON) | `Accept: application/ndjson` | | CSV (Comma-Separated Values) | `Accept: text/csv` | | XML | `Accept: text/xml` | | SCIM+JSON (for SCIM endpoints) | `Accept: application/scim+json` | If no `Accept` header is specified, the API will default to returning JSON responses. When you successfully create or update a resource, the API will respond with the resulting resource in the requested format. This API responds to successful requests with HTTP status codes in the 200 or 300 range, and with HTTP status codes in the 400 range if an error occurred. A list of objects is sent as an array of objects in JSON and NDJSON formats, as rows in CSV format, or as nested elements in XML format. All other operations return a single object. Note: The SCIM+JSON format is only available for specific endpoints (e.g. employee and group). ## Common HTTP Status Codes | Code | Description | | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **200** | Ok: The request was successful. | | **400** | Bad Request: Required parameters are missing or in the wrong format. Check the request payload against the API documentation. | | **401** | Unauthorized: The connection is likely broken and requires recreation. This typically occurs when app access has been revoked or authentication credentials are invalid. | | **403** | Forbidden: The connection lacks the required permissions or scopes. This usually indicates a mismatch between the scopes configured in your provider's developer account and what Unified.to is requesting. | | **404** | Not Found: The requested resource was not found. | | **429** | Rate Limit Exceeded: The provider's API rate limit has been reached. Each platform (and customer plan) has different rate limits. Consider using webhooks or implementing request throttling. | | **500** | Internal Server Error: Our server encountered an unexpected condition. We monitor these errors and will work to resolve them. For platform issues, retry after a short delay. | | **501** | Not Implemented: The requested functionality is not supported by this integration. Check the integration's documentation for supported features. | For more help with error codes, review our [troubleshooting guide](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_connections). ## Error Responses Error responses follow [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457){:target="\_blank"} and are returned with a `Content-Type: application/problem+json` header. The body includes the standard Problem Details members: | Field | Description | | ---------- | -------------------------------------------------------------------------------------------- | | `type` | A URI reference identifying the problem type. Defaults to `about:blank`. | | `title` | A short, human-readable summary of the problem type. | | `status` | The HTTP status code for this occurrence. | | `detail` | A human-readable explanation specific to this occurrence. | | `instance` | A URI reference (the request path) identifying this specific occurrence. | | `errors` | An array of `{ name, reason }` objects naming the field(s) that failed validation, when applicable. | For backwards compatibility, every error body also retains the legacy `statusCode`, `error`, and `message` fields, so existing clients continue to work unchanged. Example (a `400` validation error): ```json { "type": "about:blank", "title": "Bad Request", "status": 400, "detail": "\"age\" must be a positive integer", "instance": "/crm/CONNECTION_ID/contact", "errors": [{ "name": "age", "reason": "\"age\" must be a positive integer" }], "statusCode": 400, "error": "Bad Request", "message": "\"age\" must be a positive integer" } ``` Read the `errors` array to learn exactly which field failed and why, fix that field, and retry. ## SDKs URL: https://docs.unified.to/reference/sdks # SDKs Use the following SDKs to access our API. Note that each SDK will use that language's standard naming convention for field names. for example, TypeScript uses CamelCase, but all of our documenation uses snake_case. - **Typescript:** [npmjs.com/package/@unified-api/typescript-sdk](https://www.npmjs.com/package/@unified-api/typescript-sdk){:target="\_blank"} ::sdk-version{name="typescript"} :: - **Python:** [github.com/unified-to/unified-python-sdk](https://github.com/unified-to/unified-python-sdk){:target="\_blank"} ::sdk-version{name="Python"} :: - **PHP:** [github.com/unified-to/unified-php-sdk](https://github.com/unified-to/unified-php-sdk){:target="\_blank"} ::sdk-version{name="php"} :: - **Java:** [central.sonatype.com/artifact/to.unified/unified-java-sdk](https://central.sonatype.com/artifact/to.unified/unified-java-sdk){:target="\_blank"} ::sdk-version{name="java"} :: - **Go:** [github.com/unified-to/unified-go-sdk](https://github.com/unified-to/unified-go-sdk){:target="\_blank"} ::sdk-version{name="go"} :: - **C#:** [nuget.org/packages/UnifiedTo](https://www.nuget.org/packages/UnifiedTo){:target="\_blank"} ::sdk-version{name="csharp"} :: - **Ruby:** [github.com/unified-to/unified-ruby-sdk](https://github.com/unified-to/unified-ruby-sdk){:target="\_blank"} ::sdk-version{name="ruby"} :: - **Other:** You can generate SDKs in other languages by utilizing our [openapi.json](https://api.unified.to/openapi.json){:target="\_blank"} with tools like [OpenAPI Generator](https://openapi-generator.tech/){:target="\_blank"}. # Specification Files Access our API specifications in various formats to integrate with your preferred tools and workflows. They are updated, at least, daily. - **OpenAPI specification:** [openapi.json](https://api.unified.to/openapi.json){:target="\_blank"} / [openapi.yaml](https://api.unified.to/openapi.yaml){:target="\_blank"} ::callout The openapi.json/.yaml file contains our API specification in OpenAPI 3.0 format - the modern standard for REST API documentation. This specification offers enhanced features over Swagger 2.0, including improved security scheme definitions and more flexible parameter descriptions. It's great for generating modern API documentation, client SDKs, and mock servers. You can download one [openapi.json](https://api.unified.to/openapi.json){:target="\_blank"} for all of our Unified APIs, or you can download a openapi.json for each API. - [openapi-accounting.json](https://api.unified.to/docs/openapi-accounting.json){:target="\_blank"} / [openapi-accounting.yaml](https://api.unified.to/docs/openapi-accounting.yaml){:target="\_blank"} - [openapi-ads.json](https://api.unified.to/docs/openapi-ads.json){:target="\_blank"} / [openapi-ads.yaml](https://api.unified.to/docs/openapi-ads.yaml){:target="\_blank"} - [openapi-analytics.json](https://api.unified.to/docs/openapi-analytics.json){:target="\_blank"} / [openapi-analytics.yaml](https://api.unified.to/docs/openapi-analytics.yaml){:target="\_blank"} - [openapi-assessment.json](https://api.unified.to/docs/openapi-assessment.json){:target="\_blank"} / [openapi-assessment.yaml](https://api.unified.to/docs/openapi-assessment.yaml){:target="\_blank"} - [openapi-ats.json](https://api.unified.to/docs/openapi-ats.json){:target="\_blank"} / [openapi-ats.yaml](https://api.unified.to/docs/openapi-ats.yaml){:target="\_blank"} - [openapi-auth.json](https://api.unified.to/docs/openapi-auth.json){:target="\_blank"} / [openapi-auth.yaml](https://api.unified.to/docs/openapi-auth.yaml){:target="\_blank"} - [openapi-calendar.json](https://api.unified.to/docs/openapi-calendar.json){:target="\_blank"} / [openapi-calendar.yaml](https://api.unified.to/docs/openapi-calendar.yaml){:target="\_blank"} - [openapi-cdp.json](https://api.unified.to/docs/openapi-cdp.json){:target="\_blank"} / [openapi-cdp.yaml](https://api.unified.to/docs/openapi-cdp.yaml){:target="\_blank"} - [openapi-clubs.json](https://api.unified.to/docs/openapi-clubs.json){:target="\_blank"} / [openapi-clubs.yaml](https://api.unified.to/docs/openapi-clubs.yaml){:target="\_blank"} - [openapi-commerce.json](https://api.unified.to/docs/openapi-commerce.json){:target="\_blank"} / [openapi-commerce.yaml](https://api.unified.to/docs/openapi-commerce.yaml){:target="\_blank"} - [openapi-crm.json](https://api.unified.to/docs/openapi-crm.json){:target="\_blank"} / [openapi-crm.yaml](https://api.unified.to/docs/openapi-crm.yaml){:target="\_blank"} - [openapi-datastore.json](https://api.unified.to/docs/openapi-datastore.json){:target="\_blank"} / [openapi-datastore.yaml](https://api.unified.to/docs/openapi-datastore.yaml){:target="\_blank"} - [openapi-enrich.json](https://api.unified.to/docs/openapi-enrich.json){:target="\_blank"} / [openapi-enrich.yaml](https://api.unified.to/docs/openapi-enrich.yaml){:target="\_blank"} - [openapi-forms.json](https://api.unified.to/docs/openapi-forms.json){:target="\_blank"} / [openapi-forms.yaml](https://api.unified.to/docs/openapi-forms.yaml){:target="\_blank"} - [openapi-genai.json](https://api.unified.to/docs/openapi-genai.json){:target="\_blank"} / [openapi-genai.yaml](https://api.unified.to/docs/openapi-genai.yaml){:target="\_blank"} - [openapi-hris.json](https://api.unified.to/docs/openapi-hris.json){:target="\_blank"} / [openapi-hris.yaml](https://api.unified.to/docs/openapi-hris.yaml){:target="\_blank"} - [openapi-kms.json](https://api.unified.to/docs/openapi-kms.json){:target="\_blank"} / [openapi-kms.yaml](https://api.unified.to/docs/openapi-kms.yaml){:target="\_blank"} - [openapi-lms.json](https://api.unified.to/docs/openapi-lms.json){:target="\_blank"} / [openapi-lms.yaml](https://api.unified.to/docs/openapi-lms.yaml){:target="\_blank"} - [openapi-martech.json](https://api.unified.to/docs/openapi-martech.json){:target="\_blank"} / [openapi-martech.yaml](https://api.unified.to/docs/openapi-martech.yaml){:target="\_blank"} - [openapi-messaging.json](https://api.unified.to/docs/openapi-messaging.json){:target="\_blank"} / [openapi-messaging.yaml](https://api.unified.to/docs/openapi-messaging.yaml){:target="\_blank"} - [openapi-metadata.json](https://api.unified.to/docs/openapi-metadata.json){:target="\_blank"} / [openapi-metadata.yaml](https://api.unified.to/docs/openapi-metadata.yaml){:target="\_blank"} - [openapi-passthrough.json](https://api.unified.to/docs/openapi-passthrough.json){:target="\_blank"} / [openapi-passthrough.yaml](https://api.unified.to/docs/openapi-passthrough.yaml){:target="\_blank"} - [openapi-payment.json](https://api.unified.to/docs/openapi-payment.json){:target="\_blank"} / [openapi-payment.yaml](https://api.unified.to/docs/openapi-payment.yaml){:target="\_blank"} - [openapi-performance.json](https://api.unified.to/docs/openapi-performance.json){:target="\_blank"} / [openapi-performance.yaml](https://api.unified.to/docs/openapi-performance.yaml){:target="\_blank"} - [openapi-repo.json](https://api.unified.to/docs/openapi-repo.json){:target="\_blank"} / [openapi-repo.yaml](https://api.unified.to/docs/openapi-repo.yaml){:target="\_blank"} - [openapi-scim.json](https://api.unified.to/docs/openapi-scim.json){:target="\_blank"} / [openapi-scim.yaml](https://api.unified.to/docs/openapi-scim.yaml){:target="\_blank"} - [openapi-shipping.json](https://api.unified.to/docs/openapi-shipping.json){:target="\_blank"} / [openapi-shipping.yaml](https://api.unified.to/docs/openapi-shipping.yaml){:target="\_blank"} - [openapi-signing.json](https://api.unified.to/docs/openapi-signing.json){:target="\_blank"} / [openapi-signing.yaml](https://api.unified.to/docs/openapi-signing.yaml){:target="\_blank"} - [openapi-social.json](https://api.unified.to/docs/openapi-social.json){:target="\_blank"} / [openapi-social.yaml](https://api.unified.to/docs/openapi-social.yaml){:target="\_blank"} - [openapi-storage.json](https://api.unified.to/docs/openapi-storage.json){:target="\_blank"} / [openapi-storage.yaml](https://api.unified.to/docs/openapi-storage.yaml){:target="\_blank"} - [openapi-task.json](https://api.unified.to/docs/openapi-task.json){:target="\_blank"} / [openapi-task.yaml](https://api.unified.to/docs/openapi-task.yaml){:target="\_blank"} - [openapi-ticketing.json](https://api.unified.to/docs/openapi-ticketing.json){:target="\_blank"} / [openapi-ticketing.yaml](https://api.unified.to/docs/openapi-ticketing.yaml){:target="\_blank"} - [openapi-uc.json](https://api.unified.to/docs/openapi-uc.json){:target="\_blank"} / [openapi-uc.yaml](https://api.unified.to/docs/openapi-uc.yaml){:target="\_blank"} - [openapi-verification.json](https://api.unified.to/docs/openapi-verification.json){:target="\_blank"} / [openapi-verification.yaml](https://api.unified.to/docs/openapi-verification.yaml){:target="\_blank"} :: - **Swagger specification:** [swagger.json](https://api.unified.to/swagger.json){:target="\_blank"} / [swagger.yaml](https://api.unified.to/swagger.yaml) ::callout Our swagger.json/.yaml file provides a detailed API specification in the Swagger 2.0 format. It's ideal for developers who are working with older tools or platforms that haven't yet migrated to OpenAPI 3.0. Use this specification to generate client libraries, automate testing, and integrate with legacy systems. :: - **Postman collection:** [collection.json](https://api.unified.to/collection.json){:target="\_blank"} ::callout Our `collection.json` file is a ready-to-use Postman collection that lets you start testing our API endpoints immediately. Import this file into Postman to get a complete set of pre-configured API requests. It's an excellent resource for API exploration and testing without writing any code. You can download one [collection.json](https://api.unified.to/collection.json){:target="\_blank"} for all of our Unified APIs, or you can download a collection.json for each API. - [collection-accounting.json](https://api.unified.to/docs/collection-accounting.json){:target="\_blank"} - [collection-ads.json](https://api.unified.to/docs/collection-ads.json){:target="\_blank"} - [collection-analytics.json](https://api.unified.to/docs/collection-analytics.json){:target="\_blank"} - [collection-assessment.json](https://api.unified.to/docs/collection-assessment.json){:target="\_blank"} - [collection-ats.json](https://api.unified.to/docs/collection-ats.json){:target="\_blank"} - [collection-auth.json](https://api.unified.to/docs/collection-auth.json){:target="\_blank"} - [collection-calendar.json](https://api.unified.to/docs/collection-calendar.json){:target="\_blank"} - [collection-cdp.json](https://api.unified.to/docs/collection-cdp.json){:target="\_blank"} - [collection-clubs.json](https://api.unified.to/docs/collection-clubs.json){:target="\_blank"} - [collection-commerce.json](https://api.unified.to/docs/collection-commerce.json){:target="\_blank"} - [collection-crm.json](https://api.unified.to/docs/collection-crm.json){:target="\_blank"} - [collection-datastore.json](https://api.unified.to/docs/collection-datastore.json){:target="\_blank"} - [collection-enrich.json](https://api.unified.to/docs/collection-enrich.json){:target="\_blank"} - [collection-forms.json](https://api.unified.to/docs/collection-forms.json){:target="\_blank"} - [collection-genai.json](https://api.unified.to/docs/collection-genai.json){:target="\_blank"} - [collection-hris.json](https://api.unified.to/docs/collection-hris.json){:target="\_blank"} - [collection-kms.json](https://api.unified.to/docs/collection-kms.json){:target="\_blank"} - [collection-lms.json](https://api.unified.to/docs/collection-lms.json){:target="\_blank"} - [collection-martech.json](https://api.unified.to/docs/collection-martech.json){:target="\_blank"} - [collection-messaging.json](https://api.unified.to/docs/collection-messaging.json){:target="\_blank"} - [collection-metadata.json](https://api.unified.to/docs/collection-metadata.json){:target="\_blank"} - [collection-passthrough.json](https://api.unified.to/docs/collection-passthrough.json){:target="\_blank"} - [collection-payment.json](https://api.unified.to/docs/collection-payment.json){:target="\_blank"} - [collection-performance.json](https://api.unified.to/docs/collection-performance.json){:target="\_blank"} - [collection-repo.json](https://api.unified.to/docs/collection-repo.json){:target="\_blank"} - [collection-scim.json](https://api.unified.to/docs/collection-scim.json){:target="\_blank"} - [collection-shipping.json](https://api.unified.to/docs/collection-shipping.json){:target="\_blank"} - [collection-signing.json](https://api.unified.to/docs/collection-signing.json){:target="\_blank"} - [collection-social.json](https://api.unified.to/docs/collection-social.json){:target="\_blank"} - [collection-storage.json](https://api.unified.to/docs/collection-storage.json){:target="\_blank"} - [collection-task.json](https://api.unified.to/docs/collection-task.json){:target="\_blank"} - [collection-ticketing.json](https://api.unified.to/docs/collection-ticketing.json){:target="\_blank"} - [collection-uc.json](https://api.unified.to/docs/collection-uc.json){:target="\_blank"} - [collection-verification.json](https://api.unified.to/docs/collection-verification.json){:target="\_blank"} Visit the Unified postman workspace at [Postman](https://www.postman.com/unified-to/unified-api-workspace/overview){:target="\_blank"}. :: - **Supabase SQL DDL:** [Unified API's DDL](https://api.unified.to/docs/supabase-ddl.sql){:target="\_blank"} ::callout You can use our SQL DDL to create databases in Supabase matching our data-model schemas. You can download one [DDL SQL](https://api.unified.to/docs/supabase-ddl.sql){:target="\_blank"} for all of our Unified APIs, or you can download a DDL SQL for each API, which will contain just the data models for that API category. - [supabase-ddl-accounting.sql](https://api.unified.to/docs/supabase-ddl-accounting.sql){:target="\_blank"} - [supabase-ddl-ads.sql](https://api.unified.to/docs/supabase-ddl-ads.sql){:target="\_blank"} - [supabase-ddl-analytics.sql](https://api.unified.to/docs/supabase-ddl-analytics.sql){:target="\_blank"} - [supabase-ddl-ats.sql](https://api.unified.to/docs/supabase-ddl-ats.sql){:target="\_blank"} - [supabase-ddl-calendar.sql](https://api.unified.to/docs/supabase-ddl-calendar.sql){:target="\_blank"} - [supabase-ddl-cdp.sql](https://api.unified.to/docs/supabase-ddl-cdp.sql){:target="\_blank"} - [supabase-ddl-clubs.sql](https://api.unified.to/docs/supabase-ddl-clubs.sql){:target="\_blank"} - [supabase-ddl-commerce.sql](https://api.unified.to/docs/supabase-ddl-commerce.sql){:target="\_blank"} - [supabase-ddl-crm.sql](https://api.unified.to/docs/supabase-ddl-crm.sql){:target="\_blank"} - [supabase-ddl-datastore.sql](https://api.unified.to/docs/supabase-ddl-datastore.sql){:target="\_blank"} - [supabase-ddl-forms.sql](https://api.unified.to/docs/supabase-ddl-forms.sql){:target="\_blank"} - [supabase-ddl-genai.sql](https://api.unified.to/docs/supabase-ddl-genai.sql){:target="\_blank"} - [supabase-ddl-hris.sql](https://api.unified.to/docs/supabase-ddl-hris.sql){:target="\_blank"} - [supabase-ddl-kms.sql](https://api.unified.to/docs/supabase-ddl-kms.sql){:target="\_blank"} - [supabase-ddl-lms.sql](https://api.unified.to/docs/supabase-ddl-lms.sql){:target="\_blank"} - [supabase-ddl-martech.sql](https://api.unified.to/docs/supabase-ddl-martech.sql){:target="\_blank"} - [supabase-ddl-messaging.sql](https://api.unified.to/docs/supabase-ddl-messaging.sql){:target="\_blank"} - [supabase-ddl-metadata.sql](https://api.unified.to/docs/supabase-ddl-metadata.sql){:target="\_blank"} - [supabase-ddl-payment.sql](https://api.unified.to/docs/supabase-ddl-payment.sql){:target="\_blank"} - [supabase-ddl-performance.sql](https://api.unified.to/docs/supabase-ddl-performance.sql){:target="\_blank"} - [supabase-ddl-repo.sql](https://api.unified.to/docs/supabase-ddl-repo.sql){:target="\_blank"} - [supabase-ddl-scim.sql](https://api.unified.to/docs/supabase-ddl-scim.sql){:target="\_blank"} - [supabase-ddl-shipping.sql](https://api.unified.to/docs/supabase-ddl-shipping.sql){:target="\_blank"} - [supabase-ddl-signing.sql](https://api.unified.to/docs/supabase-ddl-signing.sql){:target="\_blank"} - [supabase-ddl-social.sql](https://api.unified.to/docs/supabase-ddl-social.sql){:target="\_blank"} - [supabase-ddl-storage.sql](https://api.unified.to/docs/supabase-ddl-storage.sql){:target="\_blank"} - [supabase-ddl-task.sql](https://api.unified.to/docs/supabase-ddl-task.sql){:target="\_blank"} - [supabase-ddl-ticketing.sql](https://api.unified.to/docs/supabase-ddl-ticketing.sql){:target="\_blank"} - [supabase-ddl-uc.sql](https://api.unified.to/docs/supabase-ddl-uc.sql){:target="\_blank"} - [supabase-ddl-verification.sql](https://api.unified.to/docs/supabase-ddl-verification.sql){:target="\_blank"} :: - **Snowflake SQL DDL:** [Unified API's DDL](https://api.unified.to/docs/snowflake-ddl.sql){:target="\_blank"} ::callout You can use our SQL DDL to create tables in Snowflake matching our data-model schemas. You can download one [DDL SQL](https://api.unified.to/docs/snowflake-ddl.sql){:target="\_blank"} for all of our Unified APIs, or you can download a DDL SQL for each API, which will contain just the data models for that API category. - [snowflake-ddl-accounting.sql](https://api.unified.to/docs/snowflake-ddl-accounting.sql){:target="\_blank"} - [snowflake-ddl-ads.sql](https://api.unified.to/docs/snowflake-ddl-ads.sql){:target="\_blank"} - [snowflake-ddl-analytics.sql](https://api.unified.to/docs/snowflake-ddl-analytics.sql){:target="\_blank"} - [snowflake-ddl-ats.sql](https://api.unified.to/docs/snowflake-ddl-ats.sql){:target="\_blank"} - [snowflake-ddl-calendar.sql](https://api.unified.to/docs/snowflake-ddl-calendar.sql){:target="\_blank"} - [snowflake-ddl-cdp.sql](https://api.unified.to/docs/snowflake-ddl-cdp.sql){:target="\_blank"} - [snowflake-ddl-clubs.sql](https://api.unified.to/docs/snowflake-ddl-clubs.sql){:target="\_blank"} - [snowflake-ddl-commerce.sql](https://api.unified.to/docs/snowflake-ddl-commerce.sql){:target="\_blank"} - [snowflake-ddl-crm.sql](https://api.unified.to/docs/snowflake-ddl-crm.sql){:target="\_blank"} - [snowflake-ddl-datastore.sql](https://api.unified.to/docs/snowflake-ddl-datastore.sql){:target="\_blank"} - [snowflake-ddl-forms.sql](https://api.unified.to/docs/snowflake-ddl-forms.sql){:target="\_blank"} - [snowflake-ddl-genai.sql](https://api.unified.to/docs/snowflake-ddl-genai.sql){:target="\_blank"} - [snowflake-ddl-hris.sql](https://api.unified.to/docs/snowflake-ddl-hris.sql){:target="\_blank"} - [snowflake-ddl-kms.sql](https://api.unified.to/docs/snowflake-ddl-kms.sql){:target="\_blank"} - [snowflake-ddl-lms.sql](https://api.unified.to/docs/snowflake-ddl-lms.sql){:target="\_blank"} - [snowflake-ddl-martech.sql](https://api.unified.to/docs/snowflake-ddl-martech.sql){:target="\_blank"} - [snowflake-ddl-messaging.sql](https://api.unified.to/docs/snowflake-ddl-messaging.sql){:target="\_blank"} - [snowflake-ddl-metadata.sql](https://api.unified.to/docs/snowflake-ddl-metadata.sql){:target="\_blank"} - [snowflake-ddl-payment.sql](https://api.unified.to/docs/snowflake-ddl-payment.sql){:target="\_blank"} - [snowflake-ddl-performance.sql](https://api.unified.to/docs/snowflake-ddl-performance.sql){:target="\_blank"} - [snowflake-ddl-repo.sql](https://api.unified.to/docs/snowflake-ddl-repo.sql){:target="\_blank"} - [snowflake-ddl-scim.sql](https://api.unified.to/docs/snowflake-ddl-scim.sql){:target="\_blank"} - [snowflake-ddl-shipping.sql](https://api.unified.to/docs/snowflake-ddl-shipping.sql){:target="\_blank"} - [snowflake-ddl-signing.sql](https://api.unified.to/docs/snowflake-ddl-signing.sql){:target="\_blank"} - [snowflake-ddl-social.sql](https://api.unified.to/docs/snowflake-ddl-social.sql){:target="\_blank"} - [snowflake-ddl-storage.sql](https://api.unified.to/docs/snowflake-ddl-storage.sql){:target="\_blank"} - [snowflake-ddl-task.sql](https://api.unified.to/docs/snowflake-ddl-task.sql){:target="\_blank"} - [snowflake-ddl-ticketing.sql](https://api.unified.to/docs/snowflake-ddl-ticketing.sql){:target="\_blank"} - [snowflake-ddl-uc.sql](https://api.unified.to/docs/snowflake-ddl-uc.sql){:target="\_blank"} - [snowflake-ddl-verification.sql](https://api.unified.to/docs/snowflake-ddl-verification.sql){:target="\_blank"} :: - **Cloudflare Workers:** [github repo](https://github.com/unified-to/unified-cloudflare-worker){:target="\_blank"} ::callout You can use our API in a Cloudflare Worker by using our Cloudflare Worker template [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/unified-to/unified-cloudflare-worker){:target="\_blank"} :: ## Slow fields URL: https://docs.unified.to/reference/slow-fields # Slow fields Some fields in upstream APIs are expensive to fetch because they require extra API calls per record. We call these **slow fields**. Slow fields let you choose between **faster responses** and **more complete data**. You control the default behavior at the workspace level, and you can always override it per request with the `fields` query parameter. --- ## What are slow fields? **Slow fields** are resource fields that require additional API calls to fetch from the upstream provider. Examples: - A job application’s `offers` in Greenhouse requires a separate API call per application. - Nested raw objects may expose additional slow fields like `raw.offers`. Because these fields can be expensive to retrieve, we treat them specially: - They can be **excluded by default** to keep responses fast. - They can be **included when explicitly requested** using the `fields` parameter. - Or they can be **included by default** if you prefer completeness over latency. --- ## Workspace setting: Slow fields opt-in You can control how slow fields behave by default from your workspace settings in the Unified app under **Settings → Workspace → Slow fields opt-in** ([workspace settings](https://app.unified.to/settings/workspace)). - `false` (**Opt-out / recommended default**) - Slow fields are **excluded by default**. - Fewer upstream calls and lower latency. - Slow fields are only fetched when explicitly requested via `fields`. - `true` (**Opt-in**) - Slow fields are **included by default**. - Responses are more complete. - Responses can be slower due to additional API calls. You can still override this default on any request using the `fields` parameter. For a general overview of how `fields` works (including raw fields), see [Working with Custom & Original Fields](/reference/fields). --- ## How slow fields interact with `fields=…` You can control which fields are returned using the `fields` query parameter. The behavior depends on how you’ve configured **Slow fields opt-in** for your workspace. ### When Slow fields opt-in is set to **Opt-out** (default) Slow fields are excluded unless you request them: ```http GET /ats/{connection_id}/application ``` Returns standard (fast) fields only. ```http GET /ats/{connection_id}/application?fields=offers ``` Returns only the `offers` field (a slow field; this may trigger extra upstream API calls). ```http GET /ats/{connection_id}/application?fields=job_id,candidate_id ``` Returns only the requested fast fields (no slow fields). ### When Slow fields opt-in is set to **Opt-in** Slow fields are included unless you filter them out: ```http GET /ats/{connection_id}/application ``` Returns all fields, including slow fields (slower, more complete). ```http GET /ats/{connection_id}/application?fields=job_id,candidate_id ``` Returns only the requested fields (fast, excludes other slow fields). ```http GET /ats/{connection_id}/application?fields=job_id,offers ``` Returns `job_id` plus the `offers` slow field (may trigger extra upstream API calls). --- ## Raw objects and slow fields The same logic applies to nested raw objects: - `fields=raw.offers` Requests the slow `offers` field inside the raw payload. - `fields=raw.last_activity_at` Requests only a non-slow raw field (no extra API calls). Slow fields are always explicit: if you do not request them (and your workspace is not opted in by default), they will not be fetched. --- ## When to opt in vs opt out **Recommended patterns:** 1. **High‑volume or latency‑sensitive traffic** - Set **Slow fields opt-in** to **Opt-out**. - Only request slow fields when you actually need them, via `fields=…`. 2. **Comprehensive data syncs or backfills** - Set **Slow fields opt-in** to **Opt-in**, _or_ - Leave it as **Opt-out** and explicitly request all needed slow fields with `fields`. 3. **Webhooks and push-based flows** - Configure the `fields` parameter to include only the fields your consumer needs. - Avoid including slow fields unless your webhook handler can tolerate the extra latency. --- ## Identifying which fields are slow Slow fields are **integration- and object-specific**. For example: - **Greenhouse Applications**: `offers`, `raw.offers` Other integrations and object types may expose different slow fields. To see which fields are slow for a given object, check the integration-specific documentation or contact support. ## Introduction to webhooks URL: https://docs.unified.to/reference/webhooks # Webhooks This guide covers the basics of webhooks and their underlying mechanisms. For our webhook API reference, see: [Webhook API Overview](https://docs.unified.to/unified/webhook/model) To learn how to use webhooks, see: [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks) If you’re having trouble with webhooks, see: [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks) ## Background: What are webhooks? Webhooks allow servers to send real-time notifications to other servers when a specific event occurs. They are sometimes called “HTTP callbacks”. Webhooks enable instant communication between applications, making them crucial for maintaining data synchronization and triggering automated actions. For instance, Stripe's payment processing system uses webhooks to notify a merchant's server immediately when a customer completes a purchase, allowing for instant order fulfillment and inventory updates. Think of webhooks as a delivery service that brings packages directly to your doorstep. In contrast, traditional REST APIs are like repeatedly checking your mailbox to see if you've received any mail. With webhooks, you don't need to keep asking, "Is there any new mail?" Instead, the data comes to you as soon as it's available, saving time and resources. Webhooks can make your synchronization strategy less complicated since your application will receive updated data directly, eliminating the need for frequent polling and reducing the risk of missing important updates. ### Differences between polling for updates and receiving webhook events **Polling for updates (via APIs)** The traditional way of polling for new data from an API is by calling a `List` endpoint with something like a `updated_gte: Date` filter - this returns data that has been updated since that date. How often you poll for new data should be determined by how important that data is to your application as well as your budget. If the data isn’t time-sensitive i.e. you don’t need to have the data the moment it becomes available, then this strategy may be sufficient. But you need to take care of retry strategies when the API returns a rate limit error, which can make this method more complicated than using a webhook. You’ll also need to handle any network errors that could occur. **Webhook events** Modern APIs and applications use webhooks for synchronization because of their simplicity and timeliness. You, the app developer, do not have to create a retry mechanism to actively poll the server for new data or deal with rate-limiting and other network errors. The hard work is done by the third-party API provider (and us!) to send you updates as they come. With Unified.to, native webhooks forward events as the integration sends them; virtual webhooks deliver changes on the interval you set. ### How do webhooks work? Webhooks operate on a subscription model, where your application registers to receive updates about specific events from a third-party API provider or vendor. Here's a step-by-step breakdown of how webhooks typically work: 1. Create a webhook endpoint on your server that can receive POST requests. This endpoint will handle the incoming webhook data. 2. Subscribe to the events you want to monitor by registering your webhook endpoint's URL with the third-party API provider. This is usually done through the provider's developer dashboard or via their API. 3. Specify which types of events you want to receive notifications for. This could be anything from lead generation to payment confirmations to new deals, depending on the service. 4. Receive and process data. When a target event occurs, the provider sends a POST request to your registered endpoint. Your server receives this data in real-time and can process it according to your application's needs. 5. Handle the response. Your endpoint should respond promptly to the webhook request, typically with a 200 OK status, to acknowledge receipt. This helps prevent unnecessary retries from the provider. By following this process, your application can receive and react to important events as they happen, without the need for constant polling. ### Differences between Created and Updated events? `Created` events only trigger on newly created records. `Updated` events trigger on both updated and newly created records. Deleted events fire when a record is removed. They're supported on native webhooks, and on virtual webhooks where the integration supports it. ## Webhook payload This is the payload that your server receives when webhook data comes in: | Name | Type | Description | | --------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data` | _object array_ | An array of objects specific to the webhook's connection (e.g. CRM contacts). | | `webhook` | [_Webhook_](https://docs.unified.to/unified/webhook/model) | The webhook object. You can use the id to manage or delete your webhook. | | `nonce` | _string_ | Random string. | | `sig` | _base64 string_ | Cryptographic signature to ensure that the data is coming from Unified.to and hasn't been modified mid-stream. Uses SHA-1. `@deprecated` | | `sig256` | _base64 string_ | Cryptographic signature to ensure that the data is coming from Unified.to and hasn't been modified mid-stream. Uses SHA-256. | | `external_xref` | _string_ | When creating a connection, you can provide an `external_xref` field and this will be sent back to you in the associated webhook. This value represents the user or account that is signed into your application. | | `type` | _enum_ | `INITIAL-PARTIAL, INITIAL-COMPLETE, VIRTUAL, NATIVE` | ### Values for `type`: - `INITIAL-PARTIAL`: included with each page of results for the initial sync - `INITIAL-COMPLETE`: included with last page (e.g. last 5 elements for the limit 100) of initial sync or with an empty list (no more results) - `VIRTUAL`: included with every page when reading new data from a virtual webhook - `NATIVE`: included with every page when reading new data from a native webhook Note: the payload's `type` field uses uppercase values (VIRTUAL, NATIVE). The webhook object's `webhook_type` field uses lowercase (virtual, native). They are different fields. ### Best practices for signature validation: The `sig256` value is generated using the HMAC-SHA256 crypto method. The key is your workspace secret (found at [app.unified.to/settings/api](https://app.unified.to/settings/api)) while the contents are the combination of `data` and `nonce` i.e. `HMAC-SHA256(workspace.secret, data + nonce)` Things to keep in mind: 1. **Data**: The `data` in the signature calculation refers to the data in the payload body as outlined in the table above. 2. **Order of data**: When validating the signature, the order of the payload data should match the order in which it was received. In Golang, unmarshalling json to a `map[string]interface{}` will not respect the order, because a map in golang is a hashmap. When checking for the data body, be sure to use `json.RawMessage` when trying to unmarshal the `data` property in the payload first and use that to compose the signature. 3. **Avoid adding extra spaces**: When processing the payload, be cautious about introducing extra spaces. The original payload data is sent without extra spacing, and this should be maintained during validation. In Python, when you deserialize and then re-serialize the payload data, the resulting string may not match the original input. This is because the default serialization process often adds extra spaces between fields and objects, altering the format of the data. The following code snippet demonstrates how to perform signature validation in Python: ```python [validate_webhook.py] import hmac import hashlib import base64 import json from secrets import compare_digest from flask import Request def validate_webhook(request: Request): """ Validates webhook signature using HMAC-SHA256. HMAC-SHA256(secret, JSON.stringify(data.data) + data.nonce) """ data = request.get_json() # Full payload, includes `data`, `nonce`, `sig256` sig256 = data['sig256'] # Base64-encoded signature nonce = str(data['nonce']) # Ensure nonce is string serialized_data = json.dumps(data['data'], separators=(',', ':'), ensure_ascii=False) # Compact JSON format # Compute HMAC-SHA256 signature key = bytes(settings.UNIFIED_WORKSPACE_SECRET, 'UTF-8') message = bytes(serialized_data + nonce, 'UTF-8') digester = hmac.new(key, message, hashlib.sha256) digest = base64.b64encode(digester.digest()).decode() # Encode in Base64 # Use secure timing attack-resistant comparison return compare_digest(digest, sig256) ``` The example below demonstrates how to perform validation in Javascript/Typescript: ```typescript [validateWebhook.ts] import { createHmac, timingSafeEqual } from 'crypto'; function validateWebhook( workspaceSecret: string, data: { data: any[]; webhook: any; nonce: string; sig256: string; external_xref?: string; type?: 'INITIAL-PARTIAL' | 'INITIAL-COMPLETE' | 'VIRTUAL' | 'NATIVE'; } ): boolean { // JSON.stringify(data.data) should match the sender's encoding const serializedData = JSON.stringify(data.data); // Compute HMAC-SHA256 signature const computedSig = Buffer.from( createHmac('sha256', workspaceSecret) .update(serializedData) .update(data.nonce) .digest('base64'), 'utf8' ); // Decode provided signature const providedSig = Buffer.from(data.sig256, 'utf8'); // Use timing-safe comparison return computedSig.length === providedSig.length && timingSafeEqual(computedSig, providedSig); } ``` This example demonstrates how to perform validation in Go: ```python [validate_webhook.go] package main import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "fmt" ) type WebhookPayload struct { Data *json.RawMessage `json:"data"` ExternalXref string `json:"external_xref"` Nonce string `json:"nonce"` Sig256 string `json:"sig256"` Type string `json:"type"` Webhook Webhook `json:"webhook"` } type Webhook struct { CheckedAt string `json:"checked_at"` WorkspaceID string `json:"workspace_id"` } func main() { // This is an example webhook payload. You can replace this // with your request body. sig256 below is a placeholder, not a // valid HMAC-SHA256 signature for this payload. jsonString := `{\"data\":[{\"id\":\"1\",\"created_at\":\"2025-01-20T12:53:07.976Z\",\"parent_id\":\"2\",\"status\":\"active\",\"updated_at\":\"2025-01-20T12:56:46.118Z\"}],\"external_xref\":\"id=123&page=1\",\"nonce\":\"13742787657\",\"sig256\":\"REPLACE_WITH_VALID_BASE64_SHA256_SIGNATURE==\",\"type\":\"VIRTUAL\",\"webhook\":{\"checked_at\":\"2025-02-20T12:56:09.742Z\",\"workspace_id\":\"67737e908861c2ba895d8d4c\"}}` byteData := []byte(jsonString) var payload WebhookPayload err := json.Unmarshal(byteData, &payload) if err != nil { fmt.Println(err) } fmt.Println(ValidateWebhook(`YOUR_WORKSPACE_SECRET`, payload)) } func ValidateWebhook(workspaceSecret string, payload WebhookPayload) bool { if payload.Sig256 == "" || payload.Nonce == "" || payload.Data == nil { fmt.Println("Missing required fields: sig256, nonce, or data") return false } jsonData, err := json.Marshal(payload.Data) if err != nil { fmt.Println(err) return false } serializedData := string(jsonData) h := hmac.New(sha256.New, []byte(workspaceSecret)) h.Write([]byte(serializedData)) h.Write([]byte(payload.Nonce)) digest := h.Sum(nil) // Encode computed signature in base64 computedSig := base64.StdEncoding.EncodeToString(digest) // Use constant-time comparison for security return subtle.ConstantTimeCompare([]byte(computedSig), []byte(payload.Sig256)) == 1 } ``` ## Native vs virtual webhooks Most APIs do not offer webhooks. For those integrations, Unified.to detects changes and delivers events through “virtual” webhooks, which you create and manage with the same API and payload format as native webhooks. Virtual webhooks check for changes on your interval (1 minute minimum on paid plans), so updates arrive on that schedule rather than immediately. Deleted events are supported on native webhooks, and on virtual webhooks where the integration supports it. To see which types an integration supports, open its Feature Support tab in the dashboard. Read more about the differences between native and virtual webhooks here: [Understanding virtual webhooks](/concepts/virtual_webhooks). Native webhook processing latency is not currently shown in API call logs. For virtual webhooks, you set the polling interval (minimum 1 minute). Unified.to retries failed deliveries automatically, and you aren't billed for failed reads, failed dispatches or polling runs that find no new data. See our webhook retry mechanism [here](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks#a-note-on-our-webhook-retry-mechanism). ## Additional webhook features At Unified.to, you can retrieve historical data from your connection (i.e. the third-party account), select which fields to receive data from, manually trigger virtual webhooks for testing purposes, observe an audit trail of webhook activity, and monitor the health of your webhooks. To learn how to use these features, see: [How to create and configure webhooks](https://docs.unified.to/guides/how_to_create_and_configure_webhooks) and [How to troubleshoot unhealthy webhooks](https://docs.unified.to/guides/how_to_troubleshoot_unhealthy_webhooks) --- # API Category Overviews ## accounting URL: https://docs.unified.to/accounting/overview The Unified Accounting API enables developers and product managers to access financial data from multiple accounting platforms through a single, standardized interface. Retrieve invoices, bills, transactions, contacts, and financial reports from QuickBooks, Xero, FreshBooks, and other major accounting platforms - all with one API. ## What is the Unified Accounting API? Accounting platforms like QuickBooks, Xero, and FreshBooks each have unique APIs with different authentication methods, data models, and endpoints. The Unified Accounting API normalizes these differences, allowing you to build once and support all major accounting platforms without maintaining separate integrations for each provider. ## Key Benefits for Developers - **Single Integration**: Write code once to support QuickBooks, Xero, FreshBooks, and more - no need to learn multiple accounting APIs - **Normalized Data Models**: Work with consistent invoice, bill, transaction, and financial report structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date financial data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple accounting platforms - **Faster Development**: Ship accounting features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred accounting platform without custom development - **Competitive Advantage**: Launch with support for all major accounting platforms while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to access their financial data from QuickBooks, Xero, or any supported platform through your product - **Reduced Time-to-Market**: Get accounting integration features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new accounting platforms as they emerge without significant engineering resources ## Accounting Data Objects The Accounting API provides access to the following standardized objects: - **Accounts**: Chart of accounts, account types, and account balances - **Balance Sheet**: Assets, liabilities, and equity reporting - **Bills**: Vendor bills and payables - **Cash Flow**: Cash flow statements and reports - **Categories**: Expense and income categories - **Contacts**: Customers, vendors, and other business contacts - **Credit Memos**: Credit notes and customer credits - **Expenses**: Business expenses and spending records - **Invoices**: Customer invoices and receivables - **Journals**: Journal entries and general ledger data - **Orders**: Purchase and sales orders - **Organizations**: Company information and settings - **Profit & Loss**: Income statements and P&L reports - **Purchase Orders**: Vendor purchase orders - **Reports**: Various financial reports and analytics - **Sales Orders**: Customer sales orders - **Tax Rates**: Tax codes and rates - **Transactions**: Financial transactions and entries - **Trial Balance**: Trial balance reports ## Common Use Cases ### Financial Dashboards & Reporting Build unified dashboards that display financial data from multiple accounting platforms. Enable finance teams to view invoices, bills, expenses, and financial reports from QuickBooks, Xero, or any supported platform from a single interface. ### Expense Management Applications Create expense tracking and management tools that sync with customers' accounting systems. Pull expense data, categorize transactions, and push expense reports back to their accounting platform. ### Billing & Invoice Automation Automate billing workflows by accessing invoice data from accounting platforms. Create tools that monitor invoice status, track payments, and generate reports on accounts receivable. ### Financial Analytics & BI Pull financial data from accounting platforms into your data warehouse or business intelligence tools. Combine accounting data with other business metrics for comprehensive financial analytics and forecasting. ### Multi-Entity Consolidation Aggregate financial data from multiple accounting systems for businesses operating across different entities, subsidiaries, or regions. Create consolidated financial reports from multiple QuickBooks or Xero instances. ### Bookkeeping & Accounting Automation Build AI-powered tools that categorize transactions, reconcile accounts, and generate financial reports by accessing live data from accounting platforms. ## Supported Accounting Platforms The Unified Accounting API supports integration with major accounting platforms including QuickBooks, Xero, FreshBooks, Wave, Zoho Books, and many others. Each integration provides access to the standardized data objects listed above. ## Why Use a Unified Accounting API? **Traditional Approach:** - Build separate integrations for QuickBooks, Xero, FreshBooks, etc. - Learn 5+ different authentication systems and API specifications - Maintain code for 5+ different API endpoints, data formats, and error handling patterns - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific quirks, rate limits, and pagination implementations **With Unified Accounting API:** - Integrate once with a single API that works across all accounting platforms - Use one authentication flow for all platforms - Work with normalized financial data objects - Automatic handling of API changes, rate limits, and platform-specific differences - Built-in support for webhooks and real-time data synchronization ## Integration Scenarios ### For Financial Software Add "Connect Your Accounting Software" features that work with any major accounting platform. Build expense management, budgeting, or financial planning tools without building separate integrations. ### For AI & Automation Tools Create AI-powered financial assistants that can read transaction data, analyze spending patterns, generate insights, and provide financial recommendations across multiple platforms using a single API. ### For Analytics & BI Platforms Pull accounting data from all major platforms into your data warehouse or analytics tool. Create unified financial reports, dashboards, and forecasting models without building multiple data pipelines. ### For Banking & Fintech Connect banking products with customers' accounting systems. Enable automatic transaction categorization, reconciliation, and financial reporting by accessing their accounting platform data. ### For E-commerce Platforms Sync e-commerce sales data with customers' accounting systems. Automatically create invoices, track inventory, and update financial records in their QuickBooks or Xero account. ## Platform-Specific Notes ### QuickBooks QuickBooks overwrites `taxrate_id` for some US-based accounts. You will need to enable Hybrid sales Tax or Automated sales tax within QuickBooks to specify `taxrate_id` for individual invoices. [Learn more](https://blogs.intuit.com/2017/12/11/using-quickbooks-online-api-automated-sales-tax/) ### GoCardless Creating a contact requires GoCardless Advanced or GoCardless Pro with approved payment pages. [Learn more](https://developer.gocardless.com/api-reference/#customers-create-a-customer) ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified Accounting API provides real-time access to financial data. Every API request hits the source platform directly, ensuring you always have the most current financial information. This real-time architecture is ideal for: - Financial dashboards displaying live invoice and expense data - Real-time expense tracking and approval workflows - Up-to-the-minute financial reporting and analytics - Automated reconciliation and bookkeeping workflows ## Privacy & Security Unified.to never stores your customers' financial data. All requests are stateless and pass through to the accounting platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. ### Note on Quickbooks Quickbooks overwrites `taxrate_id` for some US-based accounts. You will need to enable Hybrid sales Tax or Automated sales tax within Quickbooks to specify `taxrate_id` for individual invoices. [Learn more.](https://blogs.intuit.com/2017/12/11/using-quickbooks-online-api-automated-sales-tax/) ### Note on GoCardless Creating a contact requires GoCardless Advanced or GoCardless Pro with approved payment pages. [Learn more.](https://developer.gocardless.com/api-reference/#customers-create-a-customer) [Run In Postman](https://god.gw.postman.com/run-collection/16228585-f1672c61-3fc1-40b3-9a3b-853597940ab5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-f1672c61-3fc1-40b3-9a3b-853597940ab5%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## ads URL: https://docs.unified.to/ads/overview The Unified Advertising API enables developers and product managers to access campaign data and performance metrics from multiple advertising platforms through a single, standardized interface. Retrieve campaigns, ads, and reports from Google Ads, Meta Ads (Facebook & Instagram), and TikTok Ads - all with one API. ## What is the Unified Advertising API? Advertising platforms like Google Ads, Meta Ads, and TikTok Ads each have unique APIs with different authentication methods, data models, and endpoints. The Unified Advertising API normalizes these differences, allowing you to build once and support all major advertising platforms without maintaining separate integrations for each provider. ## Key Benefits for Developers - **Single Integration**: Write code once to support Google Ads, Meta Ads, TikTok Ads, and more - no need to learn multiple advertising APIs - **Normalized Data Models**: Work with consistent campaign, ad, and performance data structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date campaign and performance data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple advertising platforms - **Faster Development**: Ship advertising features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred advertising platform without custom development - **Competitive Advantage**: Launch with support for all major ad platforms while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to view and analyze campaigns across Google, Meta, and TikTok from a single interface - **Reduced Time-to-Market**: Get advertising analytics features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new advertising platforms as they emerge without significant engineering resources ## Advertising Data Objects The Advertising API provides access to the following standardized objects: - **Campaigns**: Ad campaigns with budgets, targeting, scheduling, spend data, and status information - **Ads**: Individual advertisements including ad type, creatives, copy, headlines, descriptions, call-to-action, and destination URLs - **Groups**: Ad groups with targeting configurations and settings - **Organizations**: Ad account information and organization details - **Reports**: Performance metrics including impressions, clicks, conversions, spend, and other KPIs ## Common Use Cases ### Multi-Platform Advertising Dashboards Build unified dashboards that display campaign performance across Google Ads, Meta Ads, and TikTok Ads side-by-side. Enable marketers to view and compare campaign data, ad performance, and spending across platforms from a single interface. ### Agency Reporting Platforms Create white-label reporting and analytics tools for agencies tracking campaign performance across multiple platforms for different clients. Support any advertising platform without building custom integrations for each one. ### AI-Powered Campaign Analytics Build AI agents and analytics tools that analyze performance data, identify trends, generate insights, and provide recommendations across all advertising platforms using a single, consistent data model. ### Cross-Platform Reporting & Attribution Aggregate advertising data from multiple platforms into unified reports. Track campaign performance across Google, Meta, and TikTok to understand multi-channel advertising effectiveness and ROI. ### Performance Monitoring & Alerts Create tools that monitor campaign performance, track spending, and send alerts when campaigns reach budget thresholds or performance metrics change across multiple advertising platforms. ### Data Warehousing & BI Integration Pull advertising data from all platforms into your data warehouse or business intelligence tools. Combine advertising data with other business metrics for comprehensive analytics and reporting. ## Supported Advertising Platforms The Unified Advertising API currently supports: - **Google Ads**: Access campaigns, ads, ad groups, and performance reports - **Meta Ads**: Retrieve Facebook and Instagram campaign and ad data - **TikTok Ads**: Access TikTok advertising campaign and ad information Additional platforms are continuously being added based on customer demand. ## Why Use a Unified Advertising API? **Traditional Approach:** - Build separate integrations for Google Ads, Meta Ads, TikTok Ads, etc. - Learn 3+ different authentication systems (OAuth flows, API keys, tokens) - Maintain code for 3+ different API endpoints, data formats, and error handling patterns - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific rate limits, pagination, and webhook implementations **With Unified Advertising API:** - Integrate once with a single API that works across all advertising platforms - Use one authentication flow for all platforms - Work with normalized campaign, ad, and performance data objects - Automatic handling of API changes, rate limits, and platform-specific quirks - Built-in webhook support for real-time updates across all platforms ## Integration Scenarios ### For SaaS Applications Add "Connect Your Ad Accounts" features that work with any major advertising platform. Build marketing analytics, reporting, or performance monitoring tools that support Google, Meta, and TikTok without 3x the development effort. ### For AI & Automation Tools Create AI-powered advertising assistants that can read campaign data, analyze performance, generate insights, and provide recommendations across multiple platforms using a single, consistent API interface. ### For Analytics & BI Platforms Pull advertising data from all major platforms into your data warehouse or analytics tool. Create unified reports, dashboards, and attribution models without building multiple data pipelines. ### For Marketing Agencies Build proprietary dashboards and reporting tools for tracking client campaign performance across all advertising platforms. Support any platform your clients use without custom development for each one. ### For E-commerce Platforms Embed advertising analytics directly into your e-commerce platform, allowing merchants to view campaign performance and ad spend from Google Shopping, Facebook Ads, and TikTok Ads within your product. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified Advertising API provides real-time access to advertising data. Every API request hits the source platform (Google, Meta, TikTok) directly, ensuring you always have the most current campaign performance, budget, and status information. This real-time architecture is ideal for: - AI applications that need fresh data for analysis and recommendations - Analytics dashboards displaying live performance metrics - Reporting tools that require up-to-the-minute campaign data - Monitoring systems that need accurate, real-time spend and performance data ## Privacy & Security Unified.to never stores your customers' advertising data. All requests are stateless and pass through to the advertising platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. --- **SEO Keywords**: unified advertising API, Google Ads API, Meta Ads API, Facebook Ads API, Instagram Ads API, TikTok Ads API, advertising platform integration, multi-platform ad reporting, advertising API for developers, campaign data API, ad performance tracking, unified marketing API, advertising analytics API, cross-platform advertising reports, ad tech integration, advertising data integration ## analytics URL: https://docs.unified.to/analytics/overview The Unified Analytics API enables developers and product managers to access web and product analytics data from multiple platforms through a single, standardized interface. Retrieve properties, events, sessions, visitors, and performance reports from Google Analytics, Mixpanel, PostHog, Pendo, and YouTube Analytics - all with one API. ## What is the Unified Analytics API? Web and product analytics platforms like Google Analytics, Mixpanel, PostHog, Pendo, and YouTube Analytics each have unique APIs with different authentication methods, data models, query languages, and reporting structures. The Unified Analytics API normalizes these differences, allowing you to build once and support traffic analysis, user behavior tracking, conversion reporting, and event ingestion across analytics providers without maintaining separate integrations for each one. ## Key Benefits for Developers - **Single Integration**: Write code once to support multiple analytics platforms - no need to learn different reporting APIs and event schemas - **Normalized Data Models**: Work with consistent property, event, session, visitor, and report structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date analytics data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple analytics platforms - **Faster Development**: Ship analytics features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred analytics platform without custom development - **Competitive Advantage**: Launch with support for major analytics tools while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to view traffic, engagement, and conversion data from their analytics accounts through a single interface - **Reduced Time-to-Market**: Get analytics dashboards and reporting features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new analytics platforms as they emerge without significant engineering resources ## Analytics Data Objects The Analytics API provides access to the following standardized objects: - **Properties**: Websites, apps, and analytics accounts with name, timezone, currency, industry, and hierarchical parent relationships - **Events**: User interactions including page views, screen views, clicks, form submissions, purchases, sign-ups, video plays, and custom events - with page context, device info, geo data, UTM parameters, and monetary values - **Sessions**: User visit sessions with start and end times, duration, landing and exit pages, page view counts, acquisition source/medium/campaign, device and location data, and bounce or conversion flags - **Visitors**: Identified and anonymous users with profile attributes, engagement totals (sessions, page views, events), and first/last seen timestamps - **Reports**: Aggregated metrics over date ranges, filterable by metric type (users, sessions, page views, conversions, revenue, video engagement, and more) and dimension (date, page, source, campaign, country, device, and more) ## Common Use Cases ### Multi-Platform Analytics Dashboards Build unified dashboards that display traffic, engagement, and conversion metrics across Google Analytics, Mixpanel, PostHog, and other analytics platforms side-by-side. Enable marketers and product teams to compare performance, user behavior, and acquisition channels from a single interface. ### Agency & Client Reporting Create white-label reporting tools for agencies tracking website and product performance across multiple clients and analytics accounts. Support any connected analytics platform without building custom integrations for each one. ### Conversion & Funnel Analysis Query session and event data to analyze user journeys, track conversions, and measure funnel performance. Filter events by type (page views, purchases, sign-ups) and attribute results to traffic sources and campaigns. ### E-Commerce & Revenue Analytics Pull report metrics for transactions, revenue, average order value, and ecommerce conversion rates. Combine traffic and revenue data to understand which channels and pages drive the most value. ### Event Tracking & Data Ingestion Create events and visitors programmatically to send tracking data into connected analytics platforms. Record page views, custom events, and user attributes through a normalized event schema with UTM and geo fields. ### Data Warehousing & BI Integration Pull analytics data from all platforms into your data warehouse or business intelligence tools. Combine web analytics with CRM, advertising, and commerce data for comprehensive cross-channel reporting. ### AI-Powered Analytics Insights Build AI agents and analytics tools that analyze traffic patterns, identify trends, surface anomalies, and generate recommendations across analytics platforms using a single, consistent data model. ## Supported Analytics Platforms The Unified Analytics API currently supports: - **Google Analytics**: Access properties, events, and performance reports for website and app traffic - **Mixpanel**: Retrieve events, visitors, properties, and reports for product analytics and user behavior - **PostHog**: Access properties, events, sessions, visitors, and reports for open-source product analytics - **Pendo**: Retrieve properties, events, visitors, and reports for product adoption and in-app usage analytics - **YouTube Analytics**: Access properties and video performance reports for channel and content metrics Additional platforms are continuously being added based on customer demand. ## Why Use a Unified Analytics API? **Traditional Approach:** - Build separate integrations for Google Analytics, Mixpanel, PostHog, Pendo, and YouTube Analytics - Learn multiple authentication systems and reporting APIs - Maintain code for different event schemas, query parameters, and metric definitions - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific rate limits, pagination, and dimension naming conventions **With Unified Analytics API:** - Integrate once with a single API that works across analytics platforms - Use one authentication flow for all platforms - Work with normalized property, event, session, visitor, and report objects - Automatic handling of API changes, rate limits, and platform-specific differences - Consistent filtering by property, date range, event type, source, medium, and campaign ## Integration Scenarios ### For SaaS Applications Add "Connect Your Analytics" features that work with any supported analytics platform. Build traffic dashboards, conversion reports, or user behavior tools without building separate integrations for each provider. ### For AI & Automation Tools Create AI-powered analytics assistants that read traffic data, analyze user behavior, identify opportunities, and provide recommendations across multiple platforms using a single API. ### For Analytics & BI Platforms Pull analytics data from all major platforms into your data warehouse or analytics tool. Create unified traffic reports, acquisition dashboards, and conversion analyses without building multiple data pipelines. ### For Marketing Agencies Build proprietary dashboards and reporting tools for tracking client website and campaign performance. Support any analytics platform your clients use without custom development for each one. ### For Product Teams Embed analytics insights directly into your product, allowing customers to view traffic, engagement, and conversion data from their connected analytics accounts within your application. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified Analytics API provides real-time access to analytics data. Every API request hits the source platform directly, ensuring you always have the most current traffic, event, and report information. This real-time architecture is ideal for: - Analytics dashboards displaying live traffic and engagement metrics - AI applications that need fresh data for analysis and recommendations - Reporting tools that require up-to-the-minute conversion and revenue data - Monitoring systems that track real-time user behavior and session activity ## Privacy & Security Unified.to never stores your customers' analytics data. All requests are stateless and pass through to the analytics platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. --- **SEO Keywords**: unified analytics API, Google Analytics API, Mixpanel API, PostHog API, Pendo API, YouTube Analytics API, web analytics API, product analytics API, analytics platform integration, multi-platform analytics reporting, website traffic API, event tracking API, session analytics API, conversion tracking API, analytics data integration, unified marketing analytics, cross-platform analytics reports, visitor analytics API, analytics reporting API for developers ## assessment URL: https://docs.unified.to/assessment/overview The Unified Assessment API enables assessment and background check providers to seamlessly integrate with Applicant Tracking Systems (ATS) platforms through a single, standardized interface. Recruiters can request assessments for candidates directly from within their ATS—Workable, Ashby, Greenhouse, and others—while assessment providers receive orders via webhooks and submit results back to the ATS, all with one API. ## What is the Unified Assessment API? ATS platforms like Workable, Ashby, and Greenhouse each have their own assessment APIs with different authentication methods, data models, and endpoints. The Unified Assessment API normalizes these differences, allowing assessment providers to build once and support multiple ATS platforms without maintaining separate integrations for each provider. Unlike the Unified Verification API, where applications initiate requests to verification providers through Unified, the Assessment API connects directly into ATS “assessment APIs” and allows recruiters to request assessments without leaving their ATS. For assessment providers, this reduces friction for recruiters and keeps them in their familiar workflow. ## Key Benefits for Developers - **Single Integration**: Write code once to support Workable, Ashby, Greenhouse, and more—no need to learn multiple ATS assessment APIs - **Normalized Data Models**: Work with consistent assessment package, order, and result structures across all supported ATS platforms - **Real-Time Webhooks**: Receive assessment orders via webhooks as soon as recruiters request them, with no polling required - **Zero Maintenance**: No need to track API version changes or deprecations across multiple ATS assessment integrations - **Faster Development**: Ship ATS assessment integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred ATS platform without custom development - **Competitive Advantage**: Launch with support for Workable, Ashby, Greenhouse, and more while competitors build integrations one at a time - **Customer Flexibility**: Allow recruiters to request assessments from within their ATS without switching to a third-party interface - **Reduced Time-to-Market**: Get assessment integration features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new ATS platforms as they add assessment API support ## Assessment Data Objects The Assessment API provides access to the following standardized objects: - **Assessment Packages**: Available assessment packages, pricing, and package configuration that ATS platforms display to recruiters - **Assessment Orders**: Orders placed by recruiters when they request an assessment for a candidate, including candidate, job, and application details - **Order Results**: Scores, status, completion timestamps, and custom attributes submitted back to the ATS when assessments are complete ## Common Use Cases ### Candidate Assessment Integration Build assessment and testing tools that integrate with customers’ ATS platforms. Recruiters request assessments from within their ATS; your application receives orders via webhooks, sends assessments to candidates, and submits results back for recruiters to review. ### Background Check Workflows Connect background check services with ATS platforms that support assessment APIs. Recruiters initiate checks from within their ATS; your service processes the request, performs verification, and updates the order with results that appear directly in the ATS. ### Skills and Aptitude Testing Create skills assessment and aptitude testing products that work with Workable, Ashby, Greenhouse, and other supported ATS platforms. Recruiters order assessments for candidates; your platform delivers the test, scores it, and pushes results back to the ATS. ### Interview Preparation Assessments Build tools that deliver pre-interview assessments or preparation materials. Recruiters order assessments for candidates at specific pipeline stages; your application tracks completion and reports results back to the ATS. ### Behavioral and Personality Assessments Integrate behavioral or personality assessments into the hiring flow. Recruiters request assessments from within their ATS; your platform collects responses, computes scores, and submits structured results for recruiters to review alongside other candidate data. ## Supported ATS Platforms The Unified Assessment API supports integration with ATS platforms that offer assessment APIs, including Workable, Ashby, and Greenhouse. Each integration uses assessment-only connections that are separate from their standard ATS integrations. ## Why Use a Unified Assessment API? **Traditional Approach:** - Build separate integrations for Workable, Ashby, Greenhouse, and each new ATS that adds assessment support - Learn multiple authentication systems and API specifications - Maintain code for different webhook formats, data models, and result submission endpoints - Monitor and update integrations when any platform deprecates or changes their assessment API - Handle platform-specific package structures, order payloads, and result formats **With Unified Assessment API:** - Integrate once with a single API that works across supported ATS platforms - Use one authentication flow (Partner API Key) for all platforms - Work with normalized assessment package and order objects - Receive orders via standardized webhooks and submit results through a single update endpoint - Built-in webhook support for assessment order creation and cancellation events ## Integration Scenarios ### For Assessment Software Providers Add “Connect Your ATS” features that work with Workable, Ashby, Greenhouse, and other supported platforms. Configure assessment packages via the Unified API; receive orders via webhooks and submit results without building separate integrations for each ATS. ### For Background Check Providers Integrate background verification services with ATS platforms that support assessment APIs. Recruiters initiate checks from within their ATS; your service receives candidate details via webhooks, performs verification, and updates orders with results that appear in the ATS. ### For Skills Testing Platforms Connect skills and aptitude testing tools with customers’ ATS systems. Recruiters request assessments for candidates; your platform receives orders, delivers tests, scores responses, and pushes results back through the Unified Assessment API. ## Real-Time, Live Data The Unified Assessment API provides real-time access to assessment orders via webhooks. When a recruiter requests an assessment in their ATS, you receive a webhook immediately with the order details. Every result submission is written back to the source ATS in real time, ensuring recruiters see up-to-date assessment status and scores. This real-time architecture is ideal for: - Immediate notification when recruiters order assessments - Live order status and result updates in the ATS - Automated workflows that react to new orders and cancellations instantly ## Privacy & Security Unified.to never stores your customers’ assessment data. All requests are stateless and pass through to the ATS platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. ## ats URL: https://docs.unified.to/ats/overview The Unified ATS (Applicant Tracking System) API enables developers and product managers to access recruiting data from multiple ATS platforms through a single, standardized interface. Retrieve jobs, candidates, applications, interviews, documents, scorecards, and activities from Greenhouse, Lever, Workable, and other major ATS platforms - all with one API. ## What is the Unified ATS API? Applicant Tracking Systems (ATS) like Greenhouse, Lever, and Workable each have unique APIs with different authentication methods, data models, and endpoints. The Unified ATS API normalizes these differences, allowing you to build once and support all major ATS platforms without maintaining separate integrations for each provider. ATS platforms allow recruiters to track jobs, candidates, applications, interviews, documents and scorecards throughout the recruiting and hiring process. ## Key Benefits for Developers - **Single Integration**: Write code once to support Greenhouse, Lever, Workable, and more - no need to learn multiple ATS APIs - **Normalized Data Models**: Work with consistent job, candidate, application, and interview structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date recruiting data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple ATS platforms - **Faster Development**: Ship ATS integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred ATS platform without custom development - **Competitive Advantage**: Launch with support for all major ATS platforms while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to access their recruiting data from Greenhouse, Lever, or any supported ATS through your product - **Reduced Time-to-Market**: Get recruiting integration features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new ATS platforms as they emerge without significant engineering resources ## ATS Data Objects The ATS API provides access to the following standardized objects: - **Activities**: Recruiting activities, notes, and candidate interactions - **Applications**: Job applications and candidate submissions - **Application Statuses**: Application stages and hiring pipeline status - **Candidates**: Applicant profiles, resumes, and candidate information - **Companies**: Employer organizations and company information - **Documents**: Resumes, cover letters, and candidate documents - **Interviews**: Interview schedules, feedback, and interview details - **Jobs**: Job postings, requisitions, and open positions - **Scorecards**: Interview scorecards and candidate evaluations ## Common Use Cases ### Recruiting Analytics & Reporting Build recruiting dashboards that display hiring metrics across multiple ATS platforms. Track time-to-hire, pipeline velocity, candidate sources, and recruiting performance from Greenhouse, Lever, or any supported ATS from a single interface. ### Candidate Sourcing Tools Create sourcing and talent acquisition tools that integrate with customers' ATS systems. Automatically add sourced candidates to job pipelines, track candidate engagement, and measure sourcing effectiveness. ### Interview Scheduling Automation Automate interview scheduling by accessing candidate and interview data from ATS platforms. Build tools that coordinate interviewer availability, send calendar invites, and track interview completion. ### Background Check Integration Connect background check services with ATS platforms. Automatically initiate background checks when candidates reach specific stages, update application status based on results, and store verification documents. ### Recruitment Marketing Build career site builders and recruitment marketing tools that sync with ATS job postings. Automatically publish jobs to career pages, track application sources, and measure recruitment marketing ROI. ### AI-Powered Candidate Screening Create AI tools that analyze candidate data, screen resumes, and rank applicants by accessing candidate and job information across multiple ATS platforms using a single API. ### Offer Management Build offer letter generation and approval tools that integrate with ATS systems. Access candidate information, track offer status, and update application stages when offers are accepted or declined. ## Supported ATS Platforms The Unified ATS API supports integration with major applicant tracking systems including Greenhouse, Lever, Workable, JazzHR, SmartRecruiters, iCIMS, Jobvite, and many others. Each integration provides access to the standardized data objects listed above. ## Why Use a Unified ATS API? **Traditional Approach:** - Build separate integrations for Greenhouse, Lever, Workable, etc. - Learn 5+ different authentication systems and API specifications - Maintain code for 5+ different API endpoints, data formats, and error handling patterns - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific candidate data structures and field mappings **With Unified ATS API:** - Integrate once with a single API that works across all ATS platforms - Use one authentication flow for all platforms - Work with normalized job, candidate, application, and interview objects - Automatic handling of API changes, rate limits, and platform-specific differences - Built-in webhook support for real-time recruiting data updates ## Integration Scenarios ### For Recruiting Software Add "Connect Your ATS" features that work with any major ATS platform. Build recruiting tools, sourcing platforms, or interview solutions without building separate integrations for each ATS. ### For HR Analytics Platforms Pull recruiting data from all major ATS platforms into your analytics tool. Create unified hiring reports, pipeline dashboards, and diversity metrics without building multiple data pipelines. ### For Background Check Providers Integrate background verification services with customers' ATS systems. Automatically receive candidate information, update application status, and store verification results in their ATS. ### For Interview Platforms Connect video interviewing and assessment tools with ATS platforms. Sync interview schedules, push interview recordings and scorecards back to the ATS, and update candidate pipeline status. ### For Job Board Aggregators Build job distribution tools that publish jobs from ATS platforms to multiple job boards. Automatically sync job postings, track applications from different sources, and measure job board performance. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified ATS API provides real-time access to recruiting data. Every API request hits the source platform directly, ensuring you always have the most current candidate and job information. This real-time architecture is ideal for: - Recruiting dashboards displaying live pipeline and candidate data - Real-time candidate status updates and notifications - Up-to-the-minute job posting and application information - Automated recruiting workflows that react to ATS changes instantly ## Privacy & Security Unified.to never stores your customers' candidate data. All requests are stateless and pass through to the ATS platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-7389d176-1245-4837-a523-9aac31b57ecb?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-7389d176-1245-4837-a523-9aac31b57ecb%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## auth URL: https://docs.unified.to/auth/overview Allow your application to sign-in your users with an OAuth2 or SAML integration. There are two ways to use the authentication APIs: - Redirect your user to the [OAuth2 login endpoint](/auth/login/Sign_in_a_user){:target="\_blank"} / [SAML SSO endpoint](/auth/saml/Sign_in_a_user_via_SAML){:target="\_blank"} with a specific `integration_type` and your `workspace_id` - Call the [Integrations API endpoint](/unified/integration/Returns_all_activated_integrations_in_a_workspace){:target="\_blank"} with `categories=auth,saml` to get a list of authentication integrations, then construct the sign-in URL for each integration: **For OAuth2 login:** `https://api.unified.to/unified/integration/login/{workspace_id}/{integration.type}?redirect=true` **For SAML SSO login:** `https://api.unified.to/unified/integration/saml/{workspace_id}/{integration.type}?redirect=true` Authentication-only integrations should not be used to create connections. They are intended to sign in your users. If you're trying to authorize your customers and create connections on your own, refer to our tutorial: [Customize your authorization flow with the Unified API](/tutorials/customize-auth-flow). That flow uses `/unified/integration/auth/...` and returns a connection `id`, not a login JWT. ## Instructions ### 1. Activate authentication integrations Go to [https://app.unified.to/integrations?tab=auth](https://app.unified.to/integrations?tab=auth){:target="\_blank"} or [https://app.unified.to/integrations?tab=saml](https://app.unified.to/integrations?tab=saml){:target="\_blank"} and activate integrations that you would like to have your users sign-in to your application with. ### 2. Display Sign-in links to your users ### 2.1. Use our embedded Embedded Sign-in widget (OAuth2-only) Go to [https://app.unified.to/settings/embed](https://app.unified.to/settings/embed?tab=Sign-in){:target="\_blank"} and configure our embedded sign-in widget. ### 2.2. Use our getActivatedIntegrations API Call the [getActivatedIntegrations](https://docs.unified.to/unified/integration/Returns_all_activated_integrations_in_a_workspace) API endpoint to retrieve a list of activated authentication integrations `categories=auth` or `categories=saml`. ```js [ { logo_url: 'https://api.unified.to/docs/images/google.png', name: 'Google', type: 'google', }, ]; ``` Construct the sign-in URL from your `workspace_id` and each integration's `type`: **For OAuth2 login:** `https://api.unified.to/unified/integration/login/{workspace_id}/{integration.type}?redirect=true` **For SAML SSO login:** `https://api.unified.to/unified/integration/saml/{workspace_id}/{integration.type}?redirect=true` The Sign-in URL can have the following optional parameters: | | | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `redirect=true` | redirect the user or if empty, will return the URL as string in its response | | `success_redirect` | the URL that the user will be redirected to once they have successfully authenticated with that integration vendor. When the workspace has `trusted_domains` set, this host must be on that list. | | `failure_redirect` | the URL that the user will be redirected to if there is an error or other issue preventing the user from being authenticated by the integration vendor. Same host restriction as `success_redirect`. | | `state` | a string that will be sent back to the success_redirect. You can use this to remember a user ID or other identifier. | ### 2.3. Restrict sign-in redirect hosts A forwarded sign-in link can otherwise be edited so the login JWT is sent to someone else's site. If you leave out `success_redirect`, Unified uses the page the user came from, so any page that links to your sign-in URL would receive the JWT too. Set `trusted_domains` on the workspace to the hostnames allowed for `success_redirect` and `failure_redirect` (for example `app.yourcompany.com` or `localhost:3000`). When `trusted_domains` is set, Unified rejects a redirect whose host is not on that list (`400`). Redirects must be absolute URLs. If `trusted_domains` is empty or unset, redirect hosts are not restricted. Update your workspace with a user API key that belongs to a workspace admin. Include your workspace `name`: ```http PUT https://api.unified.to/unified/workspace Authorization: Bearer YOUR_API_KEY ``` ```json { "name": "Your workspace", "trusted_domains": ["app.yourcompany.com"] } ``` Entries are saved as lowercase hosts; a full URL such as `https://app.yourcompany.com/login` is saved as `app.yourcompany.com`. Wildcards (`*.yourcompany.com`) are rejected, so list each host. The same list also applies to connection authorization (`/unified/integration/auth/...`). See [Customize your authorization flow](/tutorials/customize-auth-flow). ### 3. Verify the login Once the user successfully signs-in, they are redirected back to your application (or to the location of the `success_redirect` URL parameter from step 2). A `jwt` parameter will be appended to that URL. The `jwt` is a base64 encoded [JWT](https://jwt.io/) and is signed with your Workspace Secret found at [https://app.unified.to/settings/api](https://app.unified.to/settings/api){:target="\_blank"}. Verify the JWT with your workspace secret on your server (NOT in your browser as the workspace secret is not public). Then check: - `redirect` is your own callback URL (compare origin and path, so per-request query parameters still match). - `state` matches what you stored for this browser session when you created the sign-in link. This stops someone from replaying a JWT they obtained into their own browser. The JWT expires 10 minutes after it is issued (`exp`), so exchange it for your own session right away and don't store it as a session token. Example code to verify a JWT on a NodeJS server: ```js const EXPECTED_CALLBACK = 'https://app.yourcompany.com/login/callback'; try { const result = JWT.verify(req.query.jwt, workspace.secret); // also checks exp const landed = new URL(result.redirect); const expected = new URL(EXPECTED_CALLBACK); if (landed.origin !== expected.origin || landed.pathname !== expected.pathname) { throw new Error('Unexpected redirect'); } if (!result.state || result.state !== req.session.signinState) { throw new Error('Unexpected state'); } // sign the user in with a verified email from result.emails } catch (err) { console.error(err); } ``` The decoded JWT contains `workspace_id`, `name`, `emails`, `state`, `redirect` (the `success_redirect` URL used), and the standard `iat`, `exp` and `jti` claims: ```js { "workspace_id": "5e8ba11d3fb8ff95c90b7332", "name": "Jane Smith", "emails": ["jane@foo.com", "jsmith89@gmail.com"], "state": "optional-state-from-signin-url", "redirect": "https://app.yourcompany.com/login/callback", "iat": 1760300000, "exp": 1760300600, "jti": "3f0c6f9e-6d7e-4c6b-9a43-2b1f1c0d7e55" } ``` Use the emails to log the user into your application as it is verified by the integration. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-f6e189b9-b71c-4a09-8a47-f6763f9c24dc?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-f6e189b9-b71c-4a09-8a47-f6763f9c24dc%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## calendar URL: https://docs.unified.to/calendar/overview The Unified Calendar API enables developers and product managers to access calendar and scheduling data from multiple calendar platforms through a single, standardized interface. Retrieve calendars, events, busy times, scheduling links, and meeting recordings from Google Calendar, Outlook Calendar, Office 365, and other major calendar platforms - all with one API. ## What is the Unified Calendar API? Calendar platforms like Google Calendar, Outlook, and Office 365 each have unique APIs with different authentication methods, data models, and endpoints. The Unified Calendar API normalizes these differences, allowing you to build once and support all major calendar platforms without maintaining separate integrations for each provider. ## Calendar Data Objects The Unified Calendar API gives you access to: - **Busy Times**: A person's free/busy availability and time blocks - **Calendars**: Calendar accounts and calendar information - **Events**: Calendar events, meetings, and appointments - **Links**: Scheduling links and booking pages - **Recordings**: Meeting recordings and transcripts ## Common Use Cases ### Scheduling & Booking Tools Build scheduling assistants and appointment booking tools that check availability across multiple calendar platforms. Access free/busy times from Google Calendar, Outlook, or any supported platform to find optimal meeting times. ### Meeting Analytics Analyze meeting patterns and time usage by accessing calendar event data. Track meeting frequency, duration, attendees, and calendar utilization across organizations. ### Calendar Sync Applications Create calendar sync tools that keep multiple calendars in sync. Mirror events between Google Calendar and Outlook, or sync personal and work calendars automatically. ### AI Meeting Assistants Build AI-powered meeting assistants that access calendar events, meeting recordings, and transcripts. Generate meeting summaries, extract action items, and provide meeting insights. ### Resource Scheduling Develop resource booking systems that integrate with calendar platforms. Schedule conference rooms, equipment, or other resources by accessing calendar availability data. ### Time Tracking Integration Connect time tracking tools with calendar data. Automatically log time based on calendar events, categorize meetings, and generate timesheets from calendar information. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-ae23181a-d514-493e-acba-89870fd0d3e0?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-ae23181a-d514-493e-acba-89870fd0d3e0%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## cdp URL: https://docs.unified.to/cdp/overview The Unified CDP API enables developers and their applications to access customer data platform profiles, segments, events, sources, destinations, and activations from multiple CDPs through a single, standardized interface. Work with Adobe Experience Platform, Twilio Segment, Salesforce Data Cloud, mParticle, and other major CDPs - all with one API. ## What is the Unified CDP API? Customer Data Platforms (CDPs) like Twilio Segment, Adobe Experience Platform, Salesforce Data Cloud, and mParticle each have unique APIs with different authentication methods, data models, and endpoints. The Unified CDP API normalizes these differences, allowing you to build once and support all major CDPs without maintaining separate integrations for each provider. A CDP collects, unifies, and activates customer data across marketing, product, and analytics systems. It typically manages customer profiles, behavioral events, audience segments, data sources, activation destinations, and sync jobs. ## Key Benefits for Developers - **Single Integration**: Write code once to support Twilio Segment, Adobe Experience Platform, Salesforce Data Cloud, mParticle, and more - no need to learn multiple CDP APIs - **Normalized Data Models**: Work with consistent profile, segment, event, source, destination, and activation structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date customer and audience data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple CDP platforms - **Faster Development**: Ship CDP integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred CDP without custom development - **Competitive Advantage**: Launch with support for major CDPs while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to manage profiles, audiences, and activations across Segment, Adobe, Salesforce Data Cloud, and other CDPs from a single interface - **Reduced Time-to-Market**: Get customer data and audience features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new CDP platforms as they emerge without significant engineering resources ## CDP Data Objects The CDP API provides access to the following standardized objects: - **Profiles**: Unified customer profiles including identity, contact details, traits/metadata, segment membership, consent preferences, and anonymous vs. known status - **Segments**: Audiences and cohorts with membership rules, compute mode (realtime or batch), size, and active status - **Events**: Behavioral and identity events (track, page, screen, identify, group, alias) with timestamps, identifiers, and event properties - **Sources**: Inbound data connectors that feed profiles and events into the CDP - **Destinations**: Outbound activation destinations where audience and profile data is sent - **Activations**: Sync jobs that push segments (and related data) to destinations on a schedule or in realtime ## Common Use Cases ### Multi-CDP Customer 360 Dashboards Build dashboards that display unified customer profiles, segment membership, and recent events across Twilio Segment, Adobe Experience Platform, Salesforce Data Cloud, and other CDPs. Give marketers and product teams a single view of customer identity and engagement. ### Audience Sync & Activation Tools Create tools that list segments, destinations, and activations so users can sync audiences to ad platforms, email tools, and analytics destinations without learning each CDP's activation model. ### Event Streaming & Analytics Pipelines Ingest and query behavioral events from multiple CDPs into your analytics stack. Normalize track, page, screen, and identify events into a consistent schema for reporting, attribution, and product analytics. ### Identity Resolution & Profile Enrichment Use profile identifiers (email, user ID, anonymous ID, device ID, CRM ID, and more) and consent data to power identity stitching, enrichment workflows, and privacy-aware personalization across platforms. ### AI-Powered Customer Insights Build AI agents that analyze profile traits, segment membership, and event history to generate insights, recommend next-best actions, and detect churn or conversion opportunities across any connected CDP. ### Data Warehouse & BI Integration Pull profiles, segments, events, and activation status from all supported CDPs into your data warehouse or BI tools. Combine CDP data with CRM, ads, and product metrics for end-to-end customer analytics. ## Supported CDP Platforms The Unified CDP API currently supports: - **Adobe Experience Platform**: Access segments, sources, destinations, and activations - **Bloomreach Engagement**: Retrieve profiles and events - **BlueConic**: Access profiles, segments, events, sources, and destinations - **Lytics**: Retrieve segments, profiles, sources, destinations, and activations - **mParticle**: Access profiles and sources - **Salesforce Data Cloud**: Retrieve profiles, sources, and activations - **Twilio Segment**: Access segments, profiles, sources, destinations, and activations - **Tealium**: Retrieve segments - **Treasure Data**: Access segments, sources, and destinations Additional platforms are continuously being added based on customer demand. ## Why Use a Unified CDP API? **Traditional Approach:** - Build separate integrations for Segment, Adobe Experience Platform, Salesforce Data Cloud, mParticle, etc. - Learn multiple authentication systems (OAuth flows, API keys, tokens, workspace credentials) - Maintain code for different profile, audience, event, and activation models - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific identity schemas, consent models, and activation pipelines **With Unified CDP API:** - Integrate once with a single API that works across all major CDPs - Use one authentication flow for all platforms - Work with normalized profile, segment, event, source, destination, and activation objects - Automatic handling of API changes, rate limits, and platform-specific quirks - Built-in webhook support for real-time updates across all platforms ## Integration Scenarios ### For SaaS Applications Add "Connect Your CDP" features that work with any major customer data platform. Build audience management, profile lookup, or activation monitoring tools that support Segment, Adobe, Salesforce Data Cloud, and more without multiplying engineering effort. ### For AI & Automation Tools Create AI-powered customer data assistants that can read profiles, analyze event streams, inspect segment membership, and recommend activations across multiple CDPs using a single, consistent API interface. ### For Analytics & BI Platforms Pull customer profiles, segments, and events from all major CDPs into your data warehouse or analytics tool. Create unified audience reports and identity graphs without building multiple data pipelines. ### For Marketing & Ad Tech Platforms Sync audiences and profile attributes from customers' CDPs into your advertising, email, or personalization product. Support the CDPs your customers already use without custom connectors for each one. ### For Privacy & Consent Platforms Access consent preferences and profile identifiers across CDPs to support privacy workflows, preference centers, and compliance tooling with a normalized consent model. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified CDP API provides real-time access to customer data platform data. Every API request hits the source platform (Segment, Adobe Experience Platform, Salesforce Data Cloud, and others) directly, ensuring you always have the most current profiles, segments, events, and activation status. This real-time architecture is ideal for: - AI applications that need fresh customer and event data for analysis and recommendations - Dashboards displaying live audience sizes, profile traits, and activation status - Activation tools that require up-to-date segment membership before syncing destinations - Monitoring systems that need accurate, real-time event and sync health data ## Privacy & Security Unified.to never stores your customers' CDP data. All requests are stateless and pass through to the CDP platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. --- **SEO Keywords**: unified CDP API, customer data platform API, Twilio Segment API, Adobe Experience Platform API, Salesforce Data Cloud API, mParticle API, BlueConic API, Lytics API, Tealium API, Treasure Data API, Bloomreach API, customer profile API, audience segment API, event tracking API, CDP activation API, identity resolution API, multi-CDP integration, customer 360 API, audience sync API, CDP data integration ## clubs URL: https://docs.unified.to/clubs/overview The Unified Clubs API enables developers and product managers to access sports club and team data from multiple platforms through a single, standardized interface. Retrieve groups, members, events, venues, and activities from Strava, RevSpot, TeamSnap, PlayMQ, and other sports and fitness platforms - all with one API. ## What is the Unified Clubs API? Sports and fitness platforms like Strava, RevSpot, TeamSnap, and PlayMQ each have unique APIs with different authentication methods, data models, and endpoints. The Unified Clubs API normalizes these differences, allowing you to build once and support major club, team, and activity platforms without maintaining separate integrations for each provider. ## Clubs Data Objects The Clubs API provides access to the following standardized objects: - **Activities**: Workouts, training sessions, and athletic activities with distance, duration, elevation, and performance metrics - **Events**: Games, practices, and scheduled events with opponents, scores, status, and venue details - **Groups**: Sports clubs, teams, racing groups, and organizations with membership and sport metadata - **Locations**: Venues, fields, and facilities with addresses and coordinates - **Members**: Athletes, players, coaches, and club members with roles and membership status ## Common Use Cases ### Team & League Management Build team management tools that work across youth sports, league, and club platforms. Access rosters, schedules, and event details from TeamSnap, PlayMQ, or any supported platform in a single application. ### Athlete Performance Analytics Create training and performance dashboards by aggregating activity data from fitness and endurance platforms. Track distance, pace, heart rate, elevation, and workout history across Strava and other activity providers. ### Schedule & Venue Coordination Develop scheduling tools that sync games, practices, and events with venue and location data. Manage calendars, opponents, scores, and facility assignments without building separate integrations for each sports platform. ### Club Membership & Rosters Integrate membership and roster data across club and team platforms. List members, track admin roles, and reflect membership status for coaches, parents, and league administrators. ### Multi-Platform Sports Apps Let users connect the platforms they already use—whether a running club on Strava, a youth team on TeamSnap, or a league on PlayMQ—and present groups, events, and activities in one unified experience. ### Parent & Coach Tools Build apps for parents and coaches that surface upcoming events, practice locations, team rosters, and recent athletic activity from connected accounts across supported integrations. ## commerce URL: https://docs.unified.to/commerce/overview The Unified Commerce API enables developers and product managers to access e-commerce data from multiple platforms through a single, standardized interface. Retrieve products, inventory, collections, locations, reviews, and sales channel data from Shopify, WooCommerce, BigCommerce, and other major e-commerce platforms - all with one API. ## What is the Unified Commerce API? E-Commerce refers to the buying and selling of goods and services over the internet, enabling businesses to reach customers globally and conduct transactions digitally. E-commerce platforms like Shopify, WooCommerce, and BigCommerce each have unique APIs with different authentication methods and data models. The Unified Commerce API normalizes these differences. ## Commerce Data Objects The Commerce API provides access to the following standardized objects: - **Collections**: Product collections, categories, and catalogs - **Inventory**: Stock levels, inventory tracking, and warehouse data - **Items**: Products, SKUs, variants, and product information - **Locations**: Store locations, warehouses, and fulfillment centers - **Reviews**: Product reviews, ratings, and customer feedback - **Sales Channels**: Sales channels, marketplaces, and distribution channels ## Common Use Cases ### Multi-Platform Product Management Enable retailers to easily integrate and manage online marketplaces with the top e-commerce platforms. Sync product catalogs, inventory levels, and pricing across Shopify, WooCommerce, and other platforms from a single interface. ### Inventory Synchronization Streamline inventory management across multiple sellers and platforms. Keep stock levels synchronized, prevent overselling, and manage inventory across different sales channels and warehouses. ### Product Data Integration Import product data from different e-commerce stores to create a unified catalog across multiple platforms. Aggregate product information, images, descriptions, and pricing for analytics or marketplace integration. ### Review & Rating Aggregation Collect and analyze product reviews from multiple e-commerce platforms. Build review management tools that aggregate customer feedback, analyze sentiment, and display ratings across all sales channels. ### E-commerce Analytics Pull product, inventory, and sales channel data into analytics platforms. Track product performance, inventory turnover, and channel effectiveness across multiple e-commerce systems. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-522803c5-6821-4e1a-b548-3f62a090921a?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-522803c5-6821-4e1a-b548-3f62a090921a%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## compute URL: https://docs.unified.to/compute/overview The Unified Compute & Hosting API enables developers and product managers to manage cloud compute, AI sandboxes, and app hosting across multiple providers through a single, standardized interface. List GPU types and prices, launch and control instances, and deploy services on RunPod, Vast.ai, SF Compute, Crusoe, Nebius, AWS EC2, DigitalOcean, Linode, Scaleway, Hetzner Cloud, E2B, Daytona, Vercel Sandbox, Runloop, Render, Vercel, and more — all with one API. ## What is the Unified Compute & Hosting API? GPU clouds, AI agent sandboxes, and app-hosting platforms each have their own API, authentication, units, and lifecycle model. One provider calls a machine a "pod", another a "droplet", "server", "devbox", or "sandbox"; some quote prices per GPU-hour in cents, others per instance-hour, and some only bill per second. The Unified Compute & Hosting API normalizes these differences, so you can build once and let your customers bring the compute provider they already use. Compute integrations represent machines as **instances**, the hardware and plan catalog as **GPU types**, and deployable apps as **services** with their **deployments**, **domains**, and **environment variables**. Lifecycle actions such as start, stop, pause, resume, restart, and terminate are updates to an instance's `status`. ## Key Benefits for Developers - **Single Integration**: Write code once to provision and manage compute across GPU clouds, sandboxes, and hosting platforms - **Normalized Data Models**: Memory and disk are always MiB, GPUs map to one canonical GPU model list, and every vendor state maps to one instance status - **One Lifecycle Model**: Start, stop, pause, resume, restart, and terminate through a single `status` update, with `allowed_actions` telling you what the provider supports right now - **Real-Time Data Access**: Every request hits the source API live with no caching, so instance status and availability are always current - **Zero Maintenance**: No need to track API versions, deprecations, or auth changes across providers ## Benefits for Product Managers - **Multi-Provider Support**: Let customers run workloads on the cloud they already pay for, without custom development per provider - **Price & Availability Comparison**: Show GPU prices and stock across providers and regions from one catalog - **Competitive Advantage**: Launch with support for major GPU clouds, sandboxes, and hosting platforms while competitors integrate one at a time - **Reduced Time-to-Market**: Ship compute and deployment features in days instead of months of integration work - **Scalable Product Strategy**: Add new providers as customers ask for them without significant engineering resources ## Compute & Hosting Data Objects The Compute & Hosting API provides access to the following standardized objects: - **Instances**: Pods, VMs, bare-metal servers, containers, and sandboxes, with type, normalized status, `allowed_actions`, region, template, GPU type, GPU model and count, vCPUs, memory, disk, list price, exposed endpoints, SSH details, timeouts, and expiry. Hosting machines link to their service through `service_id`. - **GPU Types**: The GPU and instance-size catalog, with canonical GPU model and manufacturer, the vendor SKU, GPU count and memory, vCPUs, memory, disk, architecture, prices by market and region, and availability by region. Also used as the plan catalog for hosting tiers. - **Regions**: Regions and data centers, with location, country, and the GPU models offered there. - **Templates**: Images, templates, and blueprints that instances start from, with default sizing, ports, start command, and build status. - **Volumes**: Network volumes, filesystems, and disks, with type, status, size, region, availability zone, and the instances they are mounted on. - **Snapshots**: Memory or disk snapshots taken from an instance or a volume, which new instances can be restored or forked from. - **Usage**: Usage and cost records per period, with category (GPU, CPU, memory, storage, network), quantity, unit, and cost. - **Keys**: SSH keys, container-registry credentials, and secrets. Secret values are write-only and never returned. - **Services**: Hosted apps and projects, with type, status, runtime, region, plan, instance counts and autoscaling limits, source repository and branch, build and start commands, and public URL. Suspend or resume a service by updating its status. - **Deployments**: Builds and releases of a service, with status, trigger, environment, commit, branch, URL, and timing. Creating a deployment triggers a deploy; setting its status to canceled cancels it. - **Domains**: Custom domains routed to a service, with verification status and primary/redirect settings. - **Environment Variables**: A service's environment variables and the environments they apply to. Values are write-only and never returned. ## Common Use Cases ### GPU Marketplaces & Price Comparison Aggregate GPU types, prices, and availability across RunPod, Vast.ai, SF Compute, Crusoe, Nebius, and other GPU clouds. Help customers find the cheapest H100 or the region with capacity right now. ### AI Agent Sandboxes Give AI agents isolated environments to run code. Create, pause, resume, and terminate sandboxes on E2B, Daytona, Vercel Sandbox, or Runloop through the same instance object, with timeouts and expiry handled consistently. ### Training & Inference Orchestration Launch GPU instances for training or batch inference on the customer's own cloud account, attach volumes for datasets and checkpoints, and terminate machines when the job finishes. ### Deployment & Hosting Dashboards Build dashboards and developer tools that list services, deployments, domains, and environment variables across Render, Vercel, and other hosting platforms. Trigger, monitor, and cancel deploys from one place. ### Cloud Cost Management Pull usage and cost records alongside running instances to track spend by instance, category, and provider, and flag idle or forgotten machines. ### Infrastructure Inventory & Governance Keep an up-to-date inventory of instances, volumes, snapshots, and SSH keys across every connected provider for security reviews, audits, and cleanup. ## Why Use a Unified Compute & Hosting API? **Traditional Approach:** - Build separate integrations for each GPU cloud, sandbox, and hosting provider - Learn multiple authentication schemes, including signed requests and token exchanges - Convert each provider's units, prices, GPU names, and lifecycle states into your own model - Monitor and update integrations when providers change or retire API versions - Handle provider-specific rate limits, pagination, and asynchronous operations **With Unified Compute & Hosting API:** - Integrate once with a single API that works across compute and hosting providers - Use one authentication flow for all providers - Work with normalized instance, GPU type, service, and deployment objects - Control the full lifecycle through one `status` field - Automatic handling of API changes, rate limits, and provider-specific differences ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified Compute & Hosting API provides real-time access to compute data. Every API request hits the source provider directly, so you always see the current instance status, prices, and availability. This real-time architecture is ideal for: - Dashboards that show live instance and deployment status - Schedulers that need current GPU availability before launching - AI agents that create and tear down sandboxes on demand - Cost tools that flag running machines as soon as they start ## Privacy & Security Unified.to never stores your customers' compute data. All requests are stateless and pass through to the provider directly. Write-only values such as environment variables, registry passwords, and secrets are sent to the provider and never returned. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. ## crm URL: https://docs.unified.to/crm/overview The Unified CRM API enables developers and product managers to access customer relationship management data from multiple CRM platforms through a single, standardized interface. Retrieve contacts, companies, deals, pipelines, leads, and events from Salesforce, HubSpot, Pipedrive, and other major CRM platforms - all with one API. ## What is the Unified CRM API? Customer Relationship Management (CRM) platforms like Salesforce, HubSpot, and Pipedrive each have unique APIs with different authentication methods, data models, and endpoints. The Unified CRM API normalizes these differences, allowing you to build once and support all major CRM platforms without maintaining separate integrations for each provider. A CRM solution is typically used by salespeople to manage their sales prospects. It is also used by marketing and customer support/success teams as well. ## Key Benefits for Developers - **Single Integration**: Write code once to support Salesforce, HubSpot, Pipedrive, and more - no need to learn multiple CRM APIs - **Normalized Data Models**: Work with consistent contact, company, deal, and pipeline structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date CRM data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple CRM platforms - **Faster Development**: Ship CRM integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred CRM platform without custom development - **Competitive Advantage**: Launch with support for all major CRM platforms while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to access their sales data from Salesforce, HubSpot, or any supported CRM through your product - **Reduced Time-to-Market**: Get CRM features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new CRM platforms as they emerge without significant engineering resources ## CRM Data Objects The CRM API provides access to the following standardized objects: - **Companies**: Organizations, accounts, and business entities - **Contacts**: People, leads, and customer contact information - **Deals**: Opportunities, sales deals, and revenue pipeline - **Events**: Activities, meetings, calls, and customer interactions - **Leads**: Prospective customers and lead information - **Pipelines**: Sales pipelines, stages, and deal progression ## Common Use Cases ### Sales Enablement Tools Build sales productivity tools that sync with customers' CRM systems. Access contact and deal data to provide sales intelligence, automate follow-ups, and track sales performance across Salesforce, HubSpot, or any supported CRM. ### Customer Data Platforms Create unified customer profiles by aggregating data from CRM platforms and other sources. Combine contact information, deal history, and interaction data from multiple CRMs into a single customer view. ### Marketing Automation Integration Sync marketing campaign data with CRM contacts and leads. Track how marketing activities influence sales pipeline and revenue by connecting marketing platforms with customers' CRM data. ### AI-Powered Sales Assistants Build AI agents that analyze CRM data, identify sales opportunities, predict deal outcomes, and provide recommendations by accessing contact, company, and deal information across multiple CRM platforms. ### Analytics & Reporting Dashboards Pull CRM data into analytics platforms to create custom sales reports, pipeline visualizations, and revenue forecasts. Combine CRM data with other business metrics for comprehensive business intelligence. ### Lead Routing & Assignment Automate lead distribution by accessing lead and contact data from CRM platforms. Build intelligent lead routing systems that assign leads based on territory, product, or sales rep capacity. ## Supported CRM Platforms The Unified CRM API supports integration with major CRM platforms including Salesforce, HubSpot, Pipedrive, Zoho CRM, Microsoft Dynamics, Close, Copper, and many others. Each integration provides access to the standardized data objects listed above. ## Why Use a Unified CRM API? **Traditional Approach:** - Build separate integrations for Salesforce, HubSpot, Pipedrive, etc. - Learn 5+ different authentication systems and API specifications - Maintain code for 5+ different API endpoints, data formats, and error handling patterns - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific field mappings, custom objects, and data structures **With Unified CRM API:** - Integrate once with a single API that works across all CRM platforms - Use one authentication flow for all platforms - Work with normalized contact, company, deal, and pipeline objects - Automatic handling of API changes, rate limits, and platform-specific differences - Built-in webhook support for real-time CRM data updates ## Integration Scenarios ### For SaaS Applications Enable your end-users to add an integration to their CRM account from your Integrations Page in your software application. Support any major CRM platform without building separate integrations for each one. ### For AI & Automation Tools Create AI-powered CRM assistants that can read contact data, analyze sales patterns, generate insights, and provide sales recommendations across multiple platforms using a single API. ### For Analytics & BI Platforms Pull CRM data from all major platforms into your data warehouse or analytics tool. Create unified sales reports, pipeline dashboards, and revenue forecasts without building multiple data pipelines. ### For Communication Tools Enhance communication platforms with CRM context. Display contact information, deal status, and interaction history from customers' CRM systems directly in your chat, email, or phone tools. ### For Project Management Connect project management with CRM data. Automatically create projects from won deals, assign tasks to account owners, and track client deliverables linked to CRM accounts. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified CRM API provides real-time access to CRM data. Every API request hits the source platform directly, ensuring you always have the most current contact, company, and deal information. This real-time architecture is ideal for: - Sales dashboards displaying live pipeline and deal data - Real-time lead routing and assignment workflows - Up-to-the-minute contact and company information - Automated sales workflows that react to CRM changes instantly ## Privacy & Security Unified.to never stores your customers' CRM data. All requests are stateless and pass through to the CRM platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-8acafcf5-aec0-4160-8e1f-4682042f11e1?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-8acafcf5-aec0-4160-8e1f-4682042f11e1%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## datastore URL: https://docs.unified.to/datastore/overview The Unified Datastore API enables developers and product managers to access structured data from databases, data lakes, and spreadsheets through a single, standardized interface. Retrieve databases, tables, records, and run queries against Google Sheets, Airtable, Snowflake, and other data platforms - all with one API. ## What is the Unified Datastore API? Data platforms like Google Sheets, Airtable, and Snowflake each have unique APIs with different authentication methods, data models, and query languages. The Unified Datastore API normalizes these differences, allowing you to build once and read and write tabular data across spreadsheets, operational databases, and cloud data warehouses without maintaining separate integrations for each provider. ## Datastore Data Objects The Datastore API provides access to the following standardized objects: - **Databases**: Workbooks, bases, schemas, and data lake connections with metadata and access details - **Queries**: Structured queries and SQL with filters, sorting, pagination, and aggregations - **Records**: Rows and entries with typed field values mapped to a common schema - **Tables**: Sheets, tables, and views with column definitions, field types, and relationships ## Common Use Cases ### Spreadsheet & No-Code Data Sync Build tools that sync data between Google Sheets, Airtable, and other tabular platforms. Read and write records across spreadsheets and bases without building custom integrations for each product. ### Analytics & Reporting Create dashboards and reports by querying data from Snowflake and other data platforms. Run filtered queries, aggregations, and counts to power BI tools and internal reporting. ### ETL & Data Pipelines Develop data pipelines that extract records from spreadsheets and operational stores, transform them with a consistent query model, and load them into warehouses or downstream systems. ### Operational App Backends Use connected spreadsheets and databases as lightweight backends for internal tools and workflows. List tables, create and update records, and query data through one API regardless of the underlying platform. ### Cross-Platform Data Migration Migrate data between platforms by reading tables and records from one integration and writing them to another. Move bases, sheets, and table schemas with normalized field types and relationships. ### AI & Automation Workflows Build AI agents and automations that read structured data, filter records, and update rows across Google Sheets, Airtable, Snowflake, and other supported integrations using a single query and record model. ## enrich URL: https://docs.unified.to/enrich/overview The Unified Enrichment API enables developers and product managers to access data enrichment services from multiple platforms through a single, standardized interface. Enrich company and person data using Clearbit, FullContact, Hunter, and other major data enrichment platforms - all with one API. ## What is the Unified Enrichment API? Data enrichment platforms like Clearbit, FullContact, and Hunter each have unique APIs with different authentication methods and data models. The Unified Enrichment API normalizes these differences, allowing you to build once and support all major enrichment platforms without maintaining separate integrations. ## Enrichment Data Objects The Enrichment API provides access to the following standardized objects: - **Companies**: Company information, firmographics, and business data - **Persons**: Person profiles, contact information, and demographics ## Common Use Cases ### Lead Enrichment Automatically enrich leads with additional company and contact information. Turn email addresses into full profiles with job titles, company data, social profiles, and more. ### CRM Data Enhancement Enrich CRM contacts and accounts with up-to-date firmographic and demographic data. Fill in missing fields, validate information, and keep customer records current. ### Sales Intelligence Build sales tools that provide enriched prospect information. Access company size, industry, revenue, technology stack, and decision-maker details to help sales teams prioritize and personalize outreach. ### Marketing Personalization Enrich marketing database with additional attributes for better segmentation and personalization. Access company and person data to create more targeted marketing campaigns. ### Form Simplification Reduce form fields by enriching partial data. Capture just an email address and automatically fill in company name, job title, location, and other details using enrichment APIs. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-1c980c5b-55cd-482c-be46-68a6baee892b?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-1c980c5b-55cd-482c-be46-68a6baee892b%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## forms URL: https://docs.unified.to/forms/overview The Unified Forms API enables developers and product managers to access form and survey data from multiple form builder platforms through a single, standardized interface. Retrieve forms, submissions, fields, and responses from Typeform, Google Forms, Jotform, SurveyMonkey, and other major form platforms - all with one API. ## What is the Unified Forms API? Form platforms like Typeform, Google Forms, Jotform, and SurveyMonkey each have unique APIs with different authentication methods, data models, and endpoints. The Unified Forms API normalizes these differences, allowing you to build once and support all major form platforms without maintaining separate integrations for each provider. Form builders are used by businesses to collect information from customers, employees, and partners through surveys, registration forms, feedback forms, lead capture forms, and more. ## Key Benefits for Developers - **Single Integration**: Write code once to support Typeform, Google Forms, Jotform, and more - no need to learn multiple form APIs - **Normalized Data Models**: Work with consistent form, submission, and field structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date form responses - **Zero Maintenance**: No need to track API version changes or deprecations across multiple form platforms - **Faster Development**: Ship form integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred form platform without custom development - **Competitive Advantage**: Launch with support for all major form builders while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to access their form data from Typeform, Google Forms, or any supported platform through your product - **Reduced Time-to-Market**: Get form integration features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new form platforms as they emerge without significant engineering resources ## Forms Data Objects The Forms API provides access to the following standardized objects: - **Forms**: Form templates, questions, field definitions, settings, and configuration including published URLs and active status - **Submissions**: Form responses with answers, respondent information, and submission timestamps ## Common Use Cases ### Lead & Customer Data Capture Collect leads and customer information from multiple form platforms into your CRM or database. Automatically sync form submissions from Typeform, Google Forms, or any form builder into your sales and marketing systems. ### Survey & Feedback Analysis Build analytics dashboards that aggregate survey responses and feedback from multiple form platforms. Analyze customer satisfaction, employee engagement, and product feedback collected through various form tools. ### Form Response Automation Create automated workflows triggered by form submissions. When someone fills out a contact form, registration form, or survey, automatically trigger actions in your application or send data to other systems. ### Multi-Channel Data Collection Aggregate data collected through different form platforms into a unified database. Collect information through Typeform surveys, Google Forms, and other tools while maintaining a single source of truth. ### AI-Powered Form Analytics Build AI agents that analyze form responses, identify trends, generate insights, and provide recommendations by accessing submission data across multiple form platforms through a single interface. ### Lead Scoring & Qualification Automatically score and qualify leads based on form submission data. Analyze responses from contact forms, registration forms, and surveys to prioritize and route leads to the right teams. ### Compliance & Data Management Centralize form data collection for compliance and data governance. Ensure consistent data handling across all form platforms and maintain audit trails of form submissions. ## Supported Form Platforms The Unified Forms API supports integration with major form builder platforms including Typeform, Google Forms, Jotform, SurveyMonkey, Microsoft Forms, Wufoo, Formstack, and many others. Each integration provides access to the standardized data objects listed above. ## Why Use a Unified Forms API? **Traditional Approach:** - Build separate integrations for Typeform, Google Forms, Jotform, etc. - Learn 5+ different authentication systems and API specifications - Maintain code for 5+ different API endpoints, data formats, and error handling patterns - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific field types, response formats, and data structures **With Unified Forms API:** - Integrate once with a single API that works across all form platforms - Use one authentication flow for all platforms - Work with normalized form and submission objects - Automatic handling of API changes, rate limits, and platform-specific differences - Built-in webhook support for real-time form submission notifications ## Integration Scenarios ### For SaaS Applications Add "Connect Your Forms" features that work with any major form platform. Build marketing tools, CRM systems, or data collection platforms that automatically sync form submissions without building separate integrations for each form builder. ### For AI & Automation Tools Create AI-powered assistants that can access form data, analyze responses, identify patterns, and provide insights across multiple platforms using a single, consistent API interface. ### For Analytics & BI Platforms Pull form submission data from all major platforms into your data warehouse or analytics tool. Create unified response dashboards, trend analysis, and reporting without building multiple data pipelines. ### For Marketing Automation Connect marketing automation platforms with form builder tools. Automatically add form respondents to email campaigns, segment audiences based on form responses, and track conversion funnels. ### For Customer Data Platforms Unify customer data collected through various form platforms. Build comprehensive customer profiles by combining form responses with other customer interaction data. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified Forms API provides real-time access to form data. Every API request hits the source platform directly, ensuring you always have the most current form submissions and responses. This real-time architecture is ideal for: - Lead capture systems that need immediate access to new submissions - Real-time notification systems for form responses - Automated workflows triggered by form submissions - Dashboards displaying up-to-the-minute form response data ## Privacy & Security Unified.to never stores your customers' form data or responses. All requests are stateless and pass through to the form platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. ## genai URL: https://docs.unified.to/genai/overview The Unified Generative AI API enables developers and product managers to access AI capabilities from multiple platforms through a single, standardized interface. Work with AI models, generate embeddings, and manage prompts across OpenAI, Anthropic, Google AI, and other major AI platforms - all with one API. ## What is the Unified Generative AI API? AI platforms like OpenAI, Anthropic, and Google AI each have unique APIs with different authentication methods and data models. The Unified Generative AI API normalizes these differences, allowing you to build once and support all major AI platforms without maintaining separate integrations. ## Generative AI Data Objects The Generative AI API provides access to the following standardized objects: - **Embeddings**: Text embeddings and vector representations - **Models**: AI models, capabilities, and model information - **Prompts**: Prompt templates, configurations, and prompt management ## Common Use Cases ### Multi-Model AI Applications Build AI applications that work with multiple AI providers. Switch between OpenAI, Anthropic, or other models without rewriting integration code, enabling fallback strategies and cost optimization. ### AI Model Comparison Create tools that compare outputs from different AI models. Test the same prompt across multiple providers to evaluate quality, cost, and performance. ### Embedding Generation Generate text embeddings using different AI platforms. Build vector search, semantic similarity, or RAG (Retrieval Augmented Generation) applications that work with any embedding provider. ### Prompt Management Tools Develop prompt management and testing tools that work across AI platforms. Store, version, and test prompts with different models to optimize AI application performance. ### AI Cost Optimization Build tools that route AI requests to different providers based on cost, availability, or performance. Implement intelligent load balancing across multiple AI platforms. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-55c9c20f-5abd-4ef9-b409-ae6ac13c2814?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-55c9c20f-5abd-4ef9-b409-ae6ac13c2814%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## healthcare URL: https://docs.unified.to/healthcare/overview The Unified Healthcare API enables developers and product managers to access clinical and scheduling data from electronic health record (EHR) and practice management systems through a single, standardized interface. Retrieve patients, practitioners, locations, appointments, open slots, encounters, conditions, allergies, medications, observations, and immunizations from Epic, Oracle Health, athenahealth, MEDITECH, NextGen Healthcare, Veradigm, DrChrono, Elation Health, Tebra, Cliniko, Halaxy, EMIS, and other healthcare platforms — all with one API. ## What is the Unified Healthcare API? EHR and practice management platforms like Epic, Oracle Health, and athenahealth each have unique APIs with different authentication methods, data models, and endpoints. Many expose HL7 FHIR R4, but each vendor implements a different subset of resources, search parameters, and extensions, and many smaller practice management systems use their own REST APIs entirely. The Unified Healthcare API normalizes these differences into a simplified model mapped from FHIR R4, allowing you to build once and support the systems your healthcare customers already use, without maintaining separate integrations for each provider. Healthcare integrations represent people as **patients** and **practitioners**, places of care as **locations**, scheduling as **appointments** and bookable **slots**, and the clinical record as **encounters**, **conditions**, **allergies**, **medications**, **observations**, and **immunizations**, each linked back to its patient. ## Key Benefits for Developers - **Single Integration**: Write code once to support multiple EHR and practice management platforms — no need to learn each vendor's FHIR profile or proprietary API - **Normalized Data Models**: Work with consistent patient, appointment, and clinical-record structures across all platforms, with standard code systems (LOINC, SNOMED CT, RxNorm, ICD-10, CVX) preserved as codings - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date schedules and records - **Zero Maintenance**: No need to track API version changes or deprecations across multiple healthcare platforms - **Faster Development**: Ship healthcare integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Let clinics, practices, and health systems connect the EHR they already use without custom development - **Competitive Advantage**: Launch with support for major EHR platforms while competitors build integrations one at a time - **Customer Flexibility**: Read patient records and book appointments from a single interface regardless of the practice's software - **Reduced Time-to-Market**: Get patient-facing and scheduling features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new healthcare platforms as customers request them without significant engineering resources ## Healthcare Data Objects The Healthcare API provides access to the following standardized objects: - **Patients**: Demographics with name, gender, birthdate, deceased status, emails, telephones, address, preferred language, business identifiers such as an MRN, and primary care practitioners (`practitioner_ids`). - **Practitioners**: Doctors, nurses, and other providers with name, title, gender, contact details, identifiers such as an NPI, specialties, qualifications, languages, and the locations where they provide care (`location_ids`). - **Locations**: Clinics, hospitals, departments, and other places of care with name, description, managing organization, address, telephones, coordinates, identifiers, and a `parent_id` for locations nested within another. - **Appointments**: Booked visits with patient, practitioners, location, occupied slots, status (proposed, pending, booked, arrived, checked in, fulfilled, cancelled, no-show, waitlist), start and end times, service types, reasons, and cancellation reason. - **Slots**: Bookable availability on a practitioner's schedule at a location, with status (free, busy, busy-unavailable, busy-tentative), start and end times, service types, and whether the slot is overbooked. - **Encounters**: Visits and admissions with patient, practitioners, location, the appointment that scheduled them, conditions addressed, status, class (ambulatory, emergency, inpatient, observation, home health, virtual), reasons, and start and end times. - **Conditions**: Problems, diagnoses, and health concerns with codings, clinical status, category, and onset, resolution, and recorded dates. - **Allergies**: Allergies and intolerances with substance codings, clinical status, category (food, medication, environment, biologic), criticality, reaction manifestations, and onset date. - **Medications**: Medication orders and prescriptions with codings, status, dosage instructions, refill count, and authored, start, and end dates. - **Observations**: Lab results, vital signs, and other measurements with codings, status, category, numeric or text value and unit, interpretation, reference range, and components such as systolic and diastolic blood pressure. - **Immunizations**: Administered vaccines with codings, status, administration date, lot number, and expiration date. Clinical records link to their patient through `patient_id`, and to the encounter and practitioner that recorded them through `encounter_id` and `practitioner_id`. Each integration supports the objects and write operations its vendor documents; see the integration's support matrix for details. ## Common Use Cases ### Online Appointment Booking Build patient-facing scheduling tools that list practitioners and locations, show open slots, and book, reschedule, or cancel appointments directly in the practice's EHR — without staff re-keying bookings into each system. ### Patient-Facing Health Apps Create patient portals and companion apps that display a patient's conditions, allergies, medications, lab results, and immunizations, using standard codings so the same app works across every connected EHR. ### Care Coordination & Referrals Share a patient's clinical summary between providers. Pull encounters, active problems, and current medications to prepare referrals, care plans, and handoffs across organizations that use different systems. ### AI Clinical Assistants Build AI agents that read encounters, observations, and medication lists to summarize patient history, prepare visit notes, flag abnormal results, and answer questions about a patient's record. ### Remote Patient Monitoring Write vital signs and device readings back to the EHR as observations where the platform supports it, and read the patient's existing results and reference ranges to put new measurements in context. ### Healthcare Analytics & Reporting Pull scheduling and clinical data into analytics dashboards and data warehouses. Report on appointment volume, no-show rates, provider utilization, and population health across practices and platforms. ## Why Use a Unified Healthcare API? **Traditional Approach:** - Build separate integrations for each EHR and practice management platform - Learn multiple authentication systems, including SMART on FHIR launch flows and vendor-specific OAuth - Map each vendor's FHIR profile or proprietary schema to your own model - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific rate limits, pagination, and search parameters **With Unified Healthcare API:** - Integrate once with a single API that works across healthcare platforms - Use one authentication flow for all platforms - Work with normalized patient, scheduling, and clinical-record objects - Automatic handling of API changes, rate limits, and platform-specific differences - Consistent list filters such as `patient_id`, `practitioner_id`, and `location_id` across platforms ## Integration Scenarios ### For Digital Health SaaS Applications Add "Connect Your EHR" features that work with supported healthcare platforms. Build scheduling, intake, patient engagement, or telehealth products without building separate integrations for each provider. ### For AI & Automation Tools Create AI-powered assistants that read patient, encounter, and clinical data, then summarize records, prepare documentation, and automate routine work across platforms using a single, consistent API interface. ### For Analytics & Population Health Platforms Pull scheduling and clinical data from connected platforms into your data warehouse or analytics tool. Create unified utilization and outcomes reports without building multiple data pipelines. ### For Remote Monitoring & Device Platforms Connect device readings and patient-reported data to the patient's record. Keep clinicians working in their own EHR while your platform supplies the measurements. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified Healthcare API provides real-time access to healthcare data. Every API request hits the source platform directly, ensuring you always have the most current schedules, appointments, and clinical records. This real-time architecture is ideal for: - Booking flows that need current slot availability - Front-desk and check-in tools that rely on today's appointment status - Clinical dashboards displaying the latest results and vitals - AI applications that need fresh data for analysis and recommendations ## Privacy & Security Unified.to never stores your customers' healthcare data. All requests are stateless and pass through to the healthcare platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. ## hris URL: https://docs.unified.to/hris/overview The Unified HRIS (Human Resources Information Systems) API enables developers and product managers to access HR data from multiple HR platforms through a single, standardized interface. Retrieve employee information, groups, locations, benefits, payroll, time off, and more from BambooHR, Workday, ADP, and other major HRIS platforms - all with one API. ## What is the Unified HRIS API? HRIS platforms like BambooHR, Workday, and ADP each have unique APIs with different authentication methods, data models, and endpoints. The Unified HRIS API normalizes these differences, allowing you to build once and support all major HR platforms without maintaining separate integrations for each provider. HRIS solutions mostly deal with employees and their groups. Groups represent collections of personnel at a company and may have different labels depending on the company type and size (departments, teams, divisions, etc.). ## Key Benefits for Developers - **Single Integration**: Write code once to support BambooHR, Workday, ADP, and more - no need to learn multiple HRIS APIs - **Normalized Data Models**: Work with consistent employee, group, and HR data structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date employee data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple HR platforms - **Faster Development**: Ship HRIS integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred HR platform without custom development - **Competitive Advantage**: Launch with support for all major HRIS platforms while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to access their employee data from BambooHR, Workday, or any supported HRIS through your product - **Reduced Time-to-Market**: Get HR integration features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new HRIS platforms as they emerge without significant engineering resources ## HRIS Data Objects The HRIS API provides access to the following standardized objects: - **Benefits**: Health insurance, retirement plans, and employee benefits - **Companies**: Organization information and company entities - **Deductions**: Payroll deductions and withholdings - **Devices**: Company-issued devices and equipment assigned to employees - **Employees**: Employee profiles, contact information, and employment details - **Groups**: Departments, teams, divisions, and organizational units - **Locations**: Office locations, work sites, and geographic information - **Payslips**: Pay stubs and payroll information - **Time Off**: Vacation, sick leave, PTO requests and balances - **Time Shifts**: Work schedules, shifts, and time tracking ## Common Use Cases ### Employee Directory & Org Charts Build employee directories and organizational charts that sync with customers' HRIS systems. Display current employee information, reporting structures, departments, and contact details from BambooHR, Workday, or any supported platform. ### Identity & Access Management Automate user provisioning and de-provisioning by syncing with HRIS employee data. Automatically create accounts when employees are hired, update permissions when they change roles, and revoke access when they leave. ### Benefits Administration Create benefits enrollment and management tools that integrate with HRIS platforms. Access employee benefit information, eligibility, and enrollment status across multiple HR systems. ### Time & Attendance Tracking Build time tracking and attendance tools that sync with HRIS systems. Access employee schedules, time off balances, and shift information to create comprehensive workforce management solutions. ### Payroll Integration Connect payroll systems with HRIS data. Access employee information, deductions, and pay schedules to automate payroll processing and reporting. ### HR Analytics & Reporting Pull employee data from HRIS platforms into analytics tools. Create custom HR reports, analyze workforce trends, track turnover, and generate insights from employee data across multiple HR systems. ### Onboarding & Offboarding Automation Automate employee onboarding and offboarding workflows by accessing HRIS data. Trigger workflows when new employees are added or when employees are terminated, ensuring consistent processes across all HR platforms. ## Supported HRIS Platforms The Unified HRIS API supports integration with major HR platforms including BambooHR, Workday, ADP, Namely, Gusto, Rippling, Personio, and many others. Each integration provides access to the standardized data objects listed above. ## Why Use a Unified HRIS API? **Traditional Approach:** - Build separate integrations for BambooHR, Workday, ADP, etc. - Learn 5+ different authentication systems and API specifications - Maintain code for 5+ different API endpoints, data formats, and error handling patterns - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific employee data structures and field mappings **With Unified HRIS API:** - Integrate once with a single API that works across all HRIS platforms - Use one authentication flow for all platforms - Work with normalized employee, group, and HR data objects - Automatic handling of API changes, rate limits, and platform-specific differences - Built-in webhook support for real-time employee data updates ## Integration Scenarios ### For SaaS Applications Add "Connect Your HR System" features that work with any major HRIS platform. Build productivity tools, collaboration software, or workforce management solutions without building separate integrations for each HR platform. ### For IT & Security Tools Automate identity and access management by syncing with HRIS employee data. Provision user accounts, manage permissions, and enforce security policies based on current employee status and organizational structure. ### For AI & Automation Tools Create AI-powered HR assistants that can access employee data, analyze workforce patterns, generate insights, and provide recommendations across multiple platforms using a single API. ### For Analytics & BI Platforms Pull HR data from all major platforms into your data warehouse or analytics tool. Create unified workforce reports, retention dashboards, and headcount analytics without building multiple data pipelines. ### For Benefits Providers Connect benefits administration with customers' HRIS systems. Automatically sync employee eligibility, enrollment data, and coverage information between benefits platforms and HR systems. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified HRIS API provides real-time access to HR data. Every API request hits the source platform directly, ensuring you always have the most current employee information. This real-time architecture is ideal for: - Employee directories with up-to-date contact information - Real-time user provisioning and de-provisioning - Current organizational charts and reporting structures - Automated workflows that react to employee changes instantly ## Privacy & Security Unified.to never stores your customers' employee data. All requests are stateless and pass through to the HRIS platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-ab78fdcd-2c27-43a9-9b00-ada3b765020e?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-ab78fdcd-2c27-43a9-9b00-ada3b765020e%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## kms URL: https://docs.unified.to/kms/overview The Unified Knowledge Management System (KMS) API enables developers and product managers to access knowledge base data from multiple platforms through a single, standardized interface. Retrieve spaces, pages, and comments from Confluence, Notion, Guru, and other major knowledge management platforms - all with one API. ## What is the Unified KMS API? Knowledge management platforms like Confluence, Notion, and Guru each have unique APIs with different authentication methods and data models. The Unified KMS API normalizes these differences, allowing you to build once and support all major KMS platforms without maintaining separate integrations. ## KMS Data Objects The KMS API provides access to the following standardized objects: - **Comments**: Page comments, discussions, and feedback - **Pages**: Wiki pages, documents, and knowledge articles - **Spaces**: Workspaces, knowledge bases, and content hierarchies ## Common Use Cases ### Knowledge Base Search Build unified search tools that query across multiple knowledge management platforms. Search Confluence, Notion, and other platforms simultaneously to find relevant documentation and information. ### Documentation Portals Create documentation portals that aggregate content from multiple knowledge bases. Display knowledge articles from different sources in a single, searchable interface. ### AI Knowledge Assistants Build AI assistants that access knowledge base content to answer questions. Retrieve relevant pages and documentation from Confluence, Notion, or other platforms to power AI-driven support tools. ### Content Migration Tools Develop tools that migrate or sync content between different knowledge management platforms. Move documentation from Confluence to Notion, or keep multiple knowledge bases synchronized. ### Knowledge Analytics Analyze knowledge base usage patterns. Track page views, popular content, outdated articles, and content gaps across multiple knowledge management platforms. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-0d64464b-a325-4eec-8c53-6ab2a6c83fc3?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-0d64464b-a325-4eec-8c53-6ab2a6c83fc3%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## legal URL: https://docs.unified.to/legal/overview The Unified Legal API enables developers and product managers to access legal practice management data from multiple platforms through a single, standardized interface. Retrieve matters, practice areas, time entries, and expenses from Clio, MyCase, Smokeball, PracticePanther, Actionstep, Filevine, LEAP, and other legal platforms — all with one API. ## What is the Unified Legal API? Legal practice management platforms like Clio, MyCase, and Smokeball each have unique APIs with different authentication methods, data models, and endpoints. Some call a case a "matter", others a "case", "project", or "action". The Unified Legal API normalizes these differences, allowing you to build once and support the practice management systems your law-firm customers already use, without maintaining separate integrations for each provider. Legal integrations represent client engagements as **matters**, the firm's areas of law as **practice areas**, and billable work as **time entries** and **expenses** recorded against a matter. ## Key Benefits for Developers - **Single Integration**: Write code once to support multiple legal platforms — no need to learn different practice management APIs - **Normalized Data Models**: Work with consistent matter, practice area, time entry, and expense structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date matter and billing data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple legal platforms - **Faster Development**: Ship legal integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Let law firms connect the practice management system they already use without custom development - **Competitive Advantage**: Launch with support for major legal platforms while competitors build integrations one at a time - **Customer Flexibility**: Read and write matters, time, and expenses from a single interface regardless of the firm's software - **Reduced Time-to-Market**: Get legal-tech features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new legal platforms as customers request them without significant engineering resources ## Legal Data Objects The Legal API provides access to the following standardized objects: - **Matters**: Cases and client engagements with name, number, description, status (pending, open, closed), billing method (hourly, flat fee, contingency), practice area, and stage. Matters link to their clients through `contact_ids` / `company_ids` and to every related person or organization through `parties`, each with a role (client, opposing party, opposing counsel, co-counsel, witness, expert, judge, referrer). Responsible, originating, and assigned staff are linked through `user_id`, `originating_user_id`, and `user_ids`, along with opened, closed, pending, and statute-of-limitations dates. - **Practice Areas**: The firm's areas of law, such as personal injury, family, immigration, or corporate, with a name, code, and a normalized `type`. - **Time Entries**: Billable and non-billable time recorded against a matter, with timekeeper (`user_id`), date worked, minutes worked and billable minutes, hourly rate, total, billed status, invoice, and UTBMS/LEDES activity and task codes. - **Expenses**: Costs and disbursements recorded against a matter, with quantity, unit price, total, hard or soft cost type, category, billable and billed status, invoice, and UTBMS/LEDES expense code. Calendar events from the [Calendar API](/calendar/overview) can also be linked to a matter through `matter_id`, so court dates and deadlines appear alongside the case they belong to. ## Common Use Cases ### Client Intake & Matter Creation Build intake tools that create matters in the firm's practice management system as soon as a new client signs up. Set the practice area, client, responsible attorney, and key dates without re-keying data into each platform. ### Time Tracking & Billing Capture Create time-capture tools that log billable time and expenses directly against the right matter. Record hours, rates, and UTBMS codes from a timer, a calendar, or an AI assistant and push them into the firm's billing workflow. ### Legal Analytics & Reporting Pull matters, time entries, and expenses into analytics dashboards and data warehouses. Report on utilization, realization, matter profitability, and workload by practice area or attorney across the firm. ### AI Legal Assistants Build AI agents that read matter details, parties, and activity to summarize cases, draft time-entry narratives, flag upcoming statute-of-limitations dates, and answer questions about a firm's caseload. ### Document & E-Signature Workflows Connect document automation and e-signature tools to the firm's matters. Generate engagement letters and filings from matter and party data, and file them against the right case. ### Legal Payments & Accounting Sync billable time and expenses into accounting, payments, and trust-accounting tools. Reconcile what was worked against what was invoiced using billed status and invoice references. ## Why Use a Unified Legal API? **Traditional Approach:** - Build separate integrations for each practice management platform - Learn multiple authentication systems and API specifications - Map each platform's idea of a matter, case, project, or action to your own model - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific rate limits, pagination, and webhook implementations **With Unified Legal API:** - Integrate once with a single API that works across legal platforms - Use one authentication flow for all platforms - Work with normalized matter, practice area, time entry, and expense objects - Automatic handling of API changes, rate limits, and platform-specific differences - Built-in webhook support for real-time updates across connected platforms ## Integration Scenarios ### For Legal-Tech SaaS Applications Add "Connect Your Practice Management System" features that work with supported legal platforms. Build intake, document, billing, or client-portal products without building separate integrations for each provider. ### For AI & Automation Tools Create AI-powered assistants that read matter, party, time, and expense data, then summarize cases, draft entries, and automate routine work across platforms using a single, consistent API interface. ### For Analytics & BI Platforms Pull matter and billing data from connected platforms into your data warehouse or analytics tool. Create unified utilization, profitability, and caseload reports without building multiple data pipelines. ### For Payments & Accounting Platforms Connect billable time and expenses to invoicing, payments, and trust accounting. Keep financial records in step with the work recorded in the firm's practice management system. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified Legal API provides real-time access to legal data. Every API request hits the source platform directly, ensuring you always have the most current matters, time entries, and expenses. This real-time architecture is ideal for: - Dashboards displaying live caseload and billing activity - Time-capture workflows that need the current list of open matters - Deadline tracking tools that rely on current matter dates - AI applications that need fresh data for analysis and recommendations ## Privacy & Security Unified.to never stores your customers' legal data. All requests are stateless and pass through to the legal platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. ## lms URL: https://docs.unified.to/lms/overview The Unified Learning Management System (LMS) API enables developers and product managers to access educational data from multiple LMS platforms through a single, standardized interface. Retrieve courses, classes, students, and instructor data from Canvas, Moodle, Blackboard, and other major LMS platforms - all with one API. ## What is the Unified LMS API? Learning management platforms like Canvas, Moodle, and Blackboard each have unique APIs with different authentication methods and data models. The Unified LMS API normalizes these differences, allowing you to build once and support all major LMS platforms without maintaining separate integrations. ## LMS Data Objects The LMS API provides access to the following standardized objects: - **Classes**: Class sections, sessions, and enrollments - **Courses**: Course catalogs, curricula, and course information - **Instructors**: Teachers, professors, and instructor profiles - **Students**: Student profiles, enrollments, and learner data ## Common Use Cases ### Learning Analytics Build learning analytics dashboards that aggregate data from multiple LMS platforms. Track student engagement, course completion rates, and learning outcomes across Canvas, Moodle, or any supported platform. ### Student Information Systems Integrate student information systems with LMS platforms. Sync student enrollments, course registrations, and academic records between different educational systems. ### Educational Tools & Extensions Create educational tools that work with any LMS platform. Build study aids, assignment helpers, or collaboration tools that integrate with students' and instructors' existing LMS. ### Reporting & Compliance Generate educational reports and compliance documentation by accessing course and student data from LMS platforms. Track accreditation requirements, learning objectives, and educational outcomes. ### Course Marketplace Integration Connect course marketplaces or content providers with LMS platforms. Automatically provision courses, sync enrollments, and track learner progress across different systems. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-4e8ac380-28f2-4883-97f2-a83627c1659a?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-4e8ac380-28f2-4883-97f2-a83627c1659a%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## martech URL: https://docs.unified.to/martech/overview The Unified Marketing Automation API enables developers and product managers to access marketing campaign data from multiple marketing platforms through a single, standardized interface. Retrieve lists, members, and campaign information from Mailchimp, HubSpot Marketing, ActiveCampaign, and other major marketing automation platforms - all with one API. ## What is the Unified Marketing Automation API? A Marketing Automation solution can be email marketing software or a marketing CRM, enabling businesses to manage and automate their marketing campaigns and customer communications. Each platform has unique APIs with different authentication methods and data models. The Unified Marketing Automation API normalizes these differences. ## Marketing Data Objects Marketing API endpoints typically work with the following objects: - **Lists**: Marketing lists (also known as Campaigns or Audiences in other platforms) - **Members**: List members and contacts (similar to CRM Contacts, and may be identical when sourced from the same platform) ## Common Use Cases ### Campaign Analytics Dashboards Analyze Campaigns: Leverage real-time aggregated campaign data from multiple sources into a single dashboard. Track email performance, list growth, and engagement metrics across Mailchimp, HubSpot, and other platforms. ### Lead Nurturing Integration Nurture Leads: Integrate customer data from multiple systems to automate nurturing workflows. Sync contacts between marketing platforms and CRM systems to create seamless lead nurturing campaigns. ### Content Personalization Personalize Content: Power AI content recommendations with customer data from various marketing systems. Access member profiles and engagement history to deliver personalized marketing content. ### Marketing List Management Build tools that manage marketing lists across multiple platforms. Sync subscriber lists, manage unsubscribes, and segment audiences across different email marketing and marketing automation systems. ### Multi-Platform Email Analytics Aggregate email campaign performance data from multiple marketing platforms. Create unified reports showing email open rates, click-through rates, and conversion metrics across all marketing tools. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-a2293725-e649-46e6-ba69-9fb8bb15876f?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-a2293725-e649-46e6-ba69-9fb8bb15876f%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## messaging URL: https://docs.unified.to/messaging/overview The Unified Messaging API enables developers and product managers to access communication data from multiple messaging platforms through a single, standardized interface. Retrieve channels, messages, and events from Slack, Microsoft Teams, Discord, and other major messaging platforms - all with one API. ## What is the Unified Messaging API? Messaging platforms like Slack, Microsoft Teams, and Discord each have unique APIs with different authentication methods and data models. The Unified Messaging API normalizes these differences, allowing you to build once and support all major messaging platforms without maintaining separate integrations. ## Messaging Data Objects The Messaging API provides access to the following standardized objects: - **Channels**: Channels, rooms, groups, and conversations - **Events**: Message events, reactions, and activity notifications - **Messages**: Chat messages, threads, and direct messages ## Common Use Cases ### Chatbots & Virtual Assistants Build chatbots that work across Slack, Teams, Discord, and other messaging platforms. Create a single bot that can respond to messages, answer questions, and automate workflows across all platforms. ### Message Analytics & Insights Analyze communication patterns across messaging platforms. Track message volume, response times, channel activity, and team collaboration metrics from Slack, Teams, or any supported platform. ### Cross-Platform Messaging Create tools that sync messages between different messaging platforms. Bridge communications from Slack to Teams, or enable cross-platform team collaboration. ### Compliance & Archiving Build compliance and archiving tools that capture messages from multiple messaging platforms. Store, search, and analyze messages for regulatory compliance and e-discovery. ### Workflow Automation Automate workflows triggered by messaging events. Create tools that perform actions based on specific messages, keywords, or reactions across all messaging platforms. ## Handling threaded messages Use the `parent_id` for maximum flexibility in handling threaded messages. Note: `parent_message_id` and `root_message_id` are deprecated. `parent_id`: Represents the **immediate predecessor message** in a thread. This allows you to identify which message a given message directly replies to. Threading relationships form a **tree** of messages, where each reply points to its parent ### Replying to Messages - Set `parent_id` to the ID of the message being replied to. This ensures the reply is properly threaded and appears under the correct message across all integrations that support threaded conversations. ### Listing Messages in a Thread You can retrieve messages within a thread using the `parent_id` relationship. #### Using the `expand` Filter When listing messages, you can set the `expand` filter to `true` to automatically include **all nested descendants** of a message, not just its immediate children. This provides a full flat view of the thread hierarchy in a single query. ### Consistency Across Integrations Using the `parent_id` structure ensures: - **Consistent message hierarchy** across sender and receiver systems. - **Simplified logic** for reconstructing conversation trees. If a message does **not** include a `parent_id`, it is treated as either: - A **standalone message**, or - The **root** of a new thread. ## Working with Hierarchical Data in Messaging APIs Messaging integrations typically organize data in a hierarchical (tree-like) structure. For detailed guidance on traversing hierarchical data, refer to our guide: [How to traverse hierarchical data](https://docs.unified.to/guides/working_with_hierarchical_tree_data_in_storage_messaging_and_kms_apis) [Run In Postman](https://god.gw.postman.com/run-collection/16228585-a38b869f-6c79-4b37-b300-ac2abc4e9811?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-a38b869f-6c79-4b37-b300-ac2abc4e9811%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## metadata URL: https://docs.unified.to/metadata/overview In Unified, metadata is "custom fields": the user-defined fields that a connected provider lets you add to its objects (for example, a custom "Industry" field on a CRM contact). The Metadata API provides a seamless way to view and manage these custom fields on unified objects. Use it to list (and, when supported by the connection, create and update) custom field definitions for a connection — including each field's name, slug, format, and the unified object type it applies to. The associated objects then expose those custom fields in a `metadata` array. ## Supported objects Custom fields are available on the following unified objects (availability depends on the connection): **CRM** - **Companies** (`crm_company`) - **Contacts** (`crm_contact`) - **Deals** (`crm_deal`) - **Leads** (`crm_lead`) **ATS** - **Activities** (`ats_activity`) - **Applications** (`ats_application`) - **Candidates** (`ats_candidate`) - **Companies** (`ats_company`) - **Jobs** (`ats_job`) **HRIS** - **Employees** (`hris_employee`) **Accounting** - **Bills** (`accounting_bill`) - **Credit memos** (`accounting_creditmemo`) - **Expenses** (`accounting_expense`) - **Invoices** (`accounting_invoice`) - **Purchase orders** (`accounting_purchaseorder`) - **Sales orders** (`accounting_salesorder`) - **Vendor credits** (`accounting_vendorcredit`) **Commerce** - **Collections** (`commerce_collection`) - **Items** (`commerce_item`), including item media - **Item variants** (`commerce_itemvariant`) - **Reviews** (`commerce_review`), including review comments **KMS** - **Pages** (`kms_page`) **Task** - **Projects** (`task_project`) - **Tasks** (`task_task`) **CDP** - **Events** (`cdp_event`) - **Profiles** (`cdp_profile`) **Analytics** - **Events** (`analytics_event`) - **Visitors** (`analytics_visitor`) **Other** - **Calls** (`uc_call`) - **Signing documents** (`signing_document`) ## Custom field values On those objects, `metadata` is an array of custom field values. For example, a CRM contact: ``` { "id": "contact1", "name": "Jane Doe", "metadata": [ { "slug": "industry", "value": "Software", "format": "TEXT" } ] } ``` On many connections, `metadata` is a [slow field](/reference/slow-fields). If your workspace excludes slow fields by default, include `metadata` in the `fields` query parameter to receive custom field values: ``` GET /crm/{connection_id}/contact?fields=id,name,metadata ``` See also [Working with Custom & Original Fields](/reference/fields). ## Custom field definitions The Metadata `object_type` field identifies the unified object the custom field belongs to (for example, `crm_contact` or `commerce_item`). When listing definitions, filter with the `type` query parameter to match that object type. Optional fields on a definition include: - `format` — the unified data type for the field (see formats below) - `original_format` — the provider's native type string, when available - `options` — allowed values for select-style fields, when available - `is_required` — when `true`, the field is required on create for that connection - `objects` — when populated by the connection, a map of unified object types to arrays of record IDs associated with the definition. This field is optional and is not updated automatically for every integration when you write metadata values onto records. Example `objects` shape: ``` { "objects": { "crm_contact": ["contact1", "contact2"], "ats_candidate": ["candidate1"], "commerce_item": ["item1", "item2"] } } ``` ### Formats Supported `format` values: - `TEXT` - `NUMBER` - `DATE` - `BOOLEAN` - `FILE` - `TEXTAREA` - `SINGLE_SELECT` - `MULTIPLE_SELECT` - `MEASUREMENT` - `PRICE` - `YES_NO` - `CURRENCY` - `URL` ## Operations List and get are available for metadata definitions on connections that support the Metadata API. Create, update, and delete for definitions are supported only when the underlying connection exposes those operations. Writing custom field **values** is done by creating or updating the parent unified object and including a `metadata` array (with `id` and/or `slug` matching a definition, plus `value`). Canonical paths: ``` GET /metadata/{connection_id}/metadata GET /metadata/{connection_id}/metadata/{id} POST /metadata/{connection_id}/metadata PUT /metadata/{connection_id}/metadata/{id} PATCH /metadata/{connection_id}/metadata/{id} DELETE /metadata/{connection_id}/metadata/{id} ``` For a walkthrough of definitions and values, see [Enriching Unified Objects with Custom Metadata](/guides/enriching_unified_objects_with_custom_metadata). [Run In Postman](https://god.gw.postman.com/run-collection/16228585-c29da10b-4d4b-42e9-8c66-182bdff740ec?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-c29da10b-4d4b-42e9-8c66-182bdff740ec%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## passthrough URL: https://docs.unified.to/passthrough/overview The Passthrough API allows you to access SaaS endpoints through your existing connections, even if those endpoints aren't explicitly supported in our unified models. Think of it as a way to "pass through" your API requests directly to the third-party platform while still leveraging Unified.to's connection management and authentication handling. ::callout For examples of the Passthrough API in action, see: [How to use the Passthrough API](https://docs.unified.to/guides/how_to_use_the_passthrough_api) :: ## When to use Passthrough Most implementations don't need Passthrough. It's for edge cases where the endpoint you need isn't part of the unified API. Before building on Passthrough, [contact us](https://unified.to/contact): most gaps can be closed by adding the capability to the unified API. If you only need vendor fields that aren't in the unified data model, use raw fields or the Metadata API instead. See [Choosing how to access vendor-specific data](/reference/fields#choosing-how-to-access-vendor-specific-data). If you need a record's original vendor ID for a Passthrough call, it's available in `raw.__id`. ## How it works When you make a Passthrough API request: 1. You send your request to Unified.to's Passthrough endpoint, specifying: - The connection ID for the integration you want to access - The platform-specific API path you want to call - Any HTTP method, headers, query parameters, or body data needed 2. Unified.to handles: - Authentication with the platform using your stored connection credentials - Routing your request to the correct platform API endpoint 3. The SaaS application processes your request and sends back a response 4. Unified.to forwards the response back to you without modification ### Path construction The path you provide should be relative to the platform's base API URL. For example: - If the full endpoint URL is `https://api.vendor.com/v2/users` - You would specify `v2/users` as the path You can find the base URL for each integration under the **Feature Support > Passthrough** tab on [app.unified.to/integrations](https://app.unified.to/integrations) (click through to the integration to see it). ### Headers, parameters, and payloads - You can send any platform-specific headers your request needs, such as `Content-Type` - For write operations, you can include a payload in the request - Query parameters can be included in the path or as separate parameters - All headers from the response are passed back to you - There is only one reserved URL parameter, and that is `__domain`. Use it to override the default API URL for that integration. ### Response handling Unlike Unified.to's standard endpoints which return normalized data, Passthrough API responses contain raw data. This means: - Response formats will vary between platforms - You'll need to handle platform-specific data structures - Error formats will be platform-specific [Run In Postman](https://god.gw.postman.com/run-collection/16228585-6ad0f5a9-728f-4872-ab03-28c25cf7f1b0?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-6ad0f5a9-728f-4872-ab03-28c25cf7f1b0%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## payment URL: https://docs.unified.to/payment/overview The Unified Payment API enables developers and product managers to access payment data from multiple payment platforms through a single, standardized interface. Retrieve payment links, transactions, payouts, refunds, and subscriptions from Stripe, PayPal, Square, and other major payment platforms - all with one API. ## What is the Unified Payment API? Payment platforms like Stripe, PayPal, and Square each have unique APIs with different authentication methods and data models. The Unified Payment API normalizes these differences, allowing you to build once and support all major payment platforms without maintaining separate integrations. ## Payment Data Objects The Payment API provides access to the following standardized objects: - **Links**: Payment links and checkout URLs - **Payments**: Payment transactions and charges - **Payouts**: Transfers, payouts, and disbursements - **Refunds**: Refunds, chargebacks, and reversals - **Subscriptions**: Recurring payments and subscription billing ## Common Use Cases ### Payment Analytics & Reporting Build financial dashboards that aggregate payment data from multiple platforms. Track revenue, transaction volume, refund rates, and subscription metrics across Stripe, PayPal, or any supported platform. ### Multi-Platform Payment Processing Enable customers to connect their preferred payment processor. Build e-commerce, SaaS, or marketplace applications that work with any major payment platform without building separate integrations. ### Subscription Management Create subscription management tools that work across different payment platforms. Track recurring revenue, manage subscriptions, analyze churn, and monitor subscriber growth. ### Reconciliation & Accounting Automate payment reconciliation by accessing transaction data from payment platforms. Match payments with invoices, track payouts, and sync payment data with accounting systems. ### Payment Insights & Fraud Detection Build tools that analyze payment patterns, detect fraud, and provide insights by accessing payment and refund data across multiple payment platforms. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-b36e2b16-73d2-4f81-b966-db0553054911?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-b36e2b16-73d2-4f81-b966-db0553054911%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## performance URL: https://docs.unified.to/performance/overview The Unified Performance Management API enables developers and product managers to access performance data from multiple performance management platforms through a single, standardized interface. Retrieve review cycles, performance reviews, goals, and feedback from platforms like Lattice, 15Five, HiBob, BambooHR, Engagedly, and Achievers - all with one API. ## What is the Unified Performance Management API? Performance management platforms each have unique APIs with different authentication methods, data models, and endpoints. The Unified Performance Management API normalizes these differences, allowing you to build once and support review cycles, performance reviews, goals and OKRs, and feedback across providers without maintaining separate integrations for each platform. Performance integrations typically represent review periods and goal timeframes as **cycles**, written evaluations as **reviews**, objectives and key results as **goals**, and praise and recognition as **feedback**. Reviews, goals, and feedback reference employees by their HR employee IDs, so performance data lines up with the [Unified HRIS API](/hris/overview). ## Key Benefits for Developers - **Single Integration**: Write code once to support multiple performance management platforms — no need to learn different performance APIs - **Normalized Data Models**: Work with consistent cycle, review, goal, and feedback structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date performance data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple performance platforms - **Faster Development**: Ship performance management features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred performance management tool without custom development - **Competitive Advantage**: Launch with support for major performance platforms while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to bring reviews, goals, and feedback from whichever tool their HR team already uses - **Reduced Time-to-Market**: Get performance features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new performance platforms as customers request them without significant engineering resources ## Performance Data Objects The Performance Management API provides access to the following standardized objects: - **Cycles**: Review cycles and goal timeframes with name, description, type (review or goal), status (draft, scheduled, active, closed), and start, end, and due dates. Reviews and goals point to their cycle via `cycle_id`. - **Reviews**: Self, manager, peer, and upward reviews with the reviewee (`user_id`), reviewer (`reviewer_user_id`), status, overall rating with the provider's rating scale and label, summary comment, individual questions and answers, acknowledgement status, and submission and completion dates. - **Goals**: Individual, team, department, and company goals with owners (`user_ids`), status, progress percentage, weight, start and due dates, and milestones or key results with target and current values. Use `parent_id` to represent goal alignment and OKR trees. Goals can be created, updated, and removed. - **Feedback**: Praise, constructive feedback, and responses to feedback requests with the recipient (`user_id`), author (`author_user_id`), message, attached badges or company values (`tags`), and whether the feedback is publicly visible. Feedback can be created as well as listed. ## Common Use Cases ### Performance Dashboards & People Analytics Build dashboards that combine review ratings, goal progress, and feedback activity across connected platforms. Give HR and leadership teams a single view of performance across the organization. ### Goal & OKR Management Create tools that read and write goals in the customer's performance platform. Align individual goals to team and company objectives, track milestone progress, and keep goals in sync with your own product. ### Recognition & Feedback Workflows Post praise and feedback into the customer's performance platform from the tools employees already use, such as chat apps, project trackers, or customer support software, and surface recognition activity back in your application. ### Compensation & Talent Planning Pull review ratings and cycle results into compensation, promotion, and succession planning tools. Use normalized ratings with their maximum scale to compare outcomes across providers. ### AI-Powered Performance Assistants Build AI agents that summarize reviews, draft self-assessments, suggest goals, and identify feedback trends across employees and teams using a single, consistent API interface. ### Learning & Development Connect review outcomes and goal progress to learning platforms to recommend training based on performance data and track development over time. ## Why Use a Unified Performance Management API? **Traditional Approach:** - Build separate integrations for each performance management platform - Learn multiple authentication systems and API specifications - Maintain code for different review formats, rating scales, and goal structures - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific rate limits, pagination, and data models **With Unified Performance Management API:** - Integrate once with a single API that works across performance platforms - Use one authentication flow for all platforms - Work with normalized cycle, review, goal, and feedback objects - Automatic handling of API changes, rate limits, and platform-specific differences - Link performance data to employees through the Unified HRIS API ## Integration Scenarios ### For HR Tech & SaaS Applications Add "Connect Your Performance Platform" features that work with supported performance tools. Build engagement, compensation, or people analytics products without building separate integrations for each provider. ### For AI & Automation Tools Create AI-powered assistants that read reviews and goals, analyze feedback patterns, and generate recommendations across platforms using a single, consistent API interface. ### For Analytics & BI Platforms Pull performance data from connected platforms into your data warehouse or analytics tool. Create unified reports on review outcomes, goal attainment, and recognition activity without building multiple data pipelines. ### For Workplace & Collaboration Tools Let employees give praise, check goal progress, and see feedback without leaving your product, while the customer's performance platform stays the system of record. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified Performance Management API provides real-time access to performance data. Every API request hits the source platform directly, ensuring you always have the most current cycles, reviews, goals, and feedback. This real-time architecture is ideal for: - Dashboards displaying live goal progress and review status - Workflows that create goals or feedback and need to read them back immediately - Compensation tools that need final review ratings as soon as a cycle closes - AI applications that need fresh data for analysis and recommendations ## Privacy & Security Unified.to never stores your customers' performance data. All requests are stateless and pass through to the performance platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. ## repo URL: https://docs.unified.to/repo/overview The Unified Repository API enables developers and product managers to access version control data from multiple repository platforms through a single, standardized interface. Retrieve repositories, commits, branches, pull requests, and organization data from GitHub, GitLab, Bitbucket, and other major version control platforms - all with one API. ## What is the Unified Repository API? Version control platforms like GitHub, GitLab, and Bitbucket each have unique APIs with different authentication methods and data models. The Unified Repository API normalizes these differences, allowing you to build once and support all major repository platforms without maintaining separate integrations. ## Repository Data Objects The Repository API provides access to the following standardized objects: - **Branches**: Git branches and branch information - **Commits**: Git commits, commit history, and changes - **Organizations**: GitHub organizations, GitLab groups, and workspace data - **Pull Requests**: Pull requests, merge requests, and code reviews - **Repositories**: Code repositories and repository metadata ## Common Use Cases ### Development Analytics Build developer productivity dashboards that aggregate data from GitHub, GitLab, or Bitbucket. Track commit activity, pull request velocity, code review metrics, and team contribution patterns. ### Code Review Tools Create code review and collaboration tools that work across different repository platforms. Access pull request data, review comments, and approval status from any version control system. ### CI/CD Integration Integrate continuous integration and deployment tools with multiple repository platforms. Trigger builds on commits, track deployment status, and manage release workflows across GitHub, GitLab, or Bitbucket. ### Repository Management Build repository management tools that work across platforms. Automate repository creation, manage access permissions, and synchronize repository settings across different version control systems. ### Developer Productivity Tools Create tools that analyze code commits, identify bottlenecks, track project progress, and provide insights by accessing commit and pull request data across multiple repository platforms. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-6d8ac32b-39b9-48b4-9b74-03ed21e37a42?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-6d8ac32b-39b9-48b4-9b74-03ed21e37a42%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## scim URL: https://docs.unified.to/scim/overview Use the SCIM API to manage user provisioning, deprovisioning, and attribute updates in an employee directory. Our SCIM API is based on the [RFC 7644](https://datatracker.ietf.org/doc/html/rfc7644) standard with extensions in the User schema: - urn:ietf:params:scim:schemas:extension:enterprise:2.0:User - urn:ietf:params:scim:schemas:extension:lattice:attributes:1.0:User - urn:ietf:params:scim:schemas:extension:peakon:2.0:User ## Supported Integrations All [HR integrations](/hris/integrations) support our SCIM API, regardless if that end API supports the SCIM specification. To add a SCIM integration, search for HR integrations and activate them. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-8d5e8501-1a0a-4760-915f-cfc345beff58?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-8d5e8501-1a0a-4760-915f-cfc345beff58%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## shipping URL: https://docs.unified.to/shipping/overview The Unified Shipping API enables developers and product managers to access shipping data and functionality from multiple shipping platforms through a single, standardized interface. Retrieve shipments, tracking information, shipping rates, labels, and carrier data from ShipStation, Shippo, EasyPost, FedEx, UPS, and other major shipping platforms - all with one API. ## What is the Unified Shipping API? Shipping platforms like ShipStation, Shippo, EasyPost, FedEx, and UPS each have unique APIs with different authentication methods, data models, and endpoints. The Unified Shipping API normalizes these differences, allowing you to build once and support all major shipping platforms without maintaining separate integrations for each provider. Shipping platforms enable businesses to create shipping labels, track packages, compare carrier rates, and manage fulfillment operations across multiple carriers and services. ## Key Benefits for Developers - **Single Integration**: Write code once to support ShipStation, Shippo, EasyPost, FedEx, UPS, and more - no need to learn multiple shipping APIs - **Normalized Data Models**: Work with consistent shipment, tracking, label, and rate structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date shipping and tracking data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple shipping platforms - **Faster Development**: Ship shipping integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred shipping platform without custom development - **Competitive Advantage**: Launch with support for all major shipping platforms while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to access their shipping data from ShipStation, Shippo, or any supported platform through your product - **Reduced Time-to-Market**: Get shipping features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new shipping platforms as they emerge without significant engineering resources ## Shipping Data Objects The Shipping API provides access to the following standardized objects: - **Addresses**: Shipping addresses, validation, and address information - **Carriers**: Shipping carriers, services, and carrier account information - **Labels**: Shipping labels, label generation, and label data - **Packages**: Package information, dimensions, weight, and package details - **Rates**: Shipping rates, rate quotes, and carrier pricing - **Shipments**: Shipment records, shipping orders, and fulfillment data - **Tracking**: Package tracking information, delivery status, and tracking events ## Common Use Cases ### Multi-Carrier Shipping Management Build shipping management tools that work with any major shipping platform. Enable customers to create labels, compare rates, and track shipments across FedEx, UPS, USPS, DHL, and other carriers from a single interface. ### E-commerce Fulfillment Integration Integrate shipping functionality into e-commerce platforms. Automatically generate shipping labels, track orders, and update customers with delivery status by connecting with customers' preferred shipping platforms. ### Shipping Analytics & Reporting Create dashboards that aggregate shipping data from multiple platforms. Track shipping costs, delivery times, carrier performance, and fulfillment metrics across ShipStation, Shippo, or any supported platform. ### Rate Comparison Tools Build tools that compare shipping rates across multiple carriers and platforms. Help customers find the best shipping options by accessing rate data from all connected shipping platforms. ### Order Fulfillment Automation Automate order fulfillment workflows by accessing shipment and label data from shipping platforms. Automatically create labels, update order status, and send tracking information to customers. ### Inventory & Warehouse Management Connect warehouse management systems with shipping platforms. Sync inventory data with shipping operations, track package locations, and manage fulfillment across multiple warehouses and carriers. ### Returns Management Build returns processing tools that integrate with shipping platforms. Automatically generate return labels, track return shipments, and update return status across multiple carriers. ### Shipping Cost Optimization Create tools that analyze shipping patterns and optimize costs by accessing rate and shipment data across multiple platforms. Identify opportunities to reduce shipping expenses and improve carrier selection. ## Supported Shipping Platforms The Unified Shipping API supports integration with major shipping platforms including ShipStation, Shippo, EasyPost, FedEx, UPS, USPS, DHL, Canada Post, and many others. Each integration provides access to the standardized data objects listed above. ## Why Use a Unified Shipping API? **Traditional Approach:** - Build separate integrations for ShipStation, Shippo, EasyPost, FedEx, UPS, etc. - Learn 5+ different authentication systems and API specifications - Maintain code for 5+ different API endpoints, data formats, and error handling patterns - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific shipment data structures, rate formats, and label generation methods **With Unified Shipping API:** - Integrate once with a single API that works across all shipping platforms - Use one authentication flow for all platforms - Work with normalized shipment, tracking, label, and rate objects - Automatic handling of API changes, rate limits, and platform-specific differences - Built-in webhook support for real-time shipping updates and tracking events ## Integration Scenarios ### For E-commerce Platforms Add "Connect Your Shipping Account" features that work with any major shipping platform. Enable merchants to generate labels, track orders, and manage fulfillment without building separate integrations for each shipping provider. ### For Order Management Systems Integrate shipping functionality into order management and fulfillment systems. Access shipment data, generate labels, and track deliveries across multiple shipping platforms from a single interface. ### For Shipping Analytics Platforms Pull shipping data from all major platforms into your analytics tool. Create unified shipping reports, carrier performance dashboards, and cost analysis without building multiple data pipelines. ### For Warehouse Management Systems Connect WMS platforms with shipping providers. Automatically create shipments, generate labels, and update inventory based on shipping data from multiple platforms. ### For Returns Management Tools Build returns processing solutions that integrate with customers' shipping platforms. Automatically generate return labels, track return shipments, and update return status across all major carriers. ### For Multi-Channel Sellers Enable sellers managing inventory across multiple channels to access shipping functionality from any platform. Support ShipStation, Shippo, or any shipping provider without custom development. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified Shipping API provides real-time access to shipping data. Every API request hits the source platform directly, ensuring you always have the most current shipment status, tracking information, and rate data. This real-time architecture is ideal for: - Shipping dashboards displaying live shipment and tracking data - Real-time order fulfillment and label generation - Up-to-the-minute delivery status and tracking updates - Automated shipping workflows that react to platform changes instantly ## Privacy & Security Unified.to never stores your customers' shipping data. All requests are stateless and pass through to the shipping platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. ## signing URL: https://docs.unified.to/signing/overview The Unified E-Signature API enables developers and product managers to access electronic signature data from multiple e-signature platforms through a single, standardized interface. Create and send documents for signature, manage signatories, use templates, and track signing status across DocuSign, Adobe Sign, Dropbox Sign, PandaDoc, and other major e-signature platforms - all with one API. ## What is the Unified E-Signature API? Electronic signature platforms like DocuSign, Adobe Sign, Dropbox Sign, and PandaDoc each have unique APIs with different authentication methods, data models, and endpoints. The Unified E-Signature API normalizes these differences, allowing you to build once and support all major e-signature platforms without maintaining separate integrations for each provider. E-signature platforms are used by sales, legal, HR, and operations teams to prepare, send, and collect legally binding electronic signatures on contracts, agreements, NDAs, offer letters, and other documents. ## Key Benefits for Developers - **Single Integration**: Write code once to support DocuSign, Adobe Sign, Dropbox Sign, PandaDoc, and more - no need to learn multiple e-signature APIs - **Normalized Data Models**: Work with consistent document, signatory, and template structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date signing status and document data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple e-signature platforms - **Faster Development**: Ship e-signature integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred e-signature platform without custom development - **Competitive Advantage**: Launch with support for all major e-signature platforms while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to send and track signature requests through DocuSign, Adobe Sign, or any supported platform from within your product - **Reduced Time-to-Market**: Get e-signature features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new e-signature platforms as they emerge without significant engineering resources ## E-Signature Data Objects The E-Signature API provides access to the following standardized objects: - **Documents**: Signing documents, agreements, and contracts with status, sent and completed timestamps, expiration, and download URLs - **Signatories**: Signers and recipients on a document, including name, email, role, signing order, and signature status - **Templates**: Reusable document templates for creating new signing documents with predefined fields and signatories ## Common Use Cases ### Contract & Agreement Workflows Build contract management tools that send, track, and store signed agreements across multiple e-signature platforms. Automatically generate contracts, route them to signatories, and update records when documents are fully executed. ### Sales Proposal & Quote Signing Integrate e-signature capabilities into CPQ and proposal tools. Send quotes and proposals for signature through the customer's preferred e-signature platform and track when deals are closed. ### HR & Onboarding Automation Automate offer letters, employment agreements, NDAs, and onboarding paperwork. Send new-hire documents for signature through any supported e-signature platform and update HRIS records when documents are completed. ### Legal Document Management Build legal operations and contract lifecycle management (CLM) tools that integrate with any major e-signature platform. Track document status, manage signing order, and store executed documents alongside matter records. ### Real Estate & Lending Send disclosures, loan documents, lease agreements, and closing paperwork for signature. Track multi-party signing workflows across buyers, sellers, agents, and lenders using a single API. ### AI-Powered Document Automation Build AI agents that generate documents from templates, route them to the correct signatories, monitor signing status, and take follow-up actions when documents are signed, declined, or expired - across any supported e-signature platform. ### Compliance & Audit Trails Pull signing events, completed documents, and signatory data into compliance and audit systems. Create unified audit trails for executed agreements regardless of which e-signature platform was used. ## Supported E-Signature Platforms The Unified E-Signature API supports integration with major electronic signature platforms including DocuSign, Adobe Sign, Dropbox Sign, and PandaDoc. Each integration provides access to the standardized data objects listed above. ## Why Use a Unified E-Signature API? **Traditional Approach:** - Build separate integrations for DocuSign, Adobe Sign, Dropbox Sign, PandaDoc, etc. - Learn different authentication systems and API specifications for each provider - Maintain code for multiple API endpoints, envelope/document models, and webhook event formats - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific concepts like envelopes, agreements, signers, recipients, and tags **With Unified E-Signature API:** - Integrate once with a single API that works across all e-signature platforms - Use one authentication flow for all platforms - Work with normalized document, signatory, and template objects - Automatic handling of API changes, rate limits, and platform-specific differences - Built-in webhook support for real-time signing status updates ## Integration Scenarios ### For SaaS Applications Enable your end-users to add an integration to their e-signature account from your Integrations Page in your software application. Support any major e-signature platform without building separate integrations for each one. ### For Contract Lifecycle Management (CLM) Embed e-signature functionality into CLM platforms. Let customers send contracts for signature through their existing DocuSign, Adobe Sign, or other e-signature account while keeping all contract data in your platform. ### For CRM & Sales Tools Connect sales platforms to e-signature platforms so reps can send proposals, quotes, and contracts for signature directly from deal records. Automatically update deal status when documents are signed. ### For HR & HRIS Platforms Send offer letters, employment contracts, and policy acknowledgments from HRIS and onboarding tools. Track which employees have completed which documents across any supported e-signature platform. ### For AI & Automation Tools Create AI-powered document agents that draft, send, monitor, and follow up on signature requests across multiple e-signature platforms using a single API. ### For Document Generation & Workflow Tools Build document automation platforms that generate documents from templates and send them for signature. Support any major e-signature platform your customers already use. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified E-Signature API provides real-time access to e-signature data. Every API request hits the source platform directly, ensuring you always have the most current document status, signatory activity, and completion timestamps. This real-time architecture is ideal for: - Dashboards displaying live signing progress and pending signatures - Real-time notifications when documents are signed, declined, or expired - Up-to-the-minute document status and signatory tracking - Automated workflows that react to signing events instantly ## Privacy & Security Unified.to never stores your customers' documents or signature data. All requests are stateless and pass through to the e-signature platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. ## social URL: https://docs.unified.to/social/overview The Unified Social Media API enables developers and product managers to access social media data from multiple platforms through a single, standardized interface. Retrieve profiles, posts, reviews, and performance insights from social networks, business listings, and review sites — all with one API. ## What is the Unified Social Media API? Social media platforms, business profile services, and review sites each have unique APIs with different authentication methods, data models, and endpoints. The Unified Social Media API normalizes these differences, allowing you to build once and support profiles, content publishing, review management, and social analytics across providers without maintaining separate integrations for each platform. Social integrations typically represent pages, handles, or business listings as **profiles**, published content as **posts**, customer feedback as **reviews**, and audience and engagement data as **insights**. ## Key Benefits for Developers - **Single Integration**: Write code once to support multiple social platforms — no need to learn different social media APIs - **Normalized Data Models**: Work with consistent profile, post, review, and insight structures across all platforms - **Real-Time Data Access**: Every request hits the source API live with no caching, ensuring you always have up-to-date social data - **Zero Maintenance**: No need to track API version changes or deprecations across multiple social platforms - **Faster Development**: Ship social media integration features in days instead of months of integration work ## Benefits for Product Managers - **Multi-Platform Support**: Offer customers the ability to connect their preferred social accounts without custom development - **Competitive Advantage**: Launch with support for major social platforms while competitors build integrations one at a time - **Customer Flexibility**: Allow customers to manage profiles, posts, reviews, and analytics from a single interface - **Reduced Time-to-Market**: Get social media features to market quickly without waiting for engineering to build multiple integrations - **Scalable Product Strategy**: Easily add new social platforms as they emerge without significant engineering resources ## Social Data Objects The Social Media API provides access to the following standardized objects: - **Profiles**: Social pages, handles, and business listings with display name, username, bio, avatar, contact details, structured address, verification status, and audience metrics (followers, following, post count). Profiles can be nested using `parent_id` (for example, an account and its locations). - **Posts**: Published and scheduled content including text, media attachments, publication status, post type (post, reply, repost, quote, story, and more), call-to-action buttons, permalinks, and engagement metrics (likes, comments, shares, views, bookmarks). Use `parent_post_id` to represent replies and threaded content. - **Reviews**: Customer ratings and review text with reviewer name, avatar, location, star rating, optional title, and business replies. Reviews are linked to the profile being reviewed via `profile_id`. - **Insights**: Performance and audience metrics over time — including followers, impressions, reach, engagement, profile views, clicks, video views, likes, comments, shares, and saves — aggregated by day, week, month, or lifetime and scoped to a profile or individual post. ## Common Use Cases ### Multi-Platform Social Dashboards Build unified dashboards that display profiles, recent posts, review summaries, and engagement metrics across connected social accounts. Give marketing and operations teams a single view of social presence and performance. ### Social Content Publishing & Scheduling Create tools that draft, schedule, and publish posts across social platforms. Manage post status, attach media, set call-to-action buttons, and track engagement metrics through a normalized post object. ### Review Monitoring & Response Monitor customer reviews across business listing and review platforms. List reviews, track ratings, and publish business replies from a single interface — without building separate integrations for each review source. ### Social Analytics & Reporting Pull insight data into analytics dashboards and data warehouses. Track follower growth, reach, impressions, and engagement over time across profiles and posts using a consistent metrics schema. ### AI-Powered Social Assistants Build AI agents that analyze posts, reviews, and performance data to generate content recommendations, draft replies, identify sentiment trends, and surface engagement opportunities across multiple platforms. ### Brand & Reputation Management Aggregate reviews and social activity to monitor brand reputation. Track rating trends, respond to feedback, and report on social performance across locations, pages, and accounts. ## Why Use a Unified Social Media API? **Traditional Approach:** - Build separate integrations for each social network, business profile service, and review platform - Learn multiple authentication systems and API specifications - Maintain code for different post formats, media handling, and metric definitions - Monitor and update integrations when any platform deprecates or changes their API - Handle platform-specific rate limits, pagination, and webhook implementations **With Unified Social Media API:** - Integrate once with a single API that works across social platforms - Use one authentication flow for all platforms - Work with normalized profile, post, review, and insight objects - Automatic handling of API changes, rate limits, and platform-specific differences - Built-in webhook support for real-time updates across connected platforms ## Integration Scenarios ### For SaaS Applications Add "Connect Your Social Accounts" features that work with supported social platforms. Build social media management, review response, or analytics tools without building separate integrations for each provider. ### For AI & Automation Tools Create AI-powered assistants that read post and review data, analyze engagement patterns, generate content, and provide recommendations across platforms using a single, consistent API interface. ### For Analytics & BI Platforms Pull social insight data from connected platforms into your data warehouse or analytics tool. Create unified engagement reports, review dashboards, and audience growth analyses without building multiple data pipelines. ### For Marketing Agencies Build proprietary dashboards and reporting tools for tracking client social performance and review sentiment. Support connected platforms without custom development for each one. ## Real-Time, Live Data Unlike other integration platforms that cache data or run periodic sync jobs, the Unified Social Media API provides real-time access to social data. Every API request hits the source platform directly, ensuring you always have the most current profiles, posts, reviews, and metrics. This real-time architecture is ideal for: - Social dashboards displaying live engagement and review activity - Publishing workflows that need current post status and scheduling data - Monitoring tools that track reviews and social mentions as they happen - AI applications that need fresh data for analysis and recommendations ## Privacy & Security Unified.to never stores your customers' social media data. All requests are stateless and pass through to the social platforms directly. Traffic is regionalized (US/EU/AU) to comply with data residency requirements. ## storage URL: https://docs.unified.to/storage/overview The Unified Storage API enables developers and product managers to access cloud storage data from multiple platforms through a single, standardized interface. Retrieve and manage files from Google Drive, Dropbox, OneDrive, Box, and other major cloud storage platforms - all with one API. ## What is the Unified Storage API? Cloud storage platforms like Google Drive, Dropbox, and OneDrive each have unique APIs with different authentication methods and data models. The Unified Storage API normalizes these differences, allowing you to build once and support all major cloud storage platforms without maintaining separate integrations. ## Storage Data Objects The Storage API provides access to the following standardized objects: - **Files**: Documents, folders, images, and file metadata ## Common Use Cases ### Contract Management Systems Build contract lifecycle management tools that access contracts stored across multiple cloud storage platforms. Track contract versions, expiration dates, and approval workflows by accessing files from Google Drive, Dropbox, SharePoint, or any supported storage platform. Automatically extract contract metadata, monitor renewal dates, and ensure compliance regardless of where contracts are stored. ### Enterprise Search Create enterprise search solutions that index and search documents across all cloud storage platforms. Build unified search interfaces that find files, contracts, presentations, and documents stored in Google Drive, Dropbox, OneDrive, Box, or other platforms from a single search query. Enable employees to discover content across their organization's entire cloud storage ecosystem. ### Document Management Systems Build document management tools that work with any cloud storage platform. Access, organize, and manage files from Google Drive, Dropbox, or OneDrive through a single interface. ### Backup & Sync Tools Create backup and synchronization tools that work across multiple cloud storage providers. Sync files between different platforms or create multi-cloud backup solutions. ### Collaboration Tools Build collaboration software that integrates with customers' existing cloud storage. Access shared files, track changes, and manage document permissions across multiple storage platforms. ### Content Aggregation Aggregate content from multiple cloud storage accounts. Build search tools that find files across Google Drive, Dropbox, and other platforms, or create unified file browsers. ### AI Document Processing Build AI tools that process documents from any cloud storage platform. Extract text, analyze images, or generate insights from files stored in Google Drive, Dropbox, or other platforms. ## Working with Hierarchical Data in File Storage APIs File Storage integrations typically organize data in a hierarchical (tree-like) structure. For detailed guidance on traversing hierarchical data, refer to our guide: [How to traverse hierarchical data](https://docs.unified.to/guides/working_with_hierarchical_tree_data_in_storage_messaging_and_kms_apis) ## The StorageFile Object Both files and folders are represented by the StorageFile object. They are distinguished by the `type` field: - `type: "FILE"` - Represents a file - `type: "FOLDER"` - Represents a folder This means you'll use the same API endpoints to work with both files and folders, just with different type values. ## Keeping Folder Data in Sync The Storage API supports two ways to detect folder changes: - **Polling**: List endpoints support recursive traversal, so a single poll request can walk an entire folder tree rather than requiring one request per folder. Polling detects `created`, `updated`, and `deleted` events for folders. - **Webhooks (`receive`)**: For platforms that support it, Unified.to can push `created`, `updated`, and `deleted` folder events to your webhook endpoint in real time instead of requiring you to poll. Use polling when you need a full or recursive snapshot of a folder tree, and webhooks when you want to react to folder changes as they happen without repeated polling. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-2349a087-c8bd-43e7-a6ce-80e7319e3478?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-2349a087-c8bd-43e7-a6ce-80e7319e3478%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## task URL: https://docs.unified.to/task/overview The Unified Task Management API enables developers and product managers to access project and task data from multiple project management platforms through a single, standardized interface. Retrieve tasks, projects, comments, and changes from Asana, Trello, Monday.com, ClickUp, and other major task management platforms - all with one API. ## What is the Unified Task Management API? Project management platforms like Asana, Trello, and Monday.com each have unique APIs with different authentication methods and data models. The Unified Task Management API normalizes these differences, allowing you to build once and support all major task management platforms without maintaining separate integrations. ## Task Management Data Objects The Task Management API provides access to the following standardized objects: - **Changes**: Task changes, updates, and audit logs - **Comments**: Task comments, discussions, and notes - **Projects**: Projects, boards, workspaces, and portfolios - **Tasks**: Tasks, issues, to-dos, and work items ## Common Use Cases ### Project Analytics & Reporting Build project dashboards that aggregate task data from multiple platforms. Track project progress, team productivity, task completion rates, and sprint velocity across Asana, Trello, or any supported platform. ### Time Tracking Integration Connect time tracking tools with task management platforms. Automatically log time to tasks, generate timesheets based on task assignments, and track project budgets. ### Cross-Platform Task Sync Create tools that sync tasks between different project management platforms. Mirror tasks from Asana to Trello, or keep personal and work task lists synchronized. ### AI Project Assistants Build AI-powered project management assistants that access task data, analyze project health, identify blockers, and provide recommendations across multiple platforms using a single API. ### Resource Planning Tools Develop resource allocation tools that integrate with task management platforms. Access task assignments, workload data, and project timelines to optimize team capacity and resource allocation. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-f0c9db03-2443-41b2-94ae-a6f849628483?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-f0c9db03-2443-41b2-94ae-a6f849628483%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## ticketing URL: https://docs.unified.to/ticketing/overview The Unified Ticketing API enables developers and product managers to access customer support data from multiple ticketing platforms through a single, standardized interface. Retrieve tickets, customers, notes, and categories from Zendesk, Freshdesk, Intercom, and other major customer support platforms - all with one API. ## What is the Unified Ticketing API? Customer support platforms like Zendesk, Freshdesk, and Intercom each have unique APIs with different authentication methods and data models. The Unified Ticketing API normalizes these differences, allowing you to build once and support all major support platforms without maintaining separate integrations. ## Ticketing Data Objects The Ticketing API provides access to the following standardized objects: - **Categories**: Ticket categories, types, and classifications - **Customers**: Support customers and end-users - **Notes**: Ticket notes, comments, and internal messages - **Tickets**: Support tickets, issues, and customer requests ## Common Use Cases ### Support Analytics & Reporting Build support dashboards that aggregate ticket data from multiple platforms. Track response times, resolution rates, customer satisfaction, and support team performance across Zendesk, Freshdesk, or any supported ticketing system. ### Customer Context Tools Create tools that display complete customer support history from multiple ticketing platforms. Show all customer interactions, tickets, and notes in a unified view for better customer service. ### AI Support Assistants Build AI-powered support agents that access ticket data, analyze customer issues, suggest solutions, and automate responses across multiple support platforms using a single API. ### Ticket Routing & Assignment Automate ticket routing and assignment by accessing ticket and customer data. Build intelligent systems that assign tickets based on category, priority, agent expertise, or workload. ### Multi-Platform Support Tools Create support tools that work across different ticketing platforms. Build chatbots, knowledge bases, or customer portals that integrate with customers' existing ticketing systems. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-2716a8c7-a426-430a-bf02-7789c7ee5189?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-2716a8c7-a426-430a-bf02-7789c7ee5189%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## uc URL: https://docs.unified.to/uc/overview The Unified Communications (UC) API enables developers and product managers to access communication data from multiple unified communications platforms through a single, standardized interface. Retrieve calls, contacts, comments, and recordings from RingCentral, 8x8, Zoom Phone, and other major UC platforms - all with one API. ## What is the Unified Communications API? Unified Communications platforms like RingCentral, 8x8, and Zoom Phone each have unique APIs with different authentication methods and data models. The Unified Communications API normalizes these differences, allowing you to build once and support all major UC platforms without maintaining separate integrations. ## UC Data Objects The UC API provides access to the following standardized objects: - **Calls**: Call logs, call history, and call details - **Comments**: Call notes, comments, and annotations - **Contacts**: Phone contacts and directory information - **Recordings**: Call recordings and voicemail ## Common Use Cases ### Call Analytics & Reporting Build call center dashboards that aggregate data from multiple UC platforms. Track call volume, duration, wait times, and agent performance across RingCentral, 8x8, or any supported platform. ### CRM Integration Connect UC platforms with CRM systems. Automatically log calls to CRM records, display customer information during calls, and track call history with customer accounts. ### Call Recording Analysis Build tools that analyze call recordings using AI. Extract transcripts, analyze sentiment, identify keywords, and generate insights from call recordings across multiple UC platforms. ### Contact Center Tools Create contact center applications that work with any UC platform. Build call routing, queue management, and agent productivity tools that integrate with customers' existing phone systems. ### Call Intelligence Develop call intelligence tools that analyze call patterns, identify trends, and provide recommendations by accessing call data from multiple unified communications platforms. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-fa88e73d-7540-4309-9739-d33ec3b11587?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-fa88e73d-7540-4309-9739-d33ec3b11587%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## unified URL: https://docs.unified.to/unified/overview This is the unified.to management API that allows you to manage your connections and other objects. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-756fde70-27e8-430f-b55c-5cc8ab98a8d9?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-756fde70-27e8-430f-b55c-5cc8ab98a8d9%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) ## Data Model Unified data model ## verification URL: https://docs.unified.to/verification/overview The Unified Verification API enables developers and product managers to access identity and background verification data from multiple platforms through a single, standardized interface. Manage verification packages and requests from Checkr, Truework, Onfido, and other major verification platforms - all with one API. ## What is the Unified Verification API? Verification platforms like Checkr, Truework, and Onfido each have unique APIs with different authentication methods and data models. The Unified Verification API normalizes these differences, allowing you to build once and support all major verification platforms without maintaining separate integrations. ## Verification Data Objects The Verification API provides access to the following standardized objects: - **Packages**: Verification packages, screening bundles, and check types - **Requests**: Verification requests, background checks, and screening orders ## Common Use Cases ### Applicant Background Screening Integrate background check capabilities into recruiting and HR applications. Order background checks, employment verification, and identity checks for job applicants across multiple verification providers. ### Onboarding Automation Automate employee or contractor onboarding by integrating verification workflows. Trigger background checks when candidates accept offers and track verification status through the hiring process. ### Compliance Management Build compliance tracking tools that work with multiple verification providers. Monitor verification completion, track expiration dates, and ensure regulatory compliance across different screening services. ### Multi-Provider Verification Enable customers to use their preferred verification provider. Support Checkr, Truework, Onfido, or other platforms without building separate integrations for each service. ### Verification Analytics Track verification metrics across multiple providers. Analyze completion times, pass rates, and verification costs to optimize screening processes and vendor selection. [Run In Postman](https://god.gw.postman.com/run-collection/16228585-6c80ccd9-9565-455d-baf6-942e89cface4?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D16228585-6c80ccd9-9565-455d-baf6-942e89cface4%26entityType%3Dcollection%26workspaceId%3D0a62ef4e-9382-41f4-8a59-44c03de1e0d5) --- # MCP (Model Context Protocol) ## additional api endpoints URL: https://docs.unified.to/mcp/additional-api-endpoints # Unified MCP Server ## Additional API Endpoints [Get Tools](#get-tools) · [Call Tool](#post-toolsidcall) These REST endpoints serve the same tools as the MCP server, for LLM APIs that take tool definitions directly instead of connecting to an MCP server. They live on the same hosts as `/mcp` and are also served under the `/mcp` prefix (eg. `/mcp/tools`). Authenticate the same way as the MCP server: see [Authentication](https://docs.unified.to/mcp/authentication). ### GET /tools Get a list of the MCP tools associated with the connection. Add these URL parameters to the MCP GetTools API URL: | | | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `permissions` | A comma-delimited list of permissions from [Unified.to](https://docs.unified.to/guides/how_to_configure_webhooks_in_hubspot#select-permission-scopes-on-unifiedto) | | `tools` | A comma-delimited list of tool IDs to restrict the list to. Supports the wildcards `*` and `?`, eg. `list_*,*invoice*`. | | `defer_tools` | A comma-delimited list of tool IDs (or partial IDs) to mark with `defer_loading: true`, or `all`. The list then starts with Anthropic's `tool_search_tool_regex` tool, so the model can search for a deferred tool when it needs it. | | `aliases` | A list of words and their aliases to create additional descriptions for tools. In the format of `word1:alias1,alias2;word2:alias1,alias2`. eg. `?aliases=employee:user,person;employees:users,people` | | `include_external_tools` | Include an integration's tools from all of its API and not just the default supported unified API | | `integration_type` | Without a credential, list that integration's tools as an [unauthenticated preview](https://docs.unified.to/mcp/core#without-credentials). Without a credential or `integration_type`, the endpoint lists the Core Unified API tools. | | `type` | Use this parameter to change the structure of the result to be used for:
        · **OpenAI**'s [function calling](https://platform.openai.com/docs/guides/function-calling) when `type=openai`
        · **Google Gemini**'s [function declarations](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/function-calling) when `type=gemini` (or `google`)
        · **Cohere**'s [tools](https://docs.cohere.com/docs/tools) when `type=cohere`
        · **Grok**'s [functions](https://docs.x.ai/docs/guides/function-calling) when `type=grok`
        · **Groq**'s [functions](https://console.groq.com/docs/tool-use) when `type=groq` | | | | Without `type`, the result is an array of: ``` { id: string; description: string; parameters: { name: string; description: string; required: boolean; type?: 'string' | 'number' | 'boolean' | 'object' | 'array'; enum?: string[]; ... }[]; output_schema?: JSON-schema; // the shape of the call's structuredContent } ``` With a `type`, each tool is shaped for that provider: | `type` | Shape | | ----------------------- | ------------------------------------------------------------------ | | `openai` | `{ type: 'function', name, description, parameters }` | | `gemini` | `{ name, description, parameters }`: a function declaration | | `cohere`, `grok`, `groq` | `{ type: 'function', function: { name, description, parameters } }` | `parameters` is a JSON Schema object in every typed shape. Anthropic's Messages API takes the same JSON Schema under a different key, so use `type=openai` and rename `parameters` to `input_schema` (see [Anthropic API](https://docs.unified.to/mcp/installation#anthropic-api)). ### POST /tools/{id}/call Call a tool and return its result. `{id}` is the tool's `id` (or `name` in the typed shapes). The request body is a JSON object of the tool's arguments, keyed by parameter name. Add these URL parameters to the MCP CallTool API URL: | | | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hide_sensitive` | Hides sensitive (ie. PII) data from results. These fields include name, emails, telephones, ... | | `permissions` | A comma-delimited list of permissions from [Unified.to](https://docs.unified.to/guides/how_to_configure_webhooks_in_hubspot#select-permission-scopes-on-unifiedto) | | `tools` | The same tool restriction as `GET /tools`. A tool outside it returns `404` | | `aliases` | A list of words and their aliases to create additional descriptions for tools. In the format of `word1:alias1,alias2;word2:alias1,alias2`. eg. `?aliases=employee:user,person;employees:users,people` | | | | `include_external_tools` is not needed here: a tool whose `id` starts with `external_` is found automatically. A successful call returns: ``` { content: { type: 'text'; text: string; // the result as JSON text }[]; structuredContent: JSON-object; // a list result is wrapped as { results: [...] } } ``` When the tool runs and fails, the response has `isError: true` and the error in `content`: ``` { content: [{ type: 'text', text: 'Error: ...' }], isError: true } ``` | Status | When | Body | | ------ | ----------------------------------------------------------------------- | --------------------- | | `400` | A required parameter is missing or empty. The message names it | `isError` result | | `401` | No valid credential | `{ error, message }` | | `403` | The request's IP address is not on the workspace's allowlist | `{ error, message }` | | `403` | The OAuth token's scopes don't cover the tool | `isError` result | | `404` | No such tool for this connection | `{ error }` | | `429` | The workspace's rate limit was reached | `isError` result | | other | The status the integration's API returned | `isError` result | `signup_unified_workspace` is the one tool that can be called without a credential. See [Agent Signup](https://docs.unified.to/mcp/agent-signup#signing-up-from-an-mcp-client). #### Rate limiting Your workspace's rate limit is checked before the tool runs, so a tool call can return HTTP `429`. This applies to tool calls made through the MCP server at `/mcp` as well as to this endpoint. ## agent signup URL: https://docs.unified.to/mcp/agent-signup # Agent Signup An AI agent can create its own Unified.to workspace with one API call, without a human signing up first. The agent gets a workspace API key and Sandbox integrations with mock data straight away. A human claims the workspace later to own it and upgrade it. > **For agents:** this page is also available as Markdown at https://docs.unified.to/mcp/agent-signup.md. ## How it works 1. The agent calls `POST /agent/signup`. No body and no credentials are needed. 2. The response contains a workspace API key, the MCP URL and a claim link. Save the key immediately; it is shown only once. 3. The agent connects to the Core MCP server with the key and starts building against Sandbox integrations. 4. When a human should take over, the agent shows them the claim link. The human signs in with a work email and becomes the workspace owner. The workspace is on the Test plan. It runs for 30 days from signup, whether or not anyone claims it. To keep it running after that, a human must claim it and upgrade it. ## 1. Sign up ```bash curl -X POST https://api.unified.to/agent/signup ``` Each region is separate. Sign up on the host for the region where the data should live, and use that region's hosts afterwards: | Region | Signup URL | | ------ | ---------------------------------------- | | US | `https://api.unified.to/agent/signup` | | EU | `https://api-eu.unified.to/agent/signup` | | AU | `https://api-au.unified.to/agent/signup` | A successful call returns `201 Created`: ```json { "api_key": "eyJhbGciOi...", "workspace_id": "66f1c2...", "region": "us", "api_url": "https://api.unified.to", "mcp_url": "https://api.unified.to/mcp", "sandbox_integrations": ["hubspot", "greenhouse", "bamboohr", "quickbooks", "slack"], "claim_url": "https://app.unified.to/claim?token=...", "claim_expires_at": "2026-11-13T18:04:11.000Z", "trial_ends_at": "2026-11-13T18:04:11.000Z", "next_step": "Save api_key now; it is shown once. ..." } ``` | Field | Description | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `api_key` | Workspace API key. Send it as `Authorization: Bearer `. **Shown only once**: store it in a secret or `.env` file. | | `workspace_id` | The new workspace's ID. | | `region` | The region that holds the workspace (`us`, `eu` or `au`). | | `api_url` | Base URL for the REST API in that region. | | `mcp_url` | The Core MCP server URL for that region. | | `sandbox_integrations` | Integrations already active in the Sandbox environment, with mock data. | | `claim_url` | Link a human opens to take ownership of the workspace. | | `claim_expires_at` | When the claim link stops working. This is the same as `trial_ends_at`. | | `trial_ends_at` | When the 30-day Test plan ends. | | `next_step` | A one-line instruction for the agent. | The API key has the same access as any workspace API key: it can manage every connection, webhook and setting in the workspace. Never commit it or put it in a shareable URL. ## 2. Connect to the MCP server Add the Core MCP server with the key in the `Authorization` header: ```json { "mcpServers": { "unified-core": { "type": "http", "url": "https://api.unified.to/mcp", "headers": { "Authorization": "Bearer ${UNIFIED_API_KEY}" } } } } ``` Use the `mcp_url` from the signup response; it already points at the right region. See [Core MCP Server](https://docs.unified.to/mcp/core) for the tools it provides, and [Client configuration](https://docs.unified.to/mcp/core#client-configuration) for other clients. ## Signing up from an MCP client An agent that only speaks MCP can sign up without leaving its MCP client. Connect to the MCP server with no credential and `?signup=true`: ```json { "mcpServers": { "unified-signup": { "type": "http", "url": "https://api.unified.to/mcp?signup=true" } } } ``` The server lists the Core tools plus `signup_unified_workspace`, the one tool that can be called without a credential. Calling it returns the same fields as `POST /agent/signup` above, and has the same per-IP limit. The server's instructions tell the model to call it, then reconnect to the returned `mcp_url` with `Authorization: Bearer `. Until it reconnects, the other tools return an error asking it to authenticate. Over the [REST tool API](https://docs.unified.to/mcp/additional-api-endpoints), `POST /tools/signup_unified_workspace/call` does the same without a credential. `signup_unified_workspace` is never offered once the client presents a credential. ## 3. Build against the Sandbox The Sandbox returns mock data and accepts any credentials (see [Sandbox](https://docs.unified.to/concepts/sandbox)). To create a connection, call the `create_unified_connection` tool, or the REST API: ```bash curl -X POST https://api.unified.to/unified/connection \ -H "Authorization: Bearer $UNIFIED_API_KEY" \ -H "Content-Type: application/json" \ -d '{"integration_type": "hubspot", "environment": "Sandbox", "categories": ["crm"], "permissions": ["crm_contact_read"], "auth": {}}' ``` Then read data with the returned connection ID, for example `GET https://api.unified.to/crm/{connection_id}/contact`. ## 4. Hand the workspace to a human Show `claim_url` to a human when they should own the workspace. Opening the link, they: 1. Sign in to app.unified.to with a work email. Personal email domains such as Gmail are not accepted. 2. Become an admin and the billing owner of the workspace. 3. Can upgrade the plan, activate Production integrations, and copy the workspace secret (used to verify webhook signatures) from **Settings → API Keys**. The agent's API key keeps working after the workspace is claimed. ## Expiry The workspace follows the Test plan's 30-day trial, counted from signup. Claiming it does not restart the 30 days. If the workspace has not been upgraded when the trial ends, its connections and webhooks are paused. A workspace that nobody claimed cannot be upgraded. ## Limits - Signup is rate-limited per IP address. Too many calls return `429 Too Many Requests`. - Each call creates a new workspace with its own key. Before signing up again, check whether you already saved a key. ## authentication URL: https://docs.unified.to/mcp/authentication # Unified MCP Server ## Authentication There are five authentication options: | Option | Credential | Tools | | ----------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------- | | [1. One connection, with your API key](#_1-private-llm-api-authentication-for-1-connection) | Workspace API key + `connection` | That connection's integration tools | | [2. Your workspace](#_2-private-workspace-only-authentication) | Workspace API key | The [Core Unified API](https://docs.unified.to/mcp/core) tools | | [3. Enterprise-managed](#_3-enterprise-managed-authentication) | `ema_` token from the customer's IdP | Integration tools, narrowed to the token's scopes | | [4. Connection-scoped token](#_4-connection-scoped-token) | Signed token for one connection | That connection's integration tools | | [5. OAuth sign-in](#_5-oauth-sign-in-for-mcp-clients) | OAuth access token | The Core Unified API tools, narrowed to the granted scopes | ### 1. Private LLM API Authentication for 1 Connection: Use this option when your application connects directly to the Unified MCP Server or it gives the LLM API the Unified MCP Server remote URL. This token is NOT safe to give out publically as it is a Unified.to workspace API key and will grant access to all of your connections and Unified.to account. | | | | ------------ | ----------------------------------------------------------------------------------------- | | `connection` | An end-customer’s connection ID from Unified | | `token` | Exactly the same as a Unified.to [workspace API key](https://app.unified.to/settings/api) | | | | ### 2. Private Workspace-only Authentication: Use this option to engage with data in your workspace such as connections, webhooks, API calls, and issues/tickets. This data is configuration data and not your end-customer's data from connections. | | | | ------- | ----------------------------------------------------------------------------------------- | | `token` | Exactly the same as a Unified.to [workspace API key](https://app.unified.to/settings/api) | | | | An AI agent without an account can get a workspace API key itself with [Agent Signup](https://docs.unified.to/mcp/agent-signup). ### 3. Enterprise-managed Authentication: Use this option when an enterprise end-customer's own identity provider should decide which of their users reach the MCP server, and with what access — instead of you provisioning and handing out Unified.to credentials. The enterprise's IdP mints an identity assertion (an ID-JAG) for the end user. Your application exchanges that assertion for a Unified.to access token, then uses the token as a bearer credential against the MCP server. Enterprise-managed authentication is enabled per workspace. Contact Unified.to to turn it on. #### Exchanging the assertion `POST /oauth/token` as an RFC 7523 `jwt-bearer` grant: | | | | ------------ | ---------------------------------------------------------------------------- | | `grant_type` | `urn:ietf:params:oauth:grant-type:jwt-bearer` | | `assertion` | The ID-JAG minted by the enterprise IdP | | `scope` | Optional, space-delimited. Unknown scopes are dropped rather than rejected | | | | The assertion's `resource` claim is **required** and decides which API the issued token is good for. To get a token for the MCP server, set it to this server's resource identifier — read that value from `resource` in the [protected-resource metadata](#discovery) rather than hard-coding it, since it follows the hostname you connect to. A successful exchange returns a standard OAuth token response whose `access_token` is prefixed `ema_`: ```json { "access_token": "ema_...", "token_type": "Bearer", "expires_in": 3600, "scope": "crm.read crm.write" } ``` Send it as `Authorization: bearer ema_...`, with or without a `connection` parameter: | | | | ----------------------- | ----------------------------------------------------------------------------------------------------- | | `?connection=` | That one connection's tools, narrowed to the token's scopes | | no `connection` | The workspace's integration data tools, narrowed to the token's scopes. The Core Unified API management tools are **not** available to an enterprise-managed token | | | | #### Tokens are bound to one resource An enterprise-managed token is **not portable between the REST API and the MCP server**. A token minted for the MCP server is rejected by the REST API, and one minted for the REST API is rejected by the MCP server. Mint a separate token per resource. #### Scopes | | | | ----------------- | ---------------------------------------------------------------------- | | `*` | Everything | | `.read` | Read access to one data category, eg. `crm.read` | | `.write`| Write access to one data category, eg. `ats.write` | | | | `` is a unified data category — `crm`, `ats`, `hris`, `accounting`, `ticketing`, `commerce`, and so on. Categories that expose no unified data objects (`passthrough`, `auth`, `saml`, `scim`, `metadata`) carry no scope and are **not reachable** with an enterprise-managed token; that includes the raw provider tools added by `include_external_tools`. Scopes filter the tool list, not just the call: `tools/list` advertises a tool only when the token's scopes permit calling it, so a tool that would be refused is never offered. A call outside the granted scopes returns `403`. #### Discovery | | | | -------------------------------------------- | ----------------------------------------------------------- | | `/.well-known/oauth-protected-resource/mcp` | The MCP server as an OAuth protected resource (RFC 9728). Also served at `/mcp/.well-known/oauth-protected-resource/mcp` and `/mcp/.well-known/oauth-protected-resource` | | `/.well-known/oauth-protected-resource` | The REST API as an OAuth protected resource | | `/.well-known/oauth-authorization-server` | The authorization server (RFC 8414) | | | | An unauthenticated request to `/mcp` returns `401` with a `WWW-Authenticate` header naming the protected-resource metadata URL, so a client that auto-discovers authentication can find the rest on its own. ### 4. Connection-scoped token Use this option to give an end-customer, or an MCP client you don't control, access to one connection without handing out your workspace API key. The token has the form `--`. Generate it with the [`get_unified_connection_mcp_url`](https://docs.unified.to/mcp/core#get_unified_connection_mcp_url) tool in `user` mode, then send it like any other credential, preferably as `Authorization: Bearer `. | | | | ------- | ------------------------------------------------------------------- | | `token` | The connection-scoped token. No `connection` parameter is needed | | | | The token grants access to that one connection only. It does **not** expire; to revoke it, delete the connection. ### 5. OAuth sign-in (for MCP clients) Use this option when an MCP client that supports OAuth, such as a Claude custom connector, should sign in to Unified.to itself instead of being given an API key. Point the client at the bare MCP URL (eg. `https://mcp-api.unified.to/mcp`) with no credential. The `401` response's `WWW-Authenticate` header leads the client through [discovery](#discovery) to the authorization server, where it registers itself (RFC 7591 dynamic client registration) and starts an authorization-code flow with PKCE (`S256`). The user signs in to Unified.to and picks a workspace they are an admin of. The access token lasts 24 hours and gives the [Core Unified API](https://docs.unified.to/mcp/core) tools for that workspace. A `connection` parameter is ignored with an OAuth token. | | | | ---------------- | ------------------------------------------------------------------------ | | `mcp:read` | Read-only tools (list and get) | | `mcp:write` | Tools that create, update, delete or trigger | | `offline_access` | A refresh token, so the client can renew access without signing in again | | | | Like enterprise-managed scopes, these filter `tools/list`: a token with only `mcp:read` is never offered a write tool. The token is bound to the MCP server it was requested for and is not accepted as a REST API key. ### Notes You can provide the credential in any of these places. When more than one is present, the first one wins: | Location | Example | | ------------------------ | ----------------------------- | | `Authorization` header | `Authorization: Bearer {token}` | | `x-api-key` header | `x-api-key: {token}` | | `token` URL parameter | `?token={token}` (deprecated) | All other parameters must be sent as URL parameters. Prefer a header. Authenticating with `?token=` still works, but it is deprecated: those responses carry a `Deprecation: true` header, and a `Sunset` header once a date is set. Tokens from options 3 and 5 advertise header authentication only in their [discovery metadata](#discovery). If your workspace has an IP allowlist, a workspace API key or enterprise-managed token used from an IP address that is not on it gets `403 IP address rejected`. Connection-scoped tokens and OAuth tokens are not held to the allowlist. A credential only works on the host for its own [data region](https://docs.unified.to/mcp/core#endpoints). Listing tools needs no credential at all. See [Without credentials](https://docs.unified.to/mcp/core#without-credentials). ## changelog URL: https://docs.unified.to/mcp/changelog # Unified MCP Server ## Changelog ### October 17, 2026 - **Agent signup from an MCP client.** Connect to `/mcp?signup=true` with no credential and call `signup_unified_workspace` to create a workspace, the same as [`POST /agent/signup`](https://docs.unified.to/mcp/agent-signup#signing-up-from-an-mcp-client). It is the one tool that can be called without a credential. ### October 16, 2026 - Total tool count is now 124,663, including 20,127 "unified" tools. ### October 12, 2026 - **Agent Skills in Core Unified API mode.** The server now serves Unified.to [Agent Skills](https://docs.unified.to/mcp/core#agent-skills), including a getting-started guide, guides to this server in Core and connection mode, and one per API category (CRM, HRIS, ATS, accounting and more). Clients that support the [MCP Skills extension](https://modelcontextprotocol.io/seps/2640-skills-extension) get them from `skills/list`, `skills/get` and `skill://` resources. Every other client can use the new `list_unified_skills` and `get_unified_skill` tools. ### October 10, 2026 - A `tools/list` request with no credential now lists the Core Unified API tools. See [Without credentials](https://docs.unified.to/mcp/core#without-credentials). ### October 9, 2026 - An integration's create and update tools now list only the fields that integration accepts, and mark the fields it requires on create. ### October 8, 2026 - Total tool count is now 94,258, including 17,189 "unified" tools. - New [Core Unified API tools](https://docs.unified.to/mcp/core#tools): `list_unified_notifications`, `create_unified_connection_reauth`, and the secrets manager tools (`list_unified_secretsmanagers`, `get_unified_secretsmanager`, `create_unified_secretsmanager`, `remove_unified_secretsmanager`, `list_unified_secretsmanager_instructions`, `list_unified_secretsmanager_secrets`). ### October 7, 2026 - **Unauthenticated tool preview.** `/mcp?integration_type=` and `GET /tools?integration_type=` list an integration's tools without a credential. The tools can be listed but not called. - Unified API tools now advertise an `outputSchema` describing their `structuredContent`. `GET /tools` returns it as `output_schema`. ### September 28, 2026 - A workspace's IP allowlist now applies to workspace API keys and enterprise-managed tokens over MCP. A call from an IP address not on the list gets `403`. ### September 27, 2026 - **OAuth sign-in for MCP clients.** A client that supports OAuth, such as a Claude custom connector, can connect to the bare `/mcp` URL and sign in to pick a workspace. See [OAuth sign-in](https://docs.unified.to/mcp/authentication#_5-oauth-sign-in-for-mcp-clients). - Every tool now carries a title and MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). ### September 23, 2026 - **Removed the SSE transport.** `/sse` and `/sse/messages` now return HTTP `410`. Change `/sse` to `/mcp` in your URL — same host, same authentication, same query parameters. - `get_unified_connection_mcp_url` no longer returns `sse_url`. Read `mcp_url` instead. - **Regions no longer resolve across one another.** Each data region serves only its own database, so a credential works only on its own region's host: US `mcp-api.unified.to`, EU `mcp-api-eu.unified.to`, AU `api-au.unified.to`. This supersedes the August 7, 2025 note below — the MCP server no longer figures the region out by itself. - **Removed the `dc` parameter.** Nothing routed on it; the hostname selects the region. It is no longer accepted by `get_unified_connection_mcp_url` and no longer appears in generated URLs. URLs that still carry `?dc=` keep working — the value is ignored. - Tool calls are now subject to workspace rate limiting and can return HTTP `429`. - Added a third authentication option: [enterprise-managed authentication](https://docs.unified.to/mcp/authentication#_3-enterprise-managed-authentication), which issues resource-bound `ema_` tokens whose scopes filter the visible tool list. - Added RFC 9728 protected-resource metadata at `GET /.well-known/oauth-protected-resource/mcp`, and a `WWW-Authenticate` header on MCP `401` responses. ### September 21, 2026 - Total tool count is now 69,948, including 13,553 "unified" tools. ### July 22, 2026 - Total tool count is now 43,456, including 8,242 "unified" tools. ### June 30, 2026 - Total tool count is now 27,825, including 7,609 "unified" tools. ### January 25, 2026 - Total tool count is now 22,566, including 5,324 "unified" tools. ### December 23, 2025 - Total tool count is now 22,319, including 4,798 "unified" tools. - Introduced `defer_tools` parameter that uses Anthropic's new feature to lower tool token usage ### December 17, 2025 - Removed public authorization options ### October 19, 2025 - Added a new "workspace" mode that allows our customers to interact with their Unified data, including connections, webhooks, API calls, and issues/tickets. - Total tool count is now 21,993, including 4,425 "unified" tools. ### October 2, 2025 - Total tool count is now 21,874, including 4,322 "unified" tools. ### August 31, 2025 - Added x.ai Grok ### August 29, 2025 - Total tool count is now 20,135, including 4,046 "unified" tools. - Added support for Groq ### August 26, 2025 - Total tool count is now 19,872. - Added a new `tools` parameter to specify exactly which tools to allow in the MCP server. - Aliases no longer create seperate tools, but now generate extended tool descriptions. This will help with LLM tool matching. We removed the `default_aliases` parameter, as that is now the defaul behaviour. ### August 24, 2025 - Total tool count is now at 17,569; 3,963 "unified" tools across 317 integrations over 20 categories. 13,606 "non-unified" tools across 93 integrations. ### August 22, 2025 - New data-region in Europe. Change the MCP URL to https://mcp-api-eu.unified.to. Data accessed by the MCP will be transfered directly from the EU data-region of Unified.to. - Added support to expand an integration's tool over all of its available API endpoints, and not just the unified API's endpoints, which expand the number of tools by an additional 12,979. Use `&include_external_tools=true`. ### August 7, 2025 - Added support for all Unified.to API data-centers; US, EU, AU. The MCP will figure it out by itself. ### July 18, 2025 - Added `aliases` parameter to add additional descriptions to tools - Added support for Cohere's `chat` API and its tools structure ### July 10, 2025 - Added `structuredContent` output when the MCP-Protocol-Version is `2025-06-18` or more recent - Added `type` parameter to `GET /tools` to return tools in a specific LLM data-model - Added `hide_sensitive` parameter to `POST /tools/{id}/call` and the MCP server URL to remove PII/sensitive data from results. eg. `hide_sensitive=true` - Added `permissions` parameter to `GET /tools`, `POST /tools/{id}/call` and MCP server URL to restrict tools - Added additional authentication mechanism `token` and `connection` to be ONLY used with LLM APIs ### June 1, 2025 - Initial deploy ## core URL: https://docs.unified.to/mcp/core # Core Unified API The Unified MCP Server has two personalities: - **Connection mode** — point it at a single end-customer connection and it exposes that connection's integration-specific tools (CRM contacts, accounting invoices, HRIS employees, and so on). - **Core Unified API mode** — authenticate with only a workspace API key (no connection ID) and it exposes the [Core Unified API](https://docs.unified.to/unified/overview) management tools for your **own** workspace configuration data. This page covers Core Unified API mode. > **Note:** Core Unified API mode operates on your workspace configuration data (connections, webhooks, integrations, API-call logs, issues, environments). It does **not** read or write your end-customers' data inside their connections — use a per-connection URL for that (see [`get_unified_connection_mcp_url`](#get_unified_connection_mcp_url)). ## Authentication Provide a Unified.to [workspace API key](https://app.unified.to/settings/api) as the only credential. This is the "Private Workspace-only Authentication" method described in [Authentication](https://docs.unified.to/mcp/authentication). An MCP client that supports OAuth can instead [sign in](https://docs.unified.to/mcp/authentication#_5-oauth-sign-in-for-mcp-clients) to reach the same tools. The key may be supplied in any of these places: | Location | Example | | -------------------- | ---------------------------------------------------------- | | Authorization header | `Authorization: Bearer ` | | `x-api-key` header | `x-api-key: ` | | URL parameter | `https://mcp-api.unified.to/mcp?token=` | All other options must be passed as URL parameters. > **Deprecated:** authenticating with `?token=` still works, but responses to a request that > authenticates that way now carry a `Deprecation: true` header (and a `Sunset` header once a date is > set). Move the key into the `Authorization` header. > **Warning:** A workspace API key grants access to **all** of your connections and your entire Unified.to account. Treat it as a secret and never expose it in a shareable URL. To hand out access to a single connection, generate a connection-scoped URL with [`get_unified_connection_mcp_url`](#get_unified_connection_mcp_url). ## Endpoints | Region | Streamable HTTP | | ------ | ----------------------------------- | | US | `https://mcp-api.unified.to/mcp` | | EU | `https://mcp-api-eu.unified.to/mcp` | | AU | `https://api-au.unified.to/mcp` | Use the host for the region your workspace lives in. Regions no longer resolve across one another: each deployment sees only its own region's database, so a credential presented to another region's host resolves to **"not found"**. The MCP server runs as part of the Unified API, so `/mcp` is the canonical address in every region — which is why AU is reached at `api-au.unified.to`. `mcp-api.unified.to` and `mcp-api-eu.unified.to` are long-standing aliases kept working for US and EU. ### Transport Streamable HTTP at `/mcp` is the only transport. **The SSE transport has been removed.** Migrating is a one-word URL edit — change `/sse` to `/mcp` and keep the same host, authentication and query parameters. The server supports MCP protocol revisions `2024-11-05` through `2025-11-25`, and the stateless `2026-07-28` revision: - **Up to `2025-11-25`:** `initialize` returns an `mcp-session-id` header. Send it on later requests. Any server in the region can serve the session, and it expires after 5 minutes without a request; a request for an expired session returns `404 Session not found`, so start a new one with `initialize`. `DELETE /mcp` ends a session. - **`2026-07-28`:** send `MCP-Protocol-Version: 2026-07-28` on every request. There is no session. From revision `2025-06-18` on, tool results carry `structuredContent` (a list result is wrapped as `{ "results": [...] }`), and the Unified API tools advertise an `outputSchema` describing it. The schema is lenient: no field is required, and every value may be `null`. `GET` and `POST` on `/sse` and `/sse/messages` return HTTP `410`: ```json { "error": "gone", "message": "The SSE transport has been removed. Use the Streamable HTTP endpoint at /mcp with the same authentication and query parameters. See https://docs.unified.to/mcp/core." } ``` ### Client configuration Send your workspace API key in the `Authorization` header, not in the URL. Where a client can read environment variables, the examples take the key from `UNIFIED_API_KEY` so it stays out of config files you might commit. Swap in your region's host from [Endpoints](#endpoints). #### Claude Code Install the Unified.to plugin. It bundles the Core MCP server with our [Agent Skills](https://docs.unified.to/skills/available) and prompts you for the API key, which it keeps in secure storage: ``` /plugin marketplace add https://docs.unified.to/.claude-plugin/marketplace.json /plugin install unified@unified-to ``` Or add only the MCP server: ```bash claude mcp add --transport http unified-core https://mcp-api.unified.to/mcp \ --header "Authorization: Bearer $UNIFIED_API_KEY" ``` #### Codex ```bash codex mcp add unified-core --url https://mcp-api.unified.to/mcp --bearer-token-env-var UNIFIED_API_KEY ``` Or in `~/.codex/config.toml`: ```toml [mcp_servers.unified-core] url = "https://mcp-api.unified.to/mcp" bearer_token_env_var = "UNIFIED_API_KEY" ``` #### Cursor [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=unified-core&config=eyJ1cmwiOiJodHRwczovL21jcC1hcGkudW5pZmllZC50by9tY3AiLCJoZWFkZXJzIjp7IkF1dGhvcml6YXRpb24iOiJCZWFyZXIgJHtlbnY6VU5JRklFRF9BUElfS0VZfSJ9fQ%3D%3D) (US host), or add this to `.cursor/mcp.json` in your project or `~/.cursor/mcp.json`: ```json { "mcpServers": { "unified-core": { "url": "https://mcp-api.unified.to/mcp", "headers": { "Authorization": "Bearer ${env:UNIFIED_API_KEY}" } } } } ``` #### VS Code (GitHub Copilot) Add this to `.vscode/mcp.json`. VS Code prompts for the key the first time the server starts: ```json { "inputs": [ { "type": "promptString", "id": "unified-api-key", "description": "Unified.to workspace API key", "password": true } ], "servers": { "unified-core": { "type": "http", "url": "https://mcp-api.unified.to/mcp", "headers": { "Authorization": "Bearer ${input:unified-api-key}" } } } } ``` #### Clients that sign in with OAuth A client that supports OAuth for MCP servers (for example, a Claude custom connector) needs no key. Add `https://mcp-api.unified.to/mcp`, or your region's host, with no credential, and the client takes you through [OAuth sign-in](https://docs.unified.to/mcp/authentication#_5-oauth-sign-in-for-mcp-clients) to pick a workspace. #### Other clients Any client that speaks Streamable HTTP and supports custom headers: ```json { "mcpServers": { "unified-core": { "type": "http", "url": "https://mcp-api.unified.to/mcp", "headers": { "Authorization": "Bearer " } } } } ``` Via `mcp-remote` (for clients that only speak stdio): ```json { "mcpServers": { "unified-core": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp-api.unified.to/mcp", "--header", "Authorization:${AUTH_HEADER}"], "env": { "AUTH_HEADER": "Bearer " } } } } ``` ## Tools Core Unified API mode exposes the following tools. Each one carries MCP annotations: read tools are marked `readOnlyHint`, tools that create, change or delete are marked `destructiveHint`, and `get_unified_connection_mcp_url` is marked as neither, so clients always ask before running it. ### Search API Docs | Tool | Description | | ------------- | --------------------------------------------------------------------------------------------- | | `search_docs` | Search the API documentation at docs.unified.to and provide an answer to a query or question. | ### Skills | Tool | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `list_unified_skills` | List the Agent Skills the server serves: name, description and `skill://` URI. Optional `query` filters by name or description | | `get_unified_skill` | Return one skill's full `SKILL.md`, by `name` (e.g. `unified-crm`) or `skill://` URI | See [Agent Skills](#agent-skills) below. ### Connections | Tool | Description | | -------------------------------- | ------------------------------------------------------------------------------------------ | | `list_unified_connections` | List the connections in your workspace | | `get_unified_connection` | Retrieve a single connection by ID | | `create_unified_connection` | Create a connection | | `update_unified_connection` | Update a connection | | `remove_unified_connection` | Delete a connection | | `create_unified_connection_reauth` | Create a re-authorization token for a connection, so its end-customer can re-authorize it | | `get_unified_connection_mcp_url` | Generate a connection-scoped MCP remote URL (see [below](#get_unified_connection_mcp_url)) | ### Webhooks | Tool | Description | | -------------------------------- | ------------------------ | | `list_unified_webhooks` | List webhooks | | `get_unified_webhook` | Retrieve a webhook by ID | | `create_unified_webhook` | Create a webhook | | `update_unified_webhook` | Update a webhook | | `remove_unified_webhook` | Delete a webhook | | `update_unified_webhook_trigger` | Trigger a webhook | ### Integrations | Tool | Description | | --------------------------- | ---------------------------------------------------------------- | | `list_unified_integrations` | List the available integrations and their supported capabilities | ### Issues | Tool | Description | | --------------------- | --------------------------------------------------- | | `list_unified_issues` | List issues / tickets raised against your workspace | | `get_unified_issue` | Retrieve a single issue by ID | ### API calls | Tool | Description | | ----------------------- | ------------------------------------ | | `list_unified_apicalls` | List recent API-call logs | | `get_unified_apicall` | Retrieve a single API-call log by ID | ### Environments | Tool | Description | | ---------------------------- | -------------------------------- | | `list_unified_environments` | List authentication environments | | `create_unified_environment` | Create an environment | | `remove_unified_environment` | Delete an environment | ### Notifications | Tool | Description | | ---------------------------- | ----------------------------- | | `list_unified_notifications` | List your workspace's event notifications | ### Secrets managers | Tool | Description | | ------------------------------------------ | ----------------------------------------------------------- | | `list_unified_secretsmanagers` | List the secrets managers connected to your workspace | | `get_unified_secretsmanager` | Retrieve a secrets manager by ID | | `create_unified_secretsmanager` | Connect a secrets manager | | `remove_unified_secretsmanager` | Remove a secrets manager | | `list_unified_secretsmanager_instructions` | List the setup instructions for each secrets manager type | | `list_unified_secretsmanager_secrets` | List the secrets in a secrets manager | ### Without credentials A coding agent can see what the server offers before it has a key: - **A `tools/list` request with no credential** returns the Core Unified API tools above, with their input and output schemas. No session or `initialize` is needed. Every other method without a credential gets `401`, which is how OAuth-capable clients find the [sign-in](https://docs.unified.to/mcp/authentication#_5-oauth-sign-in-for-mcp-clients). - **`/mcp?integration_type=`** serves that integration's full tool catalog, on every method including `initialize`, eg. `https://mcp-api.unified.to/mcp?integration_type=hubspot`. An unknown type returns `404`. - **`/mcp?signup=true`** serves the Core tools on every method and adds `signup_unified_workspace`, the one tool callable without a credential. See [Agent Signup](https://docs.unified.to/mcp/agent-signup#signing-up-from-an-mcp-client). Calling any other tool from a preview returns an error result that asks the client to authenticate. The `tools`, `permissions` and `include_external_tools` [options](https://docs.unified.to/mcp/server-options) still apply. A request that presents a credential is always authenticated normally, so an invalid credential still gets `401`. [`GET /tools`](https://docs.unified.to/mcp/additional-api-endpoints#get-tools) serves the same previews in the REST shape. ## Agent Skills In Core Unified API mode the server also serves Unified.to [Agent Skills](https://docs.unified.to/skills/available): step-by-step guides that teach a coding agent how to build with Unified.to. The skills are the same `SKILL.md` files published on this site; the server picks up changes within an hour. The server serves these skills: | Skill | What it covers | | ------------------------------- | -------------------------------------------------------------------------------------------- | | `unified-quickstart` | Get started: activate integrations, embed the Authorization component, make a first API call | | `unified-mcp-core` | Manage your workspace from an agent through this server in Core Unified API mode | | `unified-mcp-connection` | Give an AI agent one end-customer connection's tools through this server in connection mode | | `unified-accounting-invoicing` | Invoicing, billing and bookkeeping (Accounting API) | | `unified-assessment` | Assessment and background-check products that plug into ATSs (Assessment API) | | `unified-ats-jobboard` | Candidate sourcing, recruiting and job boards (ATS API) | | `unified-auth-signin` | "Sign in with" Google, Microsoft and other OAuth2 or SAML providers (Authentication API) | | `unified-calendar` | Scheduling, calendars and events (Calendar API) | | `unified-crm` | CRM and sales automation (CRM API) | | `unified-ecommerce` | Products, catalogs and inventory (Commerce API) | | `unified-enterprise-search-rag` | Enterprise search and RAG over documents (Storage and KMS APIs) | | `unified-hris` | HR, people data and payroll (HRIS API) | | `unified-messaging` | Chat, bots and notifications (Messaging API) | | `unified-payments` | Payments, subscriptions and payouts (Payment API) | | `unified-ticketing` | Helpdesk and issue tracking (Ticketing API) | The [skills directory](https://docs.unified.to/skills/available) has more, such as the guides to registering OAuth apps with each vendor; those are not served by the MCP server. All skills, the connection-mode guide included, are served only in Core Unified API mode, not on connection URLs or to enterprise-managed tokens. ### MCP Skills extension Clients that support the [MCP Skills extension](https://modelcontextprotocol.io/seps/2640-skills-extension) (`io.modelcontextprotocol/skills`) discover skills on their own. The server declares the extension, and the `resources` capability it requires, at `initialize` and `server/discover`: ```json { "capabilities": { "tools": {}, "resources": {}, "extensions": { "io.modelcontextprotocol/skills": {} } } } ``` | Method | What it returns | | ---------------- | --------------------------------------------------------------------------------------------------------------------------- | | `skills/list` | Every skill: its `uri`, its `SKILL.md` `frontmatter`, and a `resources` array with each file's `digest` (sha256) and `size` | | `skills/get` | One skill's entry, given its `uri`. An unknown URI returns error `-32602` | | `resources/list` | Each skill's `SKILL.md` as a resource | | `resources/read` | A skill's `SKILL.md` content (`text/markdown`) | Each skill is a single `SKILL.md`, addressed as `skill:///SKILL.md`, for example `skill://unified-quickstart/SKILL.md`. Example `skills/list` result: ```json { "skills": [ { "uri": "skill://unified-quickstart/SKILL.md", "frontmatter": { "name": "unified-quickstart", "description": "Add Unified.to integrations to an app: …", "license": "MIT", "metadata": { "category": "quickstart", "docs": "https://docs.unified.to/quick-start" } }, "resources": [ { "uri": "skill://unified-quickstart/SKILL.md", "digest": "sha256:…", "size": 6046 } ] } ] } ``` ### Clients without the extension Every other client reaches the same skills through two tools, [`list_unified_skills` and `get_unified_skill`](#skills). The server's `instructions` tell the model to check for a matching skill before starting a build task. > **Tip:** To install skills locally instead, use the Claude Code plugin under [Client configuration](#claude-code) or `npx skills add https://docs.unified.to`. Skills installed that way work without the MCP server. ## `get_unified_connection_mcp_url` This is the bridge from Core Unified API mode into connection mode: manage your connections with your workspace API key, then generate a ready-to-use MCP remote URL for any individual connection. The URL exposes that connection's integration-specific tools over Streamable HTTP (`/mcp`). ### Parameters | Parameter | Required | Description | | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------- | | `connection_id` | yes | The ID of the connection to generate an MCP remote URL for | | `mode` | no | Authentication method for the generated URL — `api` (default) or `user`. See [Modes](#modes) | | `permissions` | no | Comma-separated permission scopes to limit the tools the URL exposes (e.g. `crm_contact_read,crm_company_read`) | | `tools` | no | Comma-separated tool IDs or wildcard patterns to limit which tools the URL exposes (e.g. `list_*,*invoices*`) | | `aliases` | no | Custom description aliases (`key:alt1,alt2;key2:alt3`) to improve tool matching (e.g. `employee:user;employees:users`) | | `defer_tools` | no | Defer tool loading to reduce context usage — `all` or a comma-separated list of tool IDs. Only takes effect on `GET /tools` | | `hide_sensitive` | no | When `true`, strips PII and other sensitive fields from tool responses before they reach the LLM | | `include_external_tools` | no | When `true`, exposes the vendor's raw passthrough endpoints as additional callable MCP tools | Any option supplied is baked into the returned URLs as a query parameter. See [Server options](https://docs.unified.to/mcp/server-options) for the full behavior of each one. > **Removed:** `dc` was a region hint that nothing routed on. It is no longer accepted here and no longer appears in generated URLs. Pick the region with the [hostname](#endpoints) instead. ### Modes The `mode` parameter selects the [authentication method](https://docs.unified.to/mcp/authentication) embedded in the generated URL: | `mode` | Generated URL | Sharing | | --------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `api` (default) | `…/mcp?connection=`, with your workspace API key as the `Authorization` header | Uses your workspace API key — grants full-account access. **Do not share.** Revoke it by rotating the workspace API key, which affects every URL that used it. | | `user` | `…/mcp`, with a connection-scoped token as the `Authorization` header | Uses a connection-scoped **signed** token. Safe to share — it grants access only to that single connection and never exposes your workspace API key. Revoke it by deleting the connection. | Neither kind of URL expires on its own. ### Response ```json { "connection_id": "682e20be9ca303793060370f", "integration_type": "hubspot", "mode": "user", "recommended": { "url": "https://mcp-api.unified.to/mcp", "headers": { "Authorization": "Bearer 682e20be9ca303793060370f--" } }, "mcp_url": "https://mcp-api.unified.to/mcp?token=682e20be9ca303793060370f--", "note": "USER mode: a connection-scoped signed token that grants access only to this single connection. Preferred: use `recommended.url` and send the token via the Authorization header. The `mcp_url` form embeds the token in the URL and is deprecated." } ``` | Field | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------- | | `recommended.url` | The MCP URL without the credential. In `api` mode it still carries `connection`, and in both modes any options you passed | | `recommended.headers` | The `Authorization` header to send with it | | `mcp_url` | The same URL with the credential in `?token=`. Deprecated, since the credential can end up in logs and browser history | > **Breaking change:** the response no longer contains `sse_url`. If your code reads that field, it now > gets `undefined` — read `recommended` (or `mcp_url`) instead. The SSE transport it pointed at has been > [removed](#transport). ### Example Generate a shareable, read-only CRM URL for one connection by calling `get_unified_connection_mcp_url` with: - `connection_id`: `682e20be9ca303793060370f` - `mode`: `user` - `permissions`: `crm_contact_read,crm_company_read` - `hide_sensitive`: `true` Give an MCP client `recommended.url` with `recommended.headers`. It only exposes the read-only CRM tools for that connection, with PII stripped from responses. For a client that can't send headers, use `mcp_url` instead. ## installation URL: https://docs.unified.to/mcp/installation # Unified MCP Server ## Installation & Usage [OpenAI](#openai-api) · [Anthropic](#anthropic-api) · [Gemini](#google-gemini-api) · [Cohere](#cohere) · [Grok & Groq](#grok-groq) · [Claude.ai](#claudeai-online) · [Claude desktop](#claude-desktop-client) · [Cursor](#cursor) The samples below use the US host. Swap it for your workspace's region — `mcp-api-eu.unified.to` or `api-au.unified.to` — since a credential only resolves on its own region's host. See [Endpoints](https://docs.unified.to/mcp/core#endpoints). ### OpenAI API: OpenAI's Responses API supports remote MCP directly. Enter in our Streamable HTTP URL. ``` resp = client.responses.create( model="gpt-4.1", tools=[{ "type": "mcp", "server_label": "unifiedMCP", "server_url": "https://mcp-api.unified.to/mcp?connection=YYYYYYY", "headers": {"Authorization": "Bearer XXXXXXXX"}, "require_approval": "never", }], input="list the candidates and then analyse the resumes from their applications", ) ``` OpenAI also supports sending in a list of tools and having their API request that you call a specific tool with specific parameters. You would first call our [`GET /tools?type=openai`](https://docs.unified.to/mcp/additional-api-endpoints#get-tools) endpoint, then take the output and include it in your prompt API call: ``` resp = client.responses.create( model="gpt-4.1", tools=$TOOLS, input="list the candidates and then analyse the resumes from their applications", ) ``` Once you call that tool (using our [`POST /tools/{id}/call`](https://docs.unified.to/mcp/additional-api-endpoints#post-toolsidcall) endpoint, with the function call's `name` as `{id}` and its parsed `arguments` as the body), you would create a new prompt and reference the original response with a `previous_response_id` value. Please see [this article](https://platform.openai.com/docs/guides/tools-remote-mcp) for more information. ### Anthropic API: Claude's Messages API connects to the Unified MCP server directly through Anthropic's [MCP connector](https://platform.claude.com/docs/en/agents-and-tools/mcp-connector), so you don't write a tool-call loop. Anthropic lists our tools, lets Claude call them, and returns the results in the same response. Declare the server in `mcp_servers`, reference it from `tools` with an `mcp_toolset` entry (a server that no toolset references is rejected), and send the `mcp-client-2025-11-20` beta header. Put your workspace API key in `authorization_token`, which Anthropic sends as the `Authorization: Bearer` header, and keep only the `connection` in the URL: ```python import anthropic client = anthropic.Anthropic() response = client.beta.messages.create( model="claude-opus-5-5", max_tokens=1024, messages=[{"role": "user", "content": "list the candidates and then analyse the resumes from their applications"}], mcp_servers=[ { "type": "url", "url": "https://mcp-api.unified.to/mcp?connection=YYYYYYY", "name": "unifiedMCP", "authorization_token": "XXXXXXXX", # your workspace API key } ], tools=[{"type": "mcp_toolset", "mcp_server_name": "unifiedMCP"}], betas=["mcp-client-2025-11-20"], ) ``` ```typescript const response = await anthropic.beta.messages.create({ model: 'claude-opus-5-5', max_tokens: 1024, messages: [{ role: 'user', content: message }], mcp_servers: [ { type: 'url', url: 'https://mcp-api.unified.to/mcp?connection=YYYYYYY', name: 'unifiedMCP', authorization_token: process.env.UNIFIED_API_KEY, }, ], tools: [{ type: 'mcp_toolset', mcp_server_name: 'unifiedMCP' }], betas: ['mcp-client-2025-11-20'], }); ``` The response contains `mcp_tool_use` and `mcp_tool_result` blocks for each tool Claude called, followed by Claude's answer: ``` [ { "type": "mcp_tool_use", "id": "mcptoolu_014Q35RayjACSWkSj4X2yov1", "name": "list_candidates", "server_name": "unifiedMCP", "input": { "limit": "100" } }, { "type": "mcp_tool_result", "tool_use_id": "mcptoolu_014Q35RayjACSWkSj4X2yov1", "is_error": false, "content": [{ "type": "text", "text": "..." }] }, { "type": "text", "text": "..." } ] ``` To expose only some tools, allowlist them in the `mcp_toolset` entry with `default_config` and `configs`. This is an alternative to our own [`tools` parameter](https://docs.unified.to/mcp/server-options): ``` { "type": "mcp_toolset", "mcp_server_name": "unifiedMCP", "default_config": { "enabled": false }, "configs": { "list_candidates": { "enabled": true } } } ``` The MCP connector supports tool calls only. It isn't available on Amazon Bedrock or Google Cloud Vertex AI, and it isn't eligible for zero data retention. See Anthropic's [MCP connector](https://platform.claude.com/docs/en/agents-and-tools/mcp-connector) documentation for details. #### Calling the tools yourself On Amazon Bedrock or Vertex AI, or when your code must run each tool call itself (for example, to log or approve it), use standard tool use instead. Fetch the tools from our [`GET /tools?type=openai`](https://docs.unified.to/mcp/additional-api-endpoints#get-tools) endpoint and rename `parameters` to `input_schema`, which is the key Anthropic expects: ```python import requests unified_tools = requests.get( "https://mcp-api.unified.to/tools", params={"type": "openai", "connection": "YYYYYYY"}, headers={"Authorization": "Bearer XXXXXXXX"}, ).json() tools = [ {"name": t["name"], "description": t["description"], "input_schema": t["parameters"]} for t in unified_tools ] resp = client.messages.create( model="claude-opus-5-5", max_tokens=1024, tools=tools, messages=[{"role": "user", "content": "list the candidates and then analyse the resumes from their applications"}], ) ``` Claude returns `tool_use` content blocks: ``` [ { "type": "tool_use", "id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV", "name": "list_candidates", "input": { "limit": "100" } } ] ``` Call that tool with our `POST /tools/{name}/call` endpoint, sending its `input` as the JSON body, then return the result to Claude in the next user message: ``` [ { "type": "tool_result", "tool_use_id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV", "content": "..." } ] ``` See Anthropic's [tool use](https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview) documentation for more information. ### Google Gemini API: Google Gemini uses a similar concept that Anthropic use for tools, but they call it `functionDeclarations`. First you will request a list of function declarations with a `GET /tools?type=gemini`, and then give them to the `generateContent` request inside a `functionDeclarations` entry: ``` const completion = await gemini.models.generateContent({ model: latestModel, contents: message, config: { tools: [{ functionDeclarations: $TOOLS }] }, }); ``` Please see [this article](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/function-calling) for more information. Gemini will return a request for you to call that specific tool: ``` content { role: "model" parts { function_call { name: "list_candidates" args { fields { key: "limit" value { string_value: "100" } } } } } } ``` Call that tool with our `POST /tools/{name}/call` endpoint, sending its `args` as the JSON body. When you respond back with another message creation, you can include the result in the `content` array: ``` { "role": "user", "parts": [ { "functionResponse": { "name": "list_candidates", "response": { ... } } } ] } ``` ### Cohere: Cohere's chat API uses the same concept as Anthropic and Google Gemini. First get a list of tools from our `GET /tools?type=cohere` endpoint, then add that result to your chat API command: ``` response = co.chat( model="command-a-03-2025", messages=messages, tools=tools ) ``` When Cohere's response asks you to call a tool, call our `POST /tools/{name}/call` endpoint with the tool call's `function.name` as `{name}` and its parsed `function.arguments` as the JSON body. ### Grok & Groq: Both x.ai's Grok and Groq (different company) work the same way, which is similar to Anthropic and Google Gemini. First get a list of tools from our `GET /tools?type=grok` or `GET /tools?type=groq` endpoint, then add that result to your chat API command: ```typescript response = client.chat.completions.create({ // ... tools: tools, tool_choice: 'auto', }); ``` Respond to `tool_calls` in the Grok/Groq response: ```typescript const response_message = response.choices[0].message; const tool_calls = response_message.tool_calls; for (const tool_call of tool_calls) { const function_name = tool_call.function.name; const function_args = JSON.parse(tool_call.function.arguments); // call our POST /tools/${function_name}/call with function_args as the JSON body const result_content = await callUnifiedTool(function_name, function_args); const messages = [ { tool_call_id: tool_call.id, role: 'tool', name: function_name, content: result_content, }, ]; const second_message = client.chat.completions.create({ // ... messages, }); } ``` ### Claude.ai (online): Go to [claude.ai](https://claude.ai/), then navigate to **Customize > Connectors** and click **Add custom connector**. On a Team or Enterprise plan, an Owner adds it under **Organization settings > Connectors** instead. **Your workspace (Core Unified API tools):** enter `https://mcp-api.unified.to/mcp` with no credential. Claude takes you through [OAuth sign-in](https://docs.unified.to/mcp/authentication#_5-oauth-sign-in-for-mcp-clients) to pick a workspace. **One connection:** send a [connection-scoped token](https://docs.unified.to/mcp/authentication#_4-connection-scoped-token) as a request header: 1. For **MCP server URL**, enter `https://mcp-api.unified.to/mcp`. 2. For **Authentication**, choose **No sign-in**. 3. Under **Request headers**, choose the `authorization` header and enter `Bearer {connectionID}-{nonce}-{signature}` as its value. Include `Bearer ` and the space; Claude sends the value exactly as entered. 4. Click **Add**. Claude stores the header value and doesn't show it again. To change the token later, remove the connector and add it again. Request headers are a beta that Anthropic offers to a limited set of organizations (see Anthropic's [custom connector docs](https://claude.com/docs/connectors/custom/add-unlisted#authenticate-with-request-headers)). If your **Add custom connector** dialog has no **Request headers** section, put the token in the URL instead: `https://mcp-api.unified.to/mcp?token={connectionID}-{nonce}-{signature}`. That form still works, but it is deprecated. Make sure to provide your end-customer the appropriate token value. Generate it with [`get_unified_connection_mcp_url`](https://docs.unified.to/mcp/core#get_unified_connection_mcp_url) in `user` mode: `recommended.headers` holds the header value, and `mcp_url` holds the URL form. ### Claude (desktop client): Edit the `claude_desktop_config.json` file: ```json { "mcpServers": { "unified-mcp": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp-api.unified.to/mcp?connection=YYYYYYY", "--header", "Authorization:${AUTH_HEADER}" ], "env": { "AUTH_HEADER": "Bearer XXXXXXXX" } } } } ``` Make sure to provide your end-customer the appropriate `token` value. ### Cursor: Navigate to Cursor > Settings > Cursor Settings > MCP and edit the MCP configuration. Replace `unified-mcp` with the name of your own application and then make sure to provide your end-customer the appropriate `token` value. ```json { "mcpServers": { "unified-mcp": { "url": "https://mcp-api.unified.to/mcp?connection=YYYYYYY", "headers": { "Authorization": "Bearer XXXXXXXX" } } } } ``` MCP is a new protocol and it is moving fast. We expect more LLM & agent clients to support its newer Streamable HTTP transport protocol. We also expect that the MCP protocol will continue to expand quickly. Stay tuned as we also keep up. ## overview URL: https://docs.unified.to/mcp/overview # Unified MCP Server Unified has launched an MCP server that connects any Unified connection to LLM (Large Language Model) providers supporting the newest MCP protocols. The available MCP `tools` will be determined by the integration’s feature support and the connection’s requested permissions. The Unified MCP server sits on top of our Unified API (which calls individual APIs), and hides all of the complexity of API calling. Each call to a tool will count as 1 API request on your plan. ![Unified MCP Server](/images/MCP_V2.png) Warning: Most current LLM models have severe limits on how many tools they can handle. Groq's models can only handle 10 tools, while most of OpenAI's models can handle only 20 available tools. Cohere's recent models seem to work with 50 tools. Make sure that you limit the tools with the `permissions` or `tools` [parameters](https://docs.unified.to/mcp/server-options); `tools` accepts wildcards such as `list_*`. Check out our sample code (in Node.js TypeScript) at [unified-mcp-typescript](https://github.com/unified-to/unified-mcp-typescript) You can see the tools before you have a credential: a `tools/list` request with no credential lists the Core Unified API tools, and `?integration_type=` lists one integration's tools. See [Without credentials](https://docs.unified.to/mcp/core#without-credentials). In [Core Unified API mode](https://docs.unified.to/mcp/core) (workspace API key, no connection), the server also serves Unified.to [Agent Skills](https://docs.unified.to/mcp/core#agent-skills): guides that teach a coding agent how to build with Unified.to. ## URLs - Streamable HTTP (US): https://mcp-api.unified.to/mcp - Streamable HTTP (EU): https://mcp-api-eu.unified.to/mcp - Streamable HTTP (AU): https://api-au.unified.to/mcp - stdin: `for real? it's 2025...` The hostname selects the data region, and it no longer resolves across regions: a credential only works on its own region's host. AU sits on the API host because the MCP server now runs as part of the Unified API; `mcp-api*` are aliases kept for US and EU. See [Endpoints](https://docs.unified.to/mcp/core#endpoints). The SSE transport has been removed. Migrating is a one-word URL edit — change `/sse` to `/mcp` and keep the same host, authentication and query parameters. See [Core Unified API](https://docs.unified.to/mcp/core#transport). ## server options URL: https://docs.unified.to/mcp/server-options # Unified MCP Server ## MCP Server Options Add these URL parameters to the MCP URL: | | | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `connection` | The [connection ID](https://app.unified.to/connections) to access. Only used when the credential is a workspace API key or an [enterprise-managed token](https://docs.unified.to/mcp/authentication#_3-enterprise-managed-authentication). | | `token` | The credential: a workspace API key or a connection-scoped token. **Deprecated** as a URL parameter; send it in the `Authorization: Bearer` (or `x-api-key`) header instead. See [Authentication](https://docs.unified.to/mcp/authentication). | | `hide_sensitive` | When `true`, removes sensitive (PII) fields from tool results: `name`, `email`, `emails`, `telephones`, `address`, `addresses`, `gender`, `date_of_birth`, `ssn_sin`, `compensation`, and credential fields such as `token`, `access_token` and `client_secret`. | | `permissions` | A comma-delimited list of [permissions](https://docs.unified.to/guides/how_to_configure_webhooks_in_hubspot#select-permission-scopes-on-unifiedto), eg. `crm_contact_read,crm_company_read`. It can only narrow the tools: a permission the connection was not granted is ignored. | | `tools` | A comma-delimited list of tool IDs to restrict the MCP server to. Supports the wildcards `*` and `?`, matched case-insensitively, eg. `list_*,*invoice*`. | | `defer_tools` | A comma-delimited list of tool IDs (or partial IDs) to mark with `defer_loading`, so the LLM does not load or tokenize them up front. Use `all` to defer every tool. Only applies to the [`GET /tools`](https://docs.unified.to/mcp/additional-api-endpoints#get-tools) endpoint, which also adds Anthropic's tool-search tool to the list. | | `aliases` | A list of words and their aliases that add extra descriptions to tools, in the format `word1:alias1,alias2;word2:alias1,alias2`. eg. `?aliases=employee:user,person;employees:users,people` | | `include_external_tools` | When `true`, also include tools for the rest of the integration's own API, not just the unified API. These tools are prefixed `external_`. | | `integration_type` | Without a credential, serves that integration's tool definitions as an [unauthenticated preview](https://docs.unified.to/mcp/core#without-credentials). The tools can be listed but not called. An unknown type returns `404`. | | `signup` | `signup=true` lets an agent with no credential connect and call `signup_unified_workspace` to create a workspace. See [Agent Signup](https://docs.unified.to/mcp/agent-signup#signing-up-from-an-mcp-client). | | `dc` | **Removed.** Was a region hint, but nothing ever routed on it — the hostname selects the region. It is no longer accepted by `get_unified_connection_mcp_url` and is no longer added to generated URLs. A URL that still carries `?dc=` keeps working; the value is ignored. Use the [region hostname](https://docs.unified.to/mcp/core#endpoints). | | | | ## Data regions The hostname selects the data region; regions do not resolve across one another. Use the host your workspace's data lives in — US `mcp-api.unified.to`, EU `mcp-api-eu.unified.to`, AU `api-au.unified.to`. See [Endpoints](https://docs.unified.to/mcp/core#endpoints). --- # Migrate to Unified.to ## Migrate from Apideck to Unified.to URL: https://docs.unified.to/migrate/apideck # Migrate from Apideck to Unified.to Apideck and Unified.to are both real-time unified APIs: requests go to the source API rather than a synced copy, so your data freshness model doesn't change. The main differences are how a connection is addressed, how end users authorize, and how pagination and webhooks work. This guide maps each Apideck concept to its Unified.to equivalent. For the overall plan and checklist, see [Migrate to Unified.to](/migrate). ## Concepts | Apideck | Unified.to | | --------------------------------------------------- | -------------------------------------------------------------------- | | Application (`x-apideck-app-id`) | Workspace | | Consumer (`x-apideck-consumer-id`) | Your own customer ID, stored on each connection as `external_xref` | | Connection (consumer + unified API + service ID) | Connection, with its own connection ID | | Service ID (e.g. `hubspot`) | Integration type (e.g. `hubspot`) | | Unified API (`crm`, `hris`, `ats`, `accounting`, …) | Category (`crm`, `hris`, `ats`, `accounting`, …) | | Vault | [Authorization component](/concepts/embedded-components) or auth URL | | `raw=true` / `_raw` | `fields=raw` / `raw` ([Custom & original fields](/reference/fields)) | | `pass_through` / Proxy API | [Passthrough API](/passthrough/overview) | | Webhooks and virtual webhooks | [Native and virtual webhooks](/reference/webhooks) | The biggest shift: Apideck identifies an account by a **consumer ID plus a service ID**, while Unified.to gives each authorized account its own **connection ID**. A customer who connects both HubSpot and Salesforce has two connection IDs. Store them next to your customer record, where you use the consumer ID today. ## Authentication Apideck sends four headers on each request. Unified.to needs only your API key; the connection is part of the URL. ```http # Apideck GET https://unify.apideck.com/crm/contacts Authorization: Bearer {APIDECK_API_KEY} x-apideck-app-id: {APP_ID} x-apideck-consumer-id: {CONSUMER_ID} x-apideck-service-id: hubspot # Unified.to GET https://api.unified.to/crm/{connection_id}/contact Authorization: Bearer {UNIFIED_API_KEY} ``` Use `https://api-eu.unified.to` or `https://api-au.unified.to` if your workspace is in the [EU or Australia region](/reference/rest#api-urls). ## Authorizing end users With Apideck, your backend creates a Vault session (`POST /vault/sessions`) and your frontend opens it with `@apideck/vault-js`. With Unified.to, there's no session to create: embed the Authorization component, or redirect the user to the auth URL, and the user returns to your `success_redirect` with the new connection ID. ```html
        ``` In the embed script, `cat` limits the list to one category, `uid` sets `external_xref`, and `surl` / `furl` set the success and failure redirects. To build your own UI instead, send users to the auth URL for a specific integration: ```text https://api.unified.to/unified/integration/auth/{WORKSPACE_ID}/hubspot ?success_redirect=https://yourapp.com/integrations/callback &failure_redirect=https://yourapp.com/integrations/error &external_xref=customer_123 &scopes=crm_contact_read,crm_contact_write &redirect=1 ``` On success, the callback URL receives `id` (the connection ID), `state`, `nonce`, `type` and `sig`. [Verify the signature](/concepts/embedded-components#signature-calculation), then store the connection ID. See [Authorization](/concepts/embedded-components) for framework components and all options. ## Moving existing connections Apideck Vault holds your customers' credentials, so you have two options: - **Re-authorize** each customer through the Authorization component. You can roll this out gradually: route new connections to Unified.to and prompt existing customers to reconnect. - **Import credentials** you hold, such as tokens issued to your own OAuth app or API keys your customers entered, with [Create connection](/unified/connection/Create_connection). See [Move your connections](/migrate#_3-move-your-connections). If you configured your own OAuth client ID and secret in Apideck for an integration, enter the same credentials in Unified.to and add Unified.to's redirect URI to that OAuth app. ## Making API calls Apideck resources are plural (`/crm/contacts`), while Unified.to objects are singular and scoped to a connection (`/crm/{connection_id}/contact`). ```bash # Apideck curl https://unify.apideck.com/hris/employees?limit=50 \ -H "Authorization: Bearer $APIDECK_API_KEY" \ -H "x-apideck-app-id: $APP_ID" \ -H "x-apideck-consumer-id: $CONSUMER_ID" \ -H "x-apideck-service-id: bamboohr" # Unified.to curl "https://api.unified.to/hris/$CONNECTION_ID/employee?limit=50" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` With the SDKs: ```typescript // Apideck import { Apideck } from '@apideck/unify'; const apideck = new Apideck({ apiKey: process.env.APIDECK_API_KEY, appId: APP_ID, consumerId: CONSUMER_ID, }); const contacts = await apideck.crm.contacts.list({ serviceId: 'hubspot', limit: 50 }); // Unified.to import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY } }); const contacts = await unified.crm.listCrmContacts({ connectionId, limit: 50 }); ``` ### Response shape Apideck wraps results in an envelope (`status_code`, `status`, `service`, `resource`, `operation`, `data`, `meta`, `links`). Unified.to returns the data itself: a JSON array for list calls and an object for single-record calls. Errors use standard [HTTP status codes](/reference/rest#common-http-status-codes). ### Common objects | Apideck | Unified.to | | ----------------------- | ------------------------------------- | | `/crm/contacts` | `/crm/{connection_id}/contact` | | `/crm/companies` | `/crm/{connection_id}/company` | | `/crm/opportunities` | `/crm/{connection_id}/deal` | | `/crm/leads` | `/crm/{connection_id}/lead` | | `/hris/employees` | `/hris/{connection_id}/employee` | | `/hris/companies` | `/hris/{connection_id}/company` | | `/ats/applicants` | `/ats/{connection_id}/candidate` | | `/ats/jobs` | `/ats/{connection_id}/job` | | `/ats/applications` | `/ats/{connection_id}/application` | | `/accounting/invoices` | `/accounting/{connection_id}/invoice` | | `/accounting/customers` | `/accounting/{connection_id}/contact` | Field names differ between the two models, so update your mappers object by object using the [API Reference](/unified/overview). ## Pagination and filtering | Apideck | Unified.to | | ---------------------------------- | ------------------------------------------------------------ | | `limit` (max 200) | `limit` (default 100) | | `cursor`, with `meta.cursors.next` | `offset`; you're done when a page returns fewer than `limit` | | `filter[updated_since]` | `updated_gte` | | `filter[email]`, `filter[name]` | `query` | | `sort[by]`, `sort[direction]` | `sort`, `order` | | `fields` | `fields` | ```typescript // Read every employee updated since the last run let offset = 0; const limit = 100; while (true) { const page = await unified.hris.listHrisEmployees({ connectionId, limit, offset, updatedGte: lastRun, }); await save(page); if (page.length < limit) break; offset += limit; } ``` See [Pagination, filtering & sorting](/reference/pagination). ## Vendor-specific data Replace `raw=true` with `raw` in the `fields` parameter. The original record comes back in each object's `raw` property: ```http GET /crm/{connection_id}/contact?fields=id,name,emails,raw ``` For custom fields that must be requested explicitly, add them with a `raw.` prefix, such as `fields=raw,raw.leadFunnelStage`. See [Custom & original fields](/reference/fields). ## Passthrough and proxy Apideck's Proxy API takes the downstream URL in `x-apideck-downstream-url`. Unified.to's Passthrough API takes the vendor path in the URL and uses the HTTP method you call it with: ```bash # Apideck curl -X GET https://unify.apideck.com/proxy \ -H "Authorization: Bearer $APIDECK_API_KEY" \ -H "x-apideck-app-id: $APP_ID" \ -H "x-apideck-consumer-id: $CONSUMER_ID" \ -H "x-apideck-service-id: hubspot" \ -H "x-apideck-downstream-url: https://api.hubapi.com/crm/v3/objects/tickets" # Unified.to curl "https://api.unified.to/passthrough/$CONNECTION_ID/crm/v3/objects/tickets" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` Unified.to adds the vendor's base URL and authentication. If you use Apideck's `pass_through` body parameter to set extra vendor fields on a write, send those fields in `raw` on the Unified.to request instead. See [Passthrough](/passthrough/overview). ## Webhooks Apideck subscriptions are per application and list event types such as `crm.contact.updated`; the payload identifies the changed entity. Unified.to webhooks are per connection and object, and the payload carries the changed records themselves, so you don't need a follow-up read. | Apideck event | Unified.to webhook | | ----------------------- | ---------------------------------------------- | | `crm.contact.created` | `object_type: crm_contact`, `event: created` | | `crm.contact.updated` | `object_type: crm_contact`, `event: updated` | | `crm.contact.deleted` | `object_type: crm_contact`, `event: deleted` | | `hris.employee.updated` | `object_type: hris_employee`, `event: updated` | | `ats.applicant.created` | `object_type: ats_candidate`, `event: created` | Create one per connection, typically right after the connection is authorized: ```bash curl -X POST https://api.unified.to/unified/webhook \ -H "Authorization: Bearer $UNIFIED_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "connection_id": "'$CONNECTION_ID'", "object_type": "crm_contact", "event": "updated", "hook_url": "https://yourapp.com/webhooks/unified", "interval": 60 }' ``` Like Apideck, Unified.to uses native webhooks where the vendor has them and [virtual webhooks](/concepts/virtual_webhooks) where it doesn't; `interval` sets how often, in minutes, virtual webhooks check for changes. **Signature verification.** Replace your `x-apideck-signature` check with a check of the `sig256` field in the payload body: `HMAC-SHA256(workspace secret, JSON of data + nonce)`, base64-encoded. Code samples are in [Webhooks](/reference/webhooks#best-practices-for-signature-validation). ## Deleting connections Replace `DELETE /vault/connections/{unified_api}/{service_id}` with `DELETE /unified/connection/{connection_id}`. ## Next steps - Follow the [migration plan and checklist](/migrate#migration-plan) - Browse [Supported integrations](/integrations) to confirm coverage - Questions? Email [hello@unified.to](mailto:hello@unified.to) or join our [Discord](https://discord.gg/2nsAPmbx) ## Migrate from Finch to Unified.to URL: https://docs.unified.to/migrate/finch # Migrate from Finch to Unified.to Finch focuses on HRIS, payroll and benefits. Unified.to covers the same employee, payroll and benefit data in its [HRIS category](/hris/overview), and uses the same connection and API model for more than 25 other categories, such as ATS, CRM and accounting. Two things change the most. First, Finch issues an access token per connection, while Unified.to uses one workspace API key and puts the connection ID in the URL. Second, Finch serves reads from its most recent sync of each provider (every 24 hours for automated integrations), while Unified.to is [real-time pass-through](/concepts/architecture): each request goes to the provider and returns current data, and no employee data is stored on our side. This guide maps each Finch concept to its Unified.to equivalent. For the overall plan and checklist, see [Migrate to Unified.to](/migrate). ## Concepts | Finch | Unified.to | | ---------------------------------------------- | --------------------------------------------------------------------------- | | Connection, with a per-connection access token | Connection, with a connection ID; one workspace API key for all connections | | `customer_id` | `external_xref` | | Provider (e.g. `gusto`, `bamboohr`) | Integration type (e.g. `gusto`, `bamboohr`) | | Products (`directory`, `individual`, …) | [Scopes](/concepts/scopes) (`hris_employee_read`, `hris_payslip_read`, …) | | Finch Connect | [Authorization component](/concepts/embedded-components) or auth URL | | Data syncs and `POST /jobs/automated` | Not needed: reads are live | | Webhooks | [Native and virtual webhooks](/reference/webhooks) | | Request forwarding (`POST /forward`) | [Passthrough API](/passthrough/overview) | ## What changes without a sync - **No initial sync.** Finch pulls historical data before a new connection is fully usable. A Unified.to connection can be read as soon as the user authorizes it. - **Current data on every read.** You no longer need `Finch-Data-Retrieved` to judge freshness, or refresh jobs to get newer data. - **Reads call the provider.** Payroll providers often have tight rate limits. If you read the same data repeatedly, keep a copy in your own database and update it with `updated_gte` or webhooks. - **Assisted integrations.** Finch's assisted integrations are run manually by Finch's team. Unified.to integrations are all API-based. Check [Supported integrations](/hris/integrations) for each provider you rely on, and [contact us](https://unified.to/contact) about any gaps. ## Authentication Finch requires a per-connection access token and an API version header. Unified.to needs your workspace API key; the connection is part of the URL. ```http # Finch GET https://api.tryfinch.com/employer/directory Authorization: Bearer {CONNECTION_ACCESS_TOKEN} Finch-API-Version: 2020-09-17 # Unified.to GET https://api.unified.to/hris/{connection_id}/employee Authorization: Bearer {UNIFIED_API_KEY} ``` You'll no longer store a secret per customer, only a connection ID, which is useless without your workspace API key. ## Authorizing end users Finch Connect takes three steps: your backend creates a session (`POST /connect/sessions`), your frontend opens it with `useFinchConnect`, and your backend exchanges the returned `code` for an access token (`POST /auth/token`). Unified.to takes one: embed the Authorization component, or redirect the user to the auth URL. The user comes back to your `success_redirect` with the connection ID in the URL, and there's no code to exchange. ```jsx // Before: Finch Connect import { useFinchConnect } from '@tryfinch/react-connect'; const { open } = useFinchConnect({ onSuccess: ({ code }) => exchangeCodeOnYourServer(code), // POST /auth/token }); open({ sessionId }); // sessionId from POST /connect/sessions // After: Unified.to Authorization component import UnifiedDirectory from '@unified-api/react-directory'; ; ``` Or, without a framework: ```html
        ``` In the embed script, `cat` limits the list to one category, `uid` sets `external_xref` (the equivalent of Finch's `customer_id`), and `surl` / `furl` set the success and failure redirects. The dashboard's **Embedded components** page generates the exact code for each framework. If you pass `integration` to Finch Connect to send users straight to one provider, use the auth URL for that integration instead: ```text https://api.unified.to/unified/integration/auth/{WORKSPACE_ID}/gusto ?success_redirect=https://yourapp.com/integrations/callback &failure_redirect=https://yourapp.com/integrations/error &external_xref=customer_123 &scopes=hris_employee_read,hris_payslip_read &redirect=1 ``` On success, the callback URL receives `id` (the connection ID), `state`, `nonce`, `type` and `sig`. [Verify the signature](/concepts/embedded-components#signature-calculation), then store the connection ID where you store the Finch access token today. ## Moving existing connections Finch access tokens are issued for Finch's API and can't be used with Unified.to, so customers need to **re-authorize** through the Authorization component. You can do this gradually: send new customers through Unified.to and ask existing ones to reconnect when they next visit your integrations page, then call Finch's `POST /disconnect` for each migrated customer. For providers where you hold the credentials yourself, such as API keys your customers gave you, **import** them with [Create connection](/unified/connection/Create_connection). See [Move your connections](/migrate#_3-move-your-connections). ## Making API calls Finch splits employee data across several endpoints: the directory lists people, and `/employer/individual` and `/employer/employment` take batches of IDs in a POST body. Unified.to returns personal and employment details together on one employee object, so a single paginated `GET` replaces all three. ```bash # Finch: list people, then fetch details in batches curl "https://api.tryfinch.com/employer/directory?limit=100&offset=0" \ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Finch-API-Version: 2020-09-17" curl -X POST https://api.tryfinch.com/employer/employment \ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Finch-API-Version: 2020-09-17" \ -H "Content-Type: application/json" \ -d '{ "requests": [{ "individual_id": "…" }] }' # Unified.to: one call curl "https://api.unified.to/hris/$CONNECTION_ID/employee?limit=100&offset=0" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` ```typescript // Finch import Finch from '@tryfinch/finch-api'; const finch = new Finch({ accessToken }); for await (const person of finch.hris.directory.list()) { // …then individuals.retrieveMany / employments.retrieveMany } // Unified.to import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY } }); const employees = await unified.hris.listHrisEmployees({ connectionId, limit: 100, offset: 0 }); ``` ### Common objects | Finch | Unified.to | | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `GET /employer/company` | `GET /hris/{connection_id}/company` | | `GET /employer/directory` | `GET /hris/{connection_id}/employee` | | `POST /employer/individual` | `GET /hris/{connection_id}/employee/{id}` (or the list above) | | `POST /employer/employment` | Same employee object: `title`, `employment_status`, `employment_type`, `hired_at`, `terminated_at`, `compensation`, `manager_id` | | `GET /employer/payment` + `POST /employer/pay-statement` | `GET /hris/{connection_id}/payslip` | | `GET /employer/benefits` | `GET /hris/{connection_id}/benefit` | | Benefit enrollments and deductions | `/hris/{connection_id}/deduction` | | Locations and departments on the employment object | `/hris/{connection_id}/location`, `/hris/{connection_id}/group` | Finch's `payment` and `pay_statement` split a pay run from each employee's statement. Unified.to's payslip is per employee and pay period; filter it by `user_id` for one employee. Field names differ between the two models, so update your mappers object by object using the [API Reference](/hris/overview). ## Pagination and filtering | Finch | Unified.to | | ------------------------------------------- | ------------------------------------------------------------ | | `limit` / `offset` on `/employer/directory` | `limit` / `offset` on every list endpoint | | Batched `requests` arrays in POST bodies | Paginated `GET` lists, or get by ID | | `start_date` / `end_date` on payments | `updated_gte` on payslips (see each object's filters) | | `entity_ids` | `company_id` filter where the provider has several companies | See [Pagination, filtering & sorting](/reference/pagination). ## Custom fields and raw data Finch returns custom fields through its own model where available. Unified.to returns the provider's original record in `raw` when you ask for it: ```http GET /hris/{connection_id}/employee?fields=id,name,compensation,raw ``` See [Custom & original fields](/reference/fields). ## Request forwarding Finch's `POST /forward` takes the method and route in the body. Unified.to's Passthrough API takes the provider path in the URL and uses the HTTP method you call it with: ```bash # Finch curl -X POST https://api.tryfinch.com/forward \ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Finch-API-Version: 2020-09-17" \ -H "Content-Type: application/json" \ -d '{ "method": "GET", "route": "/v1/companies" }' # Unified.to curl "https://api.unified.to/passthrough/$CONNECTION_ID/v1/companies" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` Unified.to adds the provider's base URL and authentication, and returns the provider's response unchanged. See [Passthrough](/passthrough/overview). ## Webhooks Finch fires webhooks after each sync. Unified.to webhooks are created per connection and object, and each delivery carries the changed records. | Finch event | Unified.to | | -------------------------------------------------------- | ------------------------------------------------------------------------------- | | `directory.created`, `individual.created` | `object_type: hris_employee`, `event: created` | | `employment.updated`, `individual.updated` | `object_type: hris_employee`, `event: updated` | | `directory.deleted` | `object_type: hris_employee`, `event: deleted`, where supported | | `pay_statement.created`, `payment.created` | `object_type: hris_payslip`, `event: created` | | `company.updated` | `object_type: hris_company`, `event: updated` | | `job.data_sync_all.completed`, `job.initial_data_sync_*` | Not needed; the first delivery is marked `INITIAL-PARTIAL` / `INITIAL-COMPLETE` | ```bash curl -X POST https://api.unified.to/unified/webhook \ -H "Authorization: Bearer $UNIFIED_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "connection_id": "'$CONNECTION_ID'", "object_type": "hris_employee", "event": "updated", "hook_url": "https://yourapp.com/webhooks/unified", "interval": 60 }' ``` Most HRIS and payroll providers don't offer webhooks, so Unified.to uses [virtual webhooks](/concepts/virtual_webhooks) for them and checks for changes every `interval` minutes, rather than once a day. **Signature verification.** Replace your `Finch-Signature` check with a check of the `sig256` field in the payload body: `HMAC-SHA256(workspace secret, JSON of data + nonce)`, base64-encoded. See [Webhooks](/reference/webhooks#best-practices-for-signature-validation). ## Disconnecting Replace `POST /disconnect` with `DELETE /unified/connection/{connection_id}`. ## Next steps - Follow the [migration plan and checklist](/migrate#migration-plan) - Browse [Supported HRIS integrations](/hris/integrations) to confirm coverage - Questions? Email [hello@unified.to](mailto:hello@unified.to) or join our [Discord](https://discord.gg/2nsAPmbx) ## Migrate from Kombo to Unified.to URL: https://docs.unified.to/migrate/kombo # Migrate from Kombo to Unified.to Kombo covers HRIS, ATS, assessment and LMS. Unified.to covers the same categories, plus more than 25 others such as CRM, accounting and ticketing, with the same connection and API model, so adding a new category later doesn't mean adding another vendor. The architectural difference matters most. Kombo syncs data from each source system into its own database on a schedule and serves your reads from that copy. Unified.to is [real-time pass-through](/concepts/architecture): every request goes to the source API, and no end-customer data is stored on our side. This guide maps each Kombo concept to its Unified.to equivalent. For the overall plan and checklist, see [Migrate to Unified.to](/migrate). ## Concepts | Kombo | Unified.to | | ---------------------------------------------------- | -------------------------------------------------------------------------------- | | Integration (`X-Integration-Id`) | Connection, identified by a connection ID in the URL path | | Tool (e.g. `bamboohr`, `greenhouse`) | Integration type (e.g. `bamboohr`, `greenhouse`) | | Category (`hris`, `ats`, `assessment`, `lms`) | Category (`hris`, `ats`, `assessment`, `lms`, and more) | | `end_user_origin_id` | `external_xref` | | Kombo Connect | [Authorization component](/concepts/embedded-components) or auth URL | | Scheduled syncs and `force-sync` | Not needed: reads are live | | `data-changed` webhook | [Native and virtual webhooks](/reference/webhooks) per object | | `remote_data`, `custom_fields`, `integration_fields` | `raw` ([Custom & original fields](/reference/fields)) | | Passthrough (`/passthrough/{tool}/{api}`) | [Passthrough API](/passthrough/overview) (`/passthrough/{connection_id}/{path}`) | ## What changes without a sync Because Kombo reads come from its database, your code may rely on sync behavior. Plan for these differences: - **No setup wait.** A Kombo integration isn't fully usable until its first sync finishes. A Unified.to connection can be read as soon as the user authorizes it. - **No force-sync.** Replace calls to `POST /v1/force-sync` with a direct read, or with a webhook's initial data load. - **Writes are immediate.** A Unified.to write goes to the source API in the same request, and the response reflects the source's result. - **Rate limits are the source's.** Each read calls the source API. Keep a local copy in your own database if you read the same data often, and keep it current with `updated_gte` or webhooks. - **Deletions.** Kombo marks deleted records with `remote_deleted_at` after a full sync. With Unified.to, subscribe to `deleted` webhooks where the integration supports them, or compare IDs in periodic full reads. ## Authentication ```http # Kombo GET https://api.kombo.dev/v1/hris/employees Authorization: Bearer {KOMBO_API_KEY} X-Integration-Id: {INTEGRATION_ID} # Unified.to GET https://api.unified.to/hris/{connection_id}/employee Authorization: Bearer {UNIFIED_API_KEY} ``` Kombo's default host is in the EU. If you need your data processed in the EU, create your Unified.to workspace in the EU region and use `https://api-eu.unified.to`. See [API URLs](/reference/rest#api-urls). ## Authorizing end users Kombo Connect takes three steps: your backend creates a link (`POST /v1/connect/create-link`), your frontend opens it with `showKomboConnect(link)`, and your backend exchanges the returned token for an integration ID (`GET /v1/connect/integration-by-token/{token}`). Unified.to takes one: embed the Authorization component, or redirect the user to the auth URL. The user comes back to your `success_redirect` with the connection ID already in the URL. ```html
        ``` In the embed script, `cat` limits the list to one category, `uid` sets `external_xref` (the equivalent of `end_user_origin_id`), and `surl` / `furl` set the success and failure redirects. To use your own integration picker, which is the equivalent of passing `integration_tool` to Kombo, send users to the auth URL for that integration: ```text https://api.unified.to/unified/integration/auth/{WORKSPACE_ID}/bamboohr ?success_redirect=https://yourapp.com/integrations/callback &failure_redirect=https://yourapp.com/integrations/error &external_xref=customer_123 &scopes=hris_employee_read,hris_group_read &redirect=1 ``` On success, the callback URL receives `id` (the connection ID), `state`, `nonce`, `type` and `sig`. [Verify the signature](/concepts/embedded-components#signature-calculation), then store the connection ID where you store the Kombo integration ID today. ## Moving existing connections Kombo stores your customers' credentials, so most teams **re-authorize**: send existing customers through the Authorization component, either all at once or the next time they visit your integrations page. Match returning users to their account with `external_xref`. For integrations where you hold the credentials yourself, such as API keys your customers gave you or tokens issued to your own OAuth app, **import** them with [Create connection](/unified/connection/Create_connection) so the customer doesn't have to reconnect. See [Move your connections](/migrate#_3-move-your-connections). ## Making API calls ```bash # Kombo curl "https://api.kombo.dev/v1/ats/candidates?page_size=100" \ -H "Authorization: Bearer $KOMBO_API_KEY" \ -H "X-Integration-Id: $INTEGRATION_ID" # Unified.to curl "https://api.unified.to/ats/$CONNECTION_ID/candidate?limit=100" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY } }); const candidates = await unified.ats.listAtsCandidates({ connectionId, limit: 100 }); ``` ### Response shape Kombo returns `{ "status": "success", "data": { "results": [...], "next": "…" } }`. Unified.to returns the list itself as a JSON array, and a single object for single-record calls. Errors use standard [HTTP status codes](/reference/rest#common-http-status-codes). ### Common objects | Kombo | Unified.to | | ---------------------- | ---------------------------------- | | `/hris/employees` | `/hris/{connection_id}/employee` | | `/hris/groups` | `/hris/{connection_id}/group` | | `/hris/absences` | `/hris/{connection_id}/timeoff` | | `/hris/legal-entities` | `/hris/{connection_id}/company` | | `/hris/locations` | `/hris/{connection_id}/location` | | `/hris/payslips` | `/hris/{connection_id}/payslip` | | `/ats/candidates` | `/ats/{connection_id}/candidate` | | `/ats/applications` | `/ats/{connection_id}/application` | | `/ats/jobs` | `/ats/{connection_id}/job` | | `/ats/interviews` | `/ats/{connection_id}/interview` | | `/ats/scorecards` | `/ats/{connection_id}/scorecard` | Field names differ between the two models, so update your mappers object by object using the [API Reference](/unified/overview). ## Pagination and filtering | Kombo | Unified.to | | -------------------------- | ------------------------------------------------------------ | | `page_size` (max 250) | `limit` (default 100) | | `cursor`, with `data.next` | `offset`; you're done when a page returns fewer than `limit` | | `updated_after` | `updated_gte` | | `ids` | Get the record by ID: `/hris/{connection_id}/employee/{id}` | | `include_deleted` | `deleted` webhooks where supported | ```typescript let offset = 0; const limit = 100; while (true) { const page = await unified.hris.listHrisEmployees({ connectionId, limit, offset, updatedGte: lastRun, }); await save(page); if (page.length < limit) break; offset += limit; } ``` See [Pagination, filtering & sorting](/reference/pagination). ## Custom fields and raw data Kombo exposes vendor data through `custom_fields`, `integration_fields` and, when enabled for your account, `remote_data`. Unified.to returns the vendor's original record in `raw` when you ask for it, with no account setting needed: ```http GET /hris/{connection_id}/employee?fields=id,name,emails,raw ``` To write a vendor-specific field, include it in `raw` on the create or update request. See [Custom & original fields](/reference/fields). ## Passthrough Kombo's passthrough endpoint names the tool and API in the URL and takes the method and path in the body. Unified.to's takes the vendor path in the URL and uses the HTTP method you call it with: ```bash # Kombo curl -X POST https://api.kombo.dev/v1/passthrough/bamboohr/v1 \ -H "Authorization: Bearer $KOMBO_API_KEY" \ -H "X-Integration-Id: $INTEGRATION_ID" \ -H "Content-Type: application/json" \ -d '{ "method": "GET", "path": "/meta/fields" }' # Unified.to curl "https://api.unified.to/passthrough/$CONNECTION_ID/meta/fields" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` Unified.to adds the vendor's base URL and authentication. See [Passthrough](/passthrough/overview). ## Webhooks Kombo's `data-changed` webhook tells you that an integration's data changed after a sync, and you then read the changes from Kombo. Unified.to webhooks are created per connection and object, and each delivery carries the changed records, so there's no follow-up read. | Kombo | Unified.to | | --------------------------- | --------------------------------------------------------------------------- | | `data-changed` (employees) | Webhook with `object_type: hris_employee`, `event: updated` / `created` | | `data-changed` (candidates) | Webhook with `object_type: ats_candidate`, `event: updated` / `created` | | `sync-finished` | Not needed; first delivery is marked `INITIAL-PARTIAL` / `INITIAL-COMPLETE` | | `integration-created` | Your `success_redirect` receives the new connection ID | ```bash curl -X POST https://api.unified.to/unified/webhook \ -H "Authorization: Bearer $UNIFIED_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "connection_id": "'$CONNECTION_ID'", "object_type": "hris_employee", "event": "updated", "hook_url": "https://yourapp.com/webhooks/unified", "interval": 60 }' ``` Integrations with native webhooks deliver changes as they happen. For the rest, [virtual webhooks](/concepts/virtual_webhooks) check for changes every `interval` minutes. A webhook can also backfill existing records, which replaces Kombo's initial sync. **Signature verification.** Replace your `X-Kombo-Signature` check with a check of the `sig256` field in the payload body: `HMAC-SHA256(workspace secret, JSON of data + nonce)`, base64-encoded. See [Webhooks](/reference/webhooks#best-practices-for-signature-validation). ## Deleting connections Replace `DELETE /v1/integrations/{integration_id}` with `DELETE /unified/connection/{connection_id}`. Because Unified.to holds no end-customer data, there's no retained copy to wait on after deletion. ## Next steps - Follow the [migration plan and checklist](/migrate#migration-plan) - Browse [Supported integrations](/integrations) to confirm coverage - Questions? Email [hello@unified.to](mailto:hello@unified.to) or join our [Discord](https://discord.gg/2nsAPmbx) ## Migrate from Merge to Unified.to URL: https://docs.unified.to/migrate/merge # Migrate from Merge to Unified.to Merge and Unified.to both normalize third-party APIs into shared data models, and the categories overlap: HRIS, ATS, CRM, accounting, ticketing, file storage, knowledge base and chat all have Unified.to equivalents, alongside more than 20 other categories. The biggest difference is architecture. Merge syncs data from each third-party API on a recurring schedule, and your reads return Merge's synced copy. Unified.to is [real-time pass-through](/concepts/architecture): every request goes to the source API and returns current data, and no end-customer data is stored on our side. This guide maps each Merge concept to its Unified.to equivalent. For the overall plan and checklist, see [Migrate to Unified.to](/migrate). ## Concepts | Merge | Unified.to | | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | Linked account (`X-Account-Token`) | Connection, identified by a connection ID in the URL path | | Integration (e.g. `bamboohr`) | Integration type (e.g. `bamboohr`) | | Category (`hris`, `ats`, `crm`, `accounting`, `ticketing`, `filestorage`, `knowledgebase`) | Category (`hris`, `ats`, `crm`, `accounting`, `ticketing`, `storage`, `kms`, and more) | | Common model | Unified object ([API Reference](/unified/overview)) | | `end_user_origin_id` | `external_xref` | | Merge Link | [Authorization component](/concepts/embedded-components) or auth URL | | Sync frequency | Not applicable: reads are live | | `remote_data`, field mappings | `raw` ([Custom & original fields](/reference/fields)) | | Passthrough (`POST /api/{category}/v1/passthrough`) | [Passthrough API](/passthrough/overview) (`/passthrough/{connection_id}/{path}`) | | Webhooks (`{Model}.added`, `.changed`, `.removed`) | [Native and virtual webhooks](/reference/webhooks) (`created`, `updated`, `deleted`) | ## What changes without a sync Code built on Merge often assumes its sync behavior. Plan for these differences: - **No initial sync.** A new Merge linked account isn't fully readable until its first sync completes (`LinkedAccount.sync_completed`). A Unified.to connection can be read as soon as the user authorizes it. - **Current data on every read.** You don't need to reason about sync frequency or when Merge last synced. `modified_after` in Merge filters on when Merge synced a record; `updated_gte` in Unified.to filters on when the record changed in the source. - **Writes are immediate.** A Unified.to write goes to the source API in the same request, and the response reflects the source's result. - **Reads call the source.** If you read the same data often, keep a copy in your own database and keep it current with `updated_gte` or webhooks. ## Authentication Merge needs your API key and the linked account's token on each request. Unified.to needs only your API key; the connection is part of the URL. ```http # Merge GET https://api.merge.dev/api/hris/v1/employees Authorization: Bearer {MERGE_API_KEY} X-Account-Token: {ACCOUNT_TOKEN} # Unified.to GET https://api.unified.to/hris/{connection_id}/employee Authorization: Bearer {UNIFIED_API_KEY} ``` | Merge host | Unified.to host | | --------------------------------- | --------------------------------------- | | `https://api.merge.dev` (US) | `https://api.unified.to` | | `https://api-eu.merge.dev` (EU) | `https://api-eu.unified.to` | | `https://api-ap.merge.dev` (APAC) | `https://api-au.unified.to` (Australia) | Your workspace must be created in the region you call. See [API URLs](/reference/rest#api-urls). ## Authorizing end users Merge Link takes three steps: your backend creates a link token (`POST /api/integrations/create-link-token`), your frontend opens Merge Link with `useMergeLink`, and your backend exchanges the returned public token for an account token (`GET /api/integrations/account-token/{public_token}`). Unified.to takes one: embed the Authorization component, or redirect the user to the auth URL. The user comes back to your `success_redirect` with the connection ID in the URL, and there's no token to exchange. ```jsx // Before: Merge Link import { useMergeLink } from '@mergeapi/react-merge-link'; const { open, isReady } = useMergeLink({ linkToken, // from POST /api/integrations/create-link-token onSuccess: (publicToken) => exchangeOnYourServer(publicToken), // GET /api/integrations/account-token/{public_token} }); // After: Unified.to Authorization component import UnifiedDirectory from '@unified-api/react-directory'; ; ``` Or, without a framework: ```html
        ``` In the embed script, `cat` limits the list to one category, `uid` sets `external_xref` (the equivalent of `end_user_origin_id`), and `surl` / `furl` set the success and failure redirects. If you pass `integration` when creating a link token to skip Merge Link's integration picker, use the auth URL for that integration instead: ```text https://api.unified.to/unified/integration/auth/{WORKSPACE_ID}/bamboohr ?success_redirect=https://yourapp.com/integrations/callback &failure_redirect=https://yourapp.com/integrations/error &external_xref=customer_123 &scopes=hris_employee_read,hris_group_read &redirect=1 ``` On success, the callback URL receives `id` (the connection ID), `state`, `nonce`, `type` and `sig`. [Verify the signature](/concepts/embedded-components#signature-calculation), then store the connection ID where you store the Merge account token today. Unlike an account token, a connection ID isn't a secret on its own: it only works with your workspace API key. ## Moving existing connections Merge holds your customers' credentials, so you have two options: - **Re-authorize** each customer through the Authorization component. Most teams roll this out gradually: new customers connect through Unified.to, and existing customers are asked to reconnect the next time they visit your integrations page. - **Import credentials** you hold, such as tokens issued to your own OAuth app or API keys your customers gave you, with [Create connection](/unified/connection/Create_connection), so the customer doesn't have to reconnect. See [Move your connections](/migrate#_3-move-your-connections). If you registered your own OAuth apps with vendors for Merge, enter the same client ID and secret in Unified.to and add Unified.to's redirect URI to each app. ## Making API calls Merge paths include a category version and plural model names (`/api/hris/v1/employees`). Unified.to paths use singular object names scoped to a connection (`/hris/{connection_id}/employee`). ```bash # Merge curl "https://api.merge.dev/api/ats/v1/candidates?page_size=100" \ -H "Authorization: Bearer $MERGE_API_KEY" \ -H "X-Account-Token: $ACCOUNT_TOKEN" # Unified.to curl "https://api.unified.to/ats/$CONNECTION_ID/candidate?limit=100" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` With the SDKs: ```typescript // Merge import { MergeClient } from '@mergeapi/merge-node-client'; const merge = new MergeClient({ apiKey: process.env.MERGE_API_KEY, accountToken }); const employees = await merge.hris.employees.list({ pageSize: 100 }); // Unified.to import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY } }); const employees = await unified.hris.listHrisEmployees({ connectionId, limit: 100 }); ``` Because the connection is a call parameter rather than a client option, one Unified.to client serves all of your connections. ### Response shape Merge returns `{ "next": "…", "previous": "…", "results": [...] }`. Unified.to returns the list itself as a JSON array, and a single object for single-record calls. Errors use standard [HTTP status codes](/reference/rest#common-http-status-codes). ### Common models | Merge | Unified.to | | ---------------------------------------------------- | ----------------------------------------------------------------------- | | `hris/v1/employees` | `hris/{connection_id}/employee` | | `hris/v1/employments` | `compensation` and employment fields on `hris/{connection_id}/employee` | | `hris/v1/groups`, `hris/v1/teams` | `hris/{connection_id}/group` | | `hris/v1/locations` | `hris/{connection_id}/location` | | `hris/v1/companies` | `hris/{connection_id}/company` | | `hris/v1/time-off` | `hris/{connection_id}/timeoff` | | `hris/v1/employee-payroll-runs` | `hris/{connection_id}/payslip` | | `hris/v1/benefits` | `hris/{connection_id}/benefit` | | `ats/v1/candidates` | `ats/{connection_id}/candidate` | | `ats/v1/applications` | `ats/{connection_id}/application` | | `ats/v1/jobs` | `ats/{connection_id}/job` | | `ats/v1/interviews` | `ats/{connection_id}/interview` | | `ats/v1/scorecards` | `ats/{connection_id}/scorecard` | | `crm/v1/contacts` | `crm/{connection_id}/contact` | | `crm/v1/accounts` | `crm/{connection_id}/company` | | `crm/v1/opportunities` | `crm/{connection_id}/deal` | | `crm/v1/leads` | `crm/{connection_id}/lead` | | `crm/v1/engagements`, `crm/v1/notes`, `crm/v1/tasks` | `crm/{connection_id}/event` | | `accounting/v1/invoices` | `accounting/{connection_id}/invoice` | | `accounting/v1/contacts` | `accounting/{connection_id}/contact` | | `accounting/v1/accounts` | `accounting/{connection_id}/account` | | `accounting/v1/journal-entries` | `accounting/{connection_id}/journal` | | `ticketing/v1/tickets` | `ticketing/{connection_id}/ticket` | | `filestorage/v1/files`, `filestorage/v1/folders` | `storage/{connection_id}/file` | | `knowledgebase/v1/articles` | `kms/{connection_id}/page` | Field names differ between the two models, so update your mappers object by object using the [API Reference](/unified/overview). Merge's `remote_id` is the vendor's ID; in Unified.to, the object `id` is usually the vendor's ID, and the original is always available in `raw.__id` when it isn't. ## Pagination and filtering | Merge | Unified.to | | -------------------------------------------- | ----------------------------------------------------------------- | | `page_size` (max 100) | `limit` (default 100) | | `cursor`, with `next` | `offset`; you're done when a page returns fewer than `limit` | | `modified_after` | `updated_gte` | | `expand` | Related IDs are returned on the object; get related objects by ID | | `include_remote_data=true` | `fields=raw` | | `include_deleted_data`, `remote_was_deleted` | `deleted` webhooks where supported | ```typescript let offset = 0; const limit = 100; while (true) { const page = await unified.hris.listHrisEmployees({ connectionId, limit, offset, updatedGte: lastRun, }); await save(page); if (page.length < limit) break; offset += limit; } ``` See [Pagination, filtering & sorting](/reference/pagination). ## Remote data and field mappings Replace `include_remote_data=true` with `raw` in the `fields` parameter. The vendor's original record comes back in each object's `raw` property: ```http GET /hris/{connection_id}/employee?fields=id,name,emails,raw ``` Where you use Merge field mappings to pull custom fields into a common model, request them with a `raw.` prefix, such as `fields=raw,raw.cost_center`, and map them in your code. To write a vendor-specific field, include it in `raw` on the create or update request. See [Custom & original fields](/reference/fields). ## Passthrough Merge's passthrough endpoint is per category and takes the method and path in the body. Unified.to's takes the vendor path in the URL and uses the HTTP method you call it with, and is available for every connection: ```bash # Merge curl -X POST https://api.merge.dev/api/hris/v1/passthrough \ -H "Authorization: Bearer $MERGE_API_KEY" \ -H "X-Account-Token: $ACCOUNT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "method": "GET", "path": "/v1/meta/fields" }' # Unified.to curl "https://api.unified.to/passthrough/$CONNECTION_ID/v1/meta/fields" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` Unified.to adds the vendor's base URL and authentication, and returns the vendor's response unchanged rather than wrapped. See [Passthrough](/passthrough/overview). ## Webhooks Merge webhooks are configured for your organization and fire after syncs. Unified.to webhooks are created per connection and object, and each delivery carries the changed records. | Merge event | Unified.to | | ------------------------------ | ------------------------------------------------------------------------------- | | `Employee.added` | `object_type: hris_employee`, `event: created` | | `Employee.changed` | `object_type: hris_employee`, `event: updated` | | `Employee.removed` | `object_type: hris_employee`, `event: deleted`, where supported | | `Candidate.added` / `.changed` | `object_type: ats_candidate`, `event: created` / `updated` | | `LinkedAccount.linked` | Your `success_redirect` receives the new connection ID | | `LinkedAccount.sync_completed` | Not needed; the first delivery is marked `INITIAL-PARTIAL` / `INITIAL-COMPLETE` | Create one per connection, typically right after the connection is authorized: ```bash curl -X POST https://api.unified.to/unified/webhook \ -H "Authorization: Bearer $UNIFIED_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "connection_id": "'$CONNECTION_ID'", "object_type": "hris_employee", "event": "updated", "hook_url": "https://yourapp.com/webhooks/unified", "interval": 60 }' ``` Integrations with native webhooks deliver changes as they happen. For the rest, [virtual webhooks](/concepts/virtual_webhooks) check for changes every `interval` minutes, independent of any sync schedule. A webhook can also backfill existing records, which replaces waiting for Merge's initial sync. **Signature verification.** Replace your `X-Merge-Webhook-Signature` check with a check of the `sig256` field in the payload body: `HMAC-SHA256(workspace secret, JSON of data + nonce)`, base64-encoded. See [Webhooks](/reference/webhooks#best-practices-for-signature-validation). ## Deleting connections Replace `POST /api/{category}/v1/delete-account` with `DELETE /unified/connection/{connection_id}`. After you cut over, delete each customer's Merge linked account so Merge no longer holds a synced copy of their data. ## Next steps - Follow the [migration plan and checklist](/migrate#migration-plan) - Browse [Supported integrations](/integrations) to confirm coverage - Questions? Email [hello@unified.to](mailto:hello@unified.to) or join our [Discord](https://discord.gg/2nsAPmbx) ## Migrate to Unified.to URL: https://docs.unified.to/migrate # Migrate to Unified.to Moving from another unified API is mostly a translation exercise. The categories, objects and CRUD operations you already use map onto Unified.to one to one, and your end users' third-party accounts stay where they are. What changes is how you identify a connection, how you page through lists, and how you receive changes. Pick your current provider for a side-by-side guide covering authorization, API calls, pagination, webhooks and passthrough: :::grid :::gridhightlight [Migrate from Merge](/migrate/merge) ::: :::gridhightlight [Migrate from Apideck](/migrate/apideck) ::: :::gridhightlight [Migrate from Kombo](/migrate/kombo) ::: :::gridhightlight [Migrate from Finch](/migrate/finch) ::: :::gridhightlight [Migrate from Rutter](/migrate/rutter) ::: ::: Moving from a provider that isn't listed, or from integrations you built in-house? The plan below still applies. [Contact us](https://unified.to/contact) and we'll help you map your current setup. ## What changes Most unified APIs share the same building blocks under different names. This table maps the common ones to Unified.to. Each provider guide has an exact mapping. | Concept | Typical unified API | Unified.to | | ---------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | An end user's authorized account | Linked account, consumer connection, integration, connection | **Connection**, identified by a connection ID | | Selecting the account on a request | A per-account token or ID sent in a header | The connection ID in the URL path: `/{category}/{connection_id}/{object}` | | Authenticating your server | API key, sometimes plus app or account headers | One workspace API key: `Authorization: Bearer {API_KEY}` | | End-user authorization UI | Link / Vault / Connect widget with a token exchange | [Authorization component](/concepts/embedded-components) or auth URL; no token exchange | | Pagination | Cursor | `limit` and `offset` ([Pagination](/reference/pagination)) | | Incremental reads | `modified_after`, `updated_after`, `filter[updated_since]` | `updated_gte` | | Vendor-specific fields | `remote_data`, `_raw`, custom field mappings | `raw` via the `fields` parameter ([Custom & original fields](/reference/fields)) | | Endpoints outside the data model | Passthrough / proxy endpoint | [Passthrough API](/passthrough/overview): `/passthrough/{connection_id}/{path}` | | Change notifications | Webhooks fired after a scheduled sync | [Native and virtual webhooks](/reference/webhooks) that carry the changed records | | Where data lives | Often a synced copy in the provider's database | Nowhere: requests go to the source API in real time ([Architecture](/concepts/architecture)) | ## From a synced copy to real-time Several unified APIs sync your customers' data into their own database on a schedule and serve your reads from that copy. Unified.to is a [real-time pass-through API](/concepts/architecture): every request goes to the source API and returns current data, and no end-customer data is stored at rest on our side. In practice this means: - **There's no initial sync to wait for.** A new connection can be read as soon as authorization finishes. - **Writes reach the source immediately**, and the response reflects the source's result. - **You decide what to store.** If your product needs a local copy, keep it in your own database and keep it current with [webhooks](/reference/webhooks). Webhooks deliver the changed records in the payload, not a pointer to fetch them. - **Reads cost source API calls.** Read incrementally with `updated_gte` or webhooks rather than re-reading full lists on every request. ## Migration plan ### 1. Inventory what you use List the categories, objects, fields and write operations your code depends on, plus every webhook event you consume. Then check each integration's coverage on [Supported integrations](/integrations) or in the integration's **Feature Support** tab in the dashboard. If something you rely on is missing, [tell us](https://unified.to/contact) before you start. Most gaps can be closed by adding the capability to the unified API. ### 2. Set up your workspace 1. Create an account at [app.unified.to](https://app.unified.to/login) in the [data region](/reference/rest#api-urls) you need, and copy your API key and workspace ID from **Settings > API Keys**. 2. Activate the same integrations in your **Production** environment. Use your own OAuth app credentials for each vendor. If you already registered OAuth apps with vendors for your current provider, you can reuse them: add Unified.to's redirect URI to each app and enter the client ID and secret in the dashboard. 3. Request the [scopes](/concepts/scopes) your code needs, and no more. ### 3. Move your connections You have two options, and you can mix them per integration. **Re-authorize.** Add the [Authorization component](/concepts/embedded-components) to your app and ask each customer to reconnect. Pass your own user or account ID as `external_xref` (or `state`) so you can link the new connection ID to the right customer. Plenty of teams do this gradually: new customers go through Unified.to, and existing customers are prompted to reconnect when they next open the integrations page. **Import existing credentials.** If you hold the credentials for a connection, such as OAuth tokens issued to your own OAuth app or an API key the customer gave you, create the connection directly with [Create connection](/unified/connection/Create_connection) and your customer never has to reconnect. Some providers can export tokens on request, but only tokens issued to an OAuth app you own will keep refreshing after you switch. ```bash curl -X POST https://api.unified.to/unified/connection \ -H "Authorization: Bearer $UNIFIED_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "integration_type": "hubspot", "categories": ["crm"], "permissions": ["crm_contact_read", "crm_contact_write"], "external_xref": "customer_123", "auth": { "access_token": "…", "refresh_token": "…", "expiry_date": "2026-10-01T00:00:00Z" } }' ``` The response contains the new connection's `id`. Store it next to your customer record, where you currently keep the other provider's account token or ID. ### 4. Swap your API calls Replace each call with its Unified.to equivalent. The URL pattern is `https://api.unified.to/{category}/{connection_id}/{object}`, so a single client covers every integration in a category. Our [SDKs](/reference/sdks) wrap the same endpoints if you prefer typed clients. Update your field mapping from the other provider's model to the Unified.to model for each object; the [API Reference](/unified/overview) lists every field. Where you read vendor-specific fields, use `raw`. ### 5. Replace sync and webhooks Create a [webhook](/reference/webhooks) for each connection and object you want to track, using `created`, `updated` or `deleted` events. Integrations without native webhooks get [virtual webhooks](/concepts/virtual_webhooks), which check for changes on the interval you set. Webhooks can backfill historical data, so they can also replace an initial sync. Verify each payload's `sig256` signature. ### 6. Run both, then cut over Run Unified.to alongside your current provider for a subset of customers and compare results. Once you're confident, switch your feature flag, move the remaining customers, and delete the connections on your old provider so it stops holding their data. ## Checklist - [ ] Every object, field and write operation you use is supported for your integrations - [ ] Integrations activated in Production with your own OAuth credentials and the right scopes - [ ] Authorization component (or auth URL) live, with `external_xref` set to your customer ID - [ ] Existing connections re-authorized or imported, and connection IDs stored - [ ] API calls, pagination and filters translated - [ ] Field mapping updated, including any `raw` fields - [ ] Webhooks created, signatures verified, and old sync jobs retired - [ ] Old provider's connections deleted after cut-over Questions along the way? Join our [Discord](https://discord.gg/2nsAPmbx) or email [hello@unified.to](mailto:hello@unified.to). ## Migrate from Rutter to Unified.to URL: https://docs.unified.to/migrate/rutter # Migrate from Rutter to Unified.to Rutter covers accounting, commerce, payments, ads and banking. Unified.to covers the same accounting, commerce, payment and ads data, and uses the same connection and API model for HRIS, ATS, CRM and more than 20 other categories. The biggest difference is architecture. Rutter pulls a connection's data when it's created, then syncs it on a schedule (hourly by default) and serves your reads from that synced data. Unified.to is [real-time pass-through](/concepts/architecture): every request goes to the source platform and returns current data, and no end-customer data is stored on our side. Writes are synchronous too, so there are no async jobs to poll. This guide maps each Rutter concept to its Unified.to equivalent. For the overall plan and checklist, see [Migrate to Unified.to](/migrate). ## Concepts | Rutter | Unified.to | | ------------------------------------------------------------ | ------------------------------------------------------------------------------- | | Connection, selected with `?access_token=` | Connection, identified by a connection ID in the URL path | | Platform (e.g. `SHOPIFY`, `QUICKBOOKS`) | Integration type (e.g. `shopify`, `quickbooks`) | | Accounting, Commerce, Payments, Ads | `accounting`, `commerce`, `payment`, `ads` categories | | Rutter Link | [Authorization component](/concepts/embedded-components) or auth URL | | Initial sync, hourly sync, `force_fetch`, manual syncs | Not needed: reads are live | | Async writes (`response_mode`, `/jobs/:id`) | Synchronous writes: the response is the source's result | | `expand=platform_data` | `fields=raw` ([Custom & original fields](/reference/fields)) | | Field-level `passthrough` on writes | `raw` on writes | | `GET /connections/credentials` to call the platform yourself | [Passthrough API](/passthrough/overview): `/passthrough/{connection_id}/{path}` | | Webhooks (`INITIAL_UPDATE`, `ORDER_CREATED`, …) | [Native and virtual webhooks](/reference/webhooks) | ## What changes without a sync Code built on Rutter often assumes its sync behavior. Plan for these differences: - **No initial sync.** A Rutter connection isn't ready until `INITIAL_UPDATE` fires, unless you read with `force_fetch=true` and accept incomplete data. A Unified.to connection can be read as soon as the user authorizes it. - **Current data on every read.** Data is never up to an hour old, and there's no `incremental_sync` or `backfill` call to make. - **Writes are synchronous.** Rutter writes can return `202` with an `async_response` that you poll at `/versioned/jobs/:id`. A Unified.to write goes to the platform in the same request and returns the created or updated record, or the platform's error. - **Reads call the platform.** Accounting and commerce platforms have their own rate limits. If you read the same data often, keep a copy in your own database and keep it current with `updated_gte` or webhooks. ## Authentication Rutter authenticates your organization with Basic auth and selects the connection with an `access_token` query parameter and a version header. Unified.to uses one Bearer API key, and the connection is part of the URL. ```http # Rutter GET https://production.rutterapi.com/versioned/accounting/invoices?access_token={ACCESS_TOKEN} Authorization: Basic {base64(client_id:client_secret)} X-Rutter-Version: 2024-08-31 # Unified.to GET https://api.unified.to/accounting/{connection_id}/invoice Authorization: Bearer {UNIFIED_API_KEY} ``` Unified.to has no API version header. Use `https://api-eu.unified.to` or `https://api-au.unified.to` if your workspace is in the [EU or Australia region](/reference/rest#api-urls), and use the [Sandbox environment](/concepts/sandbox) in place of Rutter's sandbox host. ## Authorizing end users With Rutter Link, your frontend opens Link with your public key, receives a `publicToken` in `onSuccess`, and your backend exchanges it for an access token (`POST /versioned/item/public_token/exchange`). Unified.to has no token to exchange: embed the Authorization component, or redirect the user to the auth URL. The user comes back to your `success_redirect` with the connection ID in the URL. ```jsx // Before: Rutter Link import { useRutterLink } from 'react-rutter-link'; const { open } = useRutterLink({ publicKey: RUTTER_PUBLIC_KEY, onSuccess: (publicToken) => exchangeOnYourServer(publicToken), // POST /versioned/item/public_token/exchange }); // After: Unified.to Authorization component import UnifiedDirectory from '@unified-api/react-directory'; ; ``` Or, without a framework: ```html
        ``` In the embed script, `cat` limits the list to one category, `uid` sets `external_xref` to your customer ID, and `surl` / `furl` set the success and failure redirects. If you call `open({ platform: 'SHOPIFY' })` to skip Rutter's platform picker, use the auth URL for that integration instead. It's also the equivalent of the `link_url` returned by `POST /versioned/connections/create`: ```text https://api.unified.to/unified/integration/auth/{WORKSPACE_ID}/shopify ?success_redirect=https://yourapp.com/integrations/callback &failure_redirect=https://yourapp.com/integrations/error &external_xref=customer_123 &scopes=accounting_order_read,commerce_item_read &redirect=1 ``` On success, the callback URL receives `id` (the connection ID), `state`, `nonce`, `type` and `sig`. [Verify the signature](/concepts/embedded-components#signature-calculation), then store the connection ID where you store the Rutter access token today. Unlike an access token, a connection ID isn't a secret on its own: it only works with your workspace API key. ## Moving existing connections You have two options, and you can mix them per platform: - **Re-authorize** each customer through the Authorization component. Most teams roll this out gradually: new customers connect through Unified.to, and existing customers are asked to reconnect the next time they visit your integrations page. - **Import credentials** with [Create connection](/unified/connection/Create_connection), so the customer doesn't have to reconnect. This works for credentials you hold, such as API keys your customers gave you or OAuth tokens issued to your own OAuth app. Rutter's `GET /versioned/connections/credentials` returns a connection's platform credentials, but not refresh tokens, so an imported OAuth connection will only keep working if you can supply a refresh token. See [Move your connections](/migrate#_3-move-your-connections). If you registered your own OAuth apps with platforms for Rutter, enter the same client ID and secret in Unified.to and add Unified.to's redirect URI to each app. ## Making API calls ```bash # Rutter curl "https://production.rutterapi.com/versioned/accounting/invoices?access_token=$ACCESS_TOKEN&limit=50" \ -u "$RUTTER_CLIENT_ID:$RUTTER_SECRET" \ -H "X-Rutter-Version: 2024-08-31" # Unified.to curl "https://api.unified.to/accounting/$CONNECTION_ID/invoice?limit=50" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` Unified.to also has official [SDKs](/reference/sdks) in seven languages: ```typescript import { UnifiedTo } from '@unified-api/typescript-sdk'; const unified = new UnifiedTo({ security: { jwt: process.env.UNIFIED_API_KEY } }); const invoices = await unified.accounting.listAccountingInvoices({ connectionId, limit: 50 }); ``` ### Response shape Rutter returns the list under a named key with a cursor and connection details, such as `{ "invoices": [...], "next_cursor": "…", "connection": {...} }`. Unified.to returns the list itself as a JSON array, and a single object for single-record calls. Errors use standard [HTTP status codes](/reference/rest#common-http-status-codes). ### Common objects Rutter keeps storefront orders and customers under Commerce. Unified.to returns orders and customers from storefront platforms like Shopify through the accounting category, and products through commerce. | Rutter | Unified.to | | ---------------------------------- | --------------------------------------------------------------------- | | `/accounting/accounts` | `/accounting/{connection_id}/account` | | `/accounting/invoices` | `/accounting/{connection_id}/invoice` | | `/accounting/bills` | `/accounting/{connection_id}/bill` | | `/accounting/accounting-customers` | `/accounting/{connection_id}/contact?type=CUSTOMER` | | `/accounting/vendors` | `/accounting/{connection_id}/contact?type=SUPPLIER` | | `/accounting/journal-entries` | `/accounting/{connection_id}/journal` | | `/accounting/expenses` | `/accounting/{connection_id}/expense` | | `/accounting/purchase-orders` | `/accounting/{connection_id}/purchaseorder` | | `/accounting/sales-orders` | `/accounting/{connection_id}/salesorder` | | `/accounting/balance-sheets` | `/accounting/{connection_id}/balancesheet` | | `/accounting/income-statements` | `/accounting/{connection_id}/profitloss` | | `/accounting/bank-feeds` | `/accounting/{connection_id}/bankfeedaccount`, `bankfeedtransaction` | | `/orders` | `/accounting/{connection_id}/order` | | `/customers` | `/accounting/{connection_id}/contact?type=CUSTOMER` | | `/products` | `/commerce/{connection_id}/item` | | Payouts | `/payment/{connection_id}/payout` | | Subscriptions | `/payment/{connection_id}/subscription` | | Payment transactions | `/payment/{connection_id}/payment`, `/payment/{connection_id}/refund` | Field names differ between the two models, so update your mappers object by object using the [API Reference](/unified/overview), and check each platform's **Feature Support** tab in the dashboard for the objects you rely on. ## Pagination and filtering | Rutter | Unified.to | | ------------------------------------------------------ | ------------------------------------------------------------ | | `limit` (default 50) | `limit` (default 100) | | `cursor`, with `next_cursor` | `offset`; you're done when a page returns fewer than `limit` | | `filter=updated_at >= "…"`, `updated_at_min` (Unix ms) | `updated_gte` (ISO 8601 date) | | `sort` (default `updated_at DESC`) | `sort`, `order` | | `force_fetch=true` | Not needed | ```typescript let offset = 0; const limit = 100; while (true) { const page = await unified.accounting.listAccountingInvoices({ connectionId, limit, offset, updatedGte: lastRun, }); await save(page); if (page.length < limit) break; offset += limit; } ``` See [Pagination, filtering & sorting](/reference/pagination). ## Platform data and passthrough Replace `expand=platform_data` with `raw` in the `fields` parameter. The platform's original record comes back in each object's `raw` property: ```http GET /accounting/{connection_id}/invoice?fields=id,total_amount,status,raw ``` Where you send a root-level `passthrough` object on a Rutter write to set platform-specific fields, send those fields in `raw` on the Unified.to create or update request. See [Custom & original fields](/reference/fields). Rutter has no general proxy endpoint, so to reach platform endpoints it doesn't cover you fetch the credentials and call the platform yourself. Unified.to's Passthrough API does that for you: send the platform path in the URL, with any HTTP method, and Unified.to adds the base URL and authentication. ```bash curl "https://api.unified.to/passthrough/$CONNECTION_ID/admin/api/2024-07/shop.json" \ -H "Authorization: Bearer $UNIFIED_API_KEY" ``` See [Passthrough](/passthrough/overview). ## Writes Rutter writes wrap the record in a named key and may complete asynchronously: ```bash # Rutter: may return 202 with async_response; poll /versioned/jobs/:id curl -X POST "https://production.rutterapi.com/versioned/accounting/invoices?access_token=$ACCESS_TOKEN" \ -u "$RUTTER_CLIENT_ID:$RUTTER_SECRET" \ -H "X-Rutter-Version: 2024-08-31" \ -H "Content-Type: application/json" \ -d '{ "invoice": { … }, "response_mode": "prefer_sync" }' # Unified.to: returns the created invoice curl -X POST "https://api.unified.to/accounting/$CONNECTION_ID/invoice" \ -H "Authorization: Bearer $UNIFIED_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contact_id": "…", "currency": "USD", "lineitems": [ … ] }' ``` Remove your job polling. If you rely on the `job_id` in Rutter's entity webhooks to match events to your own writes, use the `id` in the Unified.to write response instead. ## Webhooks Rutter webhooks are configured for your organization and fire as it syncs. Unified.to webhooks are created per connection and object, and each delivery carries the changed records. | Rutter event | Unified.to | | ------------------------------------- | ------------------------------------------------------------------------------- | | `ORDER_CREATED` / `ORDER_UPDATED` | `object_type: accounting_order`, `event: created` / `updated` | | `INVOICE_CREATED` / `INVOICE_UPDATED` | `object_type: accounting_invoice`, `event: created` / `updated` | | `*_DELETED` | `event: deleted`, where the integration supports it | | `INITIAL_UPDATE`, `HISTORICAL_UPDATE` | Not needed; the first delivery is marked `INITIAL-PARTIAL` / `INITIAL-COMPLETE` | | `INCREMENTAL_SYNC_COMPLETED` | Not needed; each change is delivered as it's detected | | `CONNECTION_AUTHENTICATED` | Your `success_redirect` receives the new connection ID | Create one per connection and object, typically right after the connection is authorized: ```bash curl -X POST https://api.unified.to/unified/webhook \ -H "Authorization: Bearer $UNIFIED_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "connection_id": "'$CONNECTION_ID'", "object_type": "accounting_invoice", "event": "updated", "hook_url": "https://yourapp.com/webhooks/unified", "interval": 60 }' ``` Integrations with native webhooks deliver changes as they happen. For the rest, [virtual webhooks](/concepts/virtual_webhooks) check for changes every `interval` minutes. A webhook can also backfill existing records, which replaces waiting for Rutter's initial sync. **Signature verification.** Replace your `X-Rutter-Signature` check with a check of the `sig256` field in the payload body: `HMAC-SHA256(workspace secret, JSON of data + nonce)`, base64-encoded. See [Webhooks](/reference/webhooks#best-practices-for-signature-validation). ## Deleting connections Replace `DELETE /versioned/connections/:id` with `DELETE /unified/connection/{connection_id}`. After you cut over, delete each customer's Rutter connection so Rutter stops syncing their data. ## Next steps - Follow the [migration plan and checklist](/migrate#migration-plan) - Browse [Supported accounting integrations](/accounting/integrations) and [commerce integrations](/commerce/integrations) to confirm coverage - Questions? Email [hello@unified.to](mailto:hello@unified.to) or join our [Discord](https://discord.gg/2nsAPmbx) --- # Glossary ## Access Token URL: https://docs.unified.to/concepts/glossary/access_token An **access token** is a security credential in OAuth 2 that acts as proof that an application has the authorization to access an API. They are often issued to an application by the authorization server after its end user has successfully authenticated (logged in). Tokens typically contain _claims_, which are information about the token and the end user. In an access token, claims contain information about: - The token itself, such as the URL for the authorization server that issued the token, the client ID of the token's audience (the application for which the token is to be used), the date and time when the token expires, etc. - The _scopes_ that specify the resources that holder is authorized to access, and the type of access granted (read or read/write). Access tokens are usually _bearer tokens_, which means that anyone who possesses an access token can use it to access the resources it's associated with. For security reasons, access tokens usually have a limited lifetime. Once past its end-of-life date and time, the token is invalid and can no longer be used to access the associated resources. To continue accessing those resources, the application will need to acquire a new access token (see _Refresh Token_ for details). In OIDC, authorization and authentication are often performed in the same workflow. When an application requests authorization from a authorization server for the first time in a session, the end user logs in and if authenticated, the authorization server provides the application with both an ID token and an access token. ## API URL: https://docs.unified.to/concepts/glossary/api An **API** is a set of rules and protocols for two applications to communicate with each other, allowing them to interact and share data using a defined set of methods and data formats. APIs provide an interface made of endpoints — URLs or URIs, each of which correspond to a specific operation to be performed on a specific resource, such as retrieving data, updating data, and deleting data. The client application makes requests to the API to perform an operation or provide data; the server application performs the API's services and issues responses to the client's requests. Requests are made using HTTP methods, with each method specifying a different kind of action to be performed the resource at the endpoint: - GET: Retrieve one or more resources from the server. - POST: Send data to the server to create a new resource. - PUT: Update an existing resource on the server. - DELETE: Remove a resource from the server. ## API Key URL: https://docs.unified.to/concepts/glossary/api_key An **API key** is a single text string that both identifies an API's client application and authorizes that client to access the API. Using an API key is the simplest form of API authorization. It avoids the credentialing problems that arise with changes in staff, but unlike more complex authorization systems like OAuth 2, it is simpler to compromise. ## API Request / Call URL: https://docs.unified.to/concepts/glossary/api_request_call An **API request** — also known as an **API call** — is a message sent by a client application to an API to retrieve, update, or interact with the resources provided by the API. The request is typically made over HTTP or HTTPS and follows a predefined format and protocol. APIs (Application Programming Interfaces) enable communication between different software systems by exposing endpoints that the client can interact with. **How** [**Unified.to**](http://unified.to/) **counts API calls** For billing, an API call is counted when: - Your application fetches, creates, updates or deletes data through the Unified API - [Unified.to](http://unified.to/) delivers a native webhook - A virtual webhook poll detects new data and delivers it - An AI model calls a tool through the Unified MCP server (one tool call is one API call) Errors and virtual webhook polling runs that find no new data aren't counted. For plan allowances and pricing, see [unified.to/pricing](https://unified.to/pricing). ## ATS URL: https://docs.unified.to/concepts/glossary/ats An **applicant tracking system (ATS)** is an application used by recruiters and employers to help them manage their recruitment and hiring processes. ATS applications automate and manage a number of recruitment and hiring tasks, including: - **Job posting and distribution:** Providing a system for creating and formatting job postings, then sending those postings to job boards, social media platforms, and other places where prospective candidates can find them. - **Resume parsing and storage:** Extracting relevant information from resumes (e.g., contact details, work experience, education, and skills) and storing it in a centralized repository. - **Candidate screening and filtering:** Screening applications based on predefined criteria (e.g., qualifications, experience, and skills) and using keywords and filters to identify qualified candidates from the applicant pool. - **Candidate communication and management:** Facilitates communication with candidates through email templates, notifications, and messaging systems, as well as scheduling interviews and managing interview logistics. - **Collaboration and feedback:** Enabling hiring teams to collaborate by consolidating and sharing candidate profiles, notes, and feedback. ## Authentication URL: https://docs.unified.to/concepts/glossary/authentication **Authentication** is the process of verifying the identity of a client, such as an end user, application, or device. It answers the question "Who are you?" These are the most common ways a client can authenticate itself with an API: - **API key:** The client provides an API key — a simple text string — as proof of its identity. - **API key + secret:** This is a more secure variant of API key authentication, where the client provides both an API key and a secret to authenticate itself. Like the API key, the secret is also a text string, but it is known only to the client. The API applies some transformation to the secret — usually hashing — in order to confirm that it is valid. - **OpenID Connect (OIDC):** An authentication protocol built on the OAuth 2 authorization protocol and used in conjunction with it. In OIDC, the clientsend credentials to an OAuth 2 authorization server to confirm its identity. If successful, the authorization server sends the client an ID token, which acts as proof that the client has been authenticated. ## Authorization URL: https://docs.unified.to/concepts/glossary/authorization **Authorization** is the process of determining if a client — an end user, application, or device — has permission to access a specific resource or perform a particular action. It answers the question "What are you allowed to do?" The authorization process usually takes place along with authentication. The client's identity is verified, after which their permissions are determined. These are the most common authorization mechanisms used by APIs: - **API key / API key _ secret:** These authentication methods can also act as authorization methods; the assumption is that if a client is known to the API, it has permission to access the API. This approach doesn't have any granularity of permissions unless the API uses a mechanism like an access control list. - **OAuth 2:** An open standard for authorization that grants client applications limited access to an end user's information without exposing their passwords. It is widely adopted for securing API access and is particularly popular in scenarios involving third-party integrations, where an end user grants a third-party application access to their resources hosted by another service. ## Bearer Token URL: https://docs.unified.to/concepts/glossary/bearer_token A **bearer token** is a type of security token used in authentication and authorization that indicates that any party possesses it (the "bearer") is allowed to access the associated resources. Here are some characteristics of bearer tokens: - **They simplify authorization.** Using a bearer token involves sending the token with each request to access the resources protected by the token, typically in the HTTP Authorization header. - **They are not bound to specific users.** The possessor of a bearer token, regardless of who they are, has access to the specific resource or resources that the token protects. Care must be taken to secure bearer tokens and ensure that they are not acquired by unauthorized parties. - **They are short-lived.** To minimize security risks, bearer tokens have short lifetimes. After a date and time specified within the token, the token becomes invalid and can no longer be used to access the resources it protects. - **They are revocable.** As an additional security measure, a bearer token can be revoked if necessary, rendering it invalid. When this happens, the token can no longer be used to access the resources it protects. Bearer tokens are a key part of the OAuth 2 protocol. OAuth 2's access tokens are bearer tokens, and they specify what the bearer is allowed to do with specified resources. ## Candidate URL: https://docs.unified.to/concepts/glossary/candidate A **candidate** or **applicant** is a person who has applied for a job position or is being considered for employment by an organization. In an ATS, candidates are the central focus of the recruiting process. Many of the objects managed by an ATS represent either candidates or things related to their journey through the recruitment process (such as jobs, resumes, interviews, scorecards, and so on). ## Client ID / Secret URL: https://docs.unified.to/concepts/glossary/client_id_secret A **client ID** and **client secret** are part of the OAuth 2 protocol, which protects resources by limiting access to authorized parties. The client ID is a value that uniquely identifies an application making an API request. This value is public. This value is supplied by the API provider, typically when the application's adminsitrators or developers register the application with the provider. The provider uses the client ID to track and manage the application's interactions with the API. The client secret is a value that proves the identity of the application making an API request. This value is confidential, and should be kept secure. It is supplied by the API provider along with the client ID. The client ID and client secret are used together during the OAuth 2 flow to obtain an access token, which authorizes an application to access resources via the API. ## Connection URL: https://docs.unified.to/concepts/glossary/connection A **connection** represents a specific authentication of an integration. In Unified.to, a connection represents the permission granted by an end user for an application to access their account on an integration via Unified.to's unified API. End users grant this permission by clicking on the integration's icon in the Embedded Authorization component and confirming that they would like to grant the application access to their account on that integration — usually by entering their credentials. Once the end user does that, they are returned to the application, and a connection representing the user's permission is created within Unified.to. You can see a list of all your end users' connections by selecting **Integrations** → **User Connections** in the Unified.to dashboard. ## Connection ID URL: https://docs.unified.to/concepts/glossary/connection_id {} ## CRM URL: https://docs.unified.to/concepts/glossary/crm **Customer Relationship Management (CRM)** applications help businesses manage their interactions and relationships with current and prospective customers. They are used primarily by salespeople, but are also used by marketing and customer support/success staff. CRM applications include the following functionalities: - **Contact/company management**: Tracking of customer/prospective customer and company information. - **Deal management:** Tracking sales opportunities with contacts or companies. - **Event/interaction management:** Tracking interactions with customers over various channels — emails, calls, meetings — as well as things related to those interactions, such as notes and tasks. ## Data Model URL: https://docs.unified.to/concepts/glossary/data_model A **data model** is a representation of data elements describing how that data is structured, related, and used. It provides a framework for defining the data elements and their relationships, which are crucial for designing databases, data warehouses, and other data systems. The key components of a data model are: - **Entities:** Representations of objects or concepts that are important to an organization or process, such as a customer, business transaction, email or chat message, or generative AI prompt. - **Attributes:** Properties or characteristics of entities. For example, a customer entity would have properties like a name, email address, and phone number. - **Relationships:** These describe how entities are related or connected to each other, or how they interact with each other. Relationships can be one-to-one, one-to-many, or many-to-many. For example, a customer can place multiple orders, which would be represented by a one-to-many relationship between the corresponding customer entity and many order entities. - **Keys:** Unique identifiers for entities. There are _primary keys_, which unique identify an entity, and _foreign keys_, which link entities of different types. ## Endpoint URL: https://docs.unified.to/concepts/glossary/endpoint An **endpoint** is a specific URL or URI where a particular resource or service can be accessed by the client. They define the points of interaction between the client and the server, enabling the client to perform various operations such as retrieving, creating, updating, or deleting resources. Here are the key characteristics of an endpoint: - **URL structure:** Endpoints are made up of the API's base URL, followed by the path of that specifies the resource. For example, an endpoint that provides information about a customer whose id value is 123 could be [`https://api.example.com/`](https://api.example.com/)`customer/123`. `https://api.example.com/` is the base URL, and `customer/123` is the path to the resource. - **Resource identifiers:** These are identifiers at the end of the path that specify a specific resource or collection of resources that the client wants to interact with. For the endpoint [`https://api.example.com/`](https://api.example.com/)`customer/123`, the resource identifier is `123`. - **HTTP method:** Endpoints are accessed using various HTTP methods, which determine the type of operation to be performed: - `GET`: Retrieve a resource or a list of resources from the API. - `POST`: Submit data to the API to create a new resource. - `PUT`: Update an existing resource. - `DELETE`: Remove an existing resource. - **Parameters:** Endpoints can include query parameters or path parameters to filter, modify, or specify the resource further. These are key-value pairs added after the URL For example, the following request asks for a list of only the customers who are active: `https://api.example.com/v2/customer?status=active`. ## Environment URL: https://docs.unified.to/concepts/glossary/environment An **environment** is a specific configuration and context where an application runs, namely the hardware, operating system, other applications, utilities, dependencies, libraries, and configurations and settings. What an application does and how it performs depends on the environment in which it runs. Here are the general categories of environment in an ideal software development scenario: - **Development environment:** An environment where developers build the application. This environment is optimized for writing software, typically running on a developer's computer and has the presence of developer tools, dependencies, and libraries. - **Sandbox environment:** An environment explicitly designed for isolated and safe experimentation during development. They use dummy data and do not impact any real systems or data. - **Testing environment:** A testing server where automated tests (such as unit and integration tests) are run to verify the functionality. Test data is used to avoid any impact on real users. - **Staging environment:** A staging server where the setup mimics the production environment as closely as possible in order to simulate the actual conditions under which the application will be run. This is used for fine-tuning and user acceptance testing (UAT). - **Production environment:** The live system where real users make use of the application. ## ETL URL: https://docs.unified.to/concepts/glossary/etl **ETL** — short for extract, transform, and load — is a process where data is moves from multiple sources into a centralized repository, such as a data warehouse. ETL, as its name implies, involves three steps: 1. **Extract:** The data is pulled from a various sources — databases, flat files, APIs, and other data repositories — with the goal of gathering all relevant data needed for analysis. 2. **Transform:** In this step, the extracted data is cleaned, transformed, formatted, and prepared for loading. This can involve various operations such as filtering out duplicate or invalid data, sorting, aggregating, and applying business rules to ensure the data is in the correct format and quality for analysis. 3. **Load:** The transformed data is loaded into the destination data repository, such as a data warehouse or data lake. The end result of ETL is a more complete collection of data gathered from a wide array of sources that provides a unified view in order to provide more effective data analysis, insights, and decision-making. ## GDPR URL: https://docs.unified.to/concepts/glossary/gdpr The **GDPR (General Data Protection Regulation)**, is a regulation in the European Union (EU) that sets out how personal data of individuals in the EU can be collected, used, and stored. It's considered one of the most comprehensive data privacy laws in the world, and it has had a significant impact on how organizations around the globe handle personal data. Here are some key points about the GDPR: • **Who it applies to:** The GDPR applies to any organization that processes the personal data of individuals in the EU, regardless of where the organization is located. This includes businesses, government agencies, and non-profit organizations. • **What it protects:** The GDPR protects a wide range of personal data, including names, addresses, email addresses, phone numbers, IP addresses, and health information. • **Key rights for individuals:** Under the GDPR, individuals have a number of rights in relation to their personal data, including the right to access their data, the right to rectification (correction), the right to erasure (deletion), the right to restrict processing, the right to data portability, and the right to object to automated decision-making. • **Obligations for organizations:** Organizations that are subject to the GDPR have a number of obligations, including: ◦ **Lawful basis for processing:** Organizations must have a legal basis for processing personal data, such as consent, contractual necessity, or a legitimate interest. ◦ **Data minimization:** Organizations should only collect and process the personal data that is necessary for the specific purposes for which it is being processed. ◦ **Security measures:** Organizations must implement appropriate technical and organizational measures to protect personal data from unauthorized access, disclosure, alteration, or destruction. ◦ **Data breach notification:** Organizations must notify individuals and the relevant authorities in the event of a data breach. Here are some resources where you can learn more about the GDPR: • **Official GDPR website:** [https://gdpr.eu/what-is-gdpr/](https://gdpr.eu/what-is-gdpr/) • **European Commission website on GDPR:** [https://gdpr.eu/what-is-gdpr/](https://gdpr.eu/what-is-gdpr/) ## Generative AI URL: https://docs.unified.to/concepts/glossary/generative_ai **Generative AI** is a class of artificial intelligence models and algorithms designed to create new content. Unlike discriminative or "traditional" AI, which typically focuses on recognizing patterns and making predictions based on existing data or placing data into categories, generative AI generates new, original data that is not explicitly present in the training set. ## HRIS URL: https://docs.unified.to/concepts/glossary/hris An **HRIS (Human Resources Information System)** is an application or service that manages and streamlines various human resource (HR) functions within an organization. HRIS systems integrate multiple HR processes, employee data management, payroll, benefits administration, performance management, and compliance reporting. Some HRIS applications also provide recruitment functionality similar to that offered by an applicant tracking system (ATS). ## ID Token URL: https://docs.unified.to/concepts/glossary/id_token An **ID token** is a security credential in OIDC (OpenID Connect, an authentication protocol built on OAuth 2) that acts as proof that the end user has been authenticated. They are issued to an application by the authorization server as the end result of successful authentication (logging in). Tokens typically contain _claims_, which are information about the token and the end user. In an ID token, claims contain information about: - The token itself, such as the URL for the authorization server that issued the token, the client ID of the token's audience (the application for which the token is to be used), the date and time when the token expires, etc. - Information about the end user, typically concerning their profile and identity, such as names, email address, URL for their profile photo, etc. In OIDC, authorization and authentication are often performed in the same workflow. When an application requests authorization from a authorization server for the first time in a session, the end user logs in and if authenticated, the authorization server provides the application with both an ID token and an access token. ## Integration URL: https://docs.unified.to/concepts/glossary/integration An **integration** is a connection between a unified API and a SaaS API. In Unified.to, an integration a connection between Unified.to's unified API and one of the SaaS applications that the unified API can send messages to and receive messages from. In order to be able to send messages to and receive messages from a specific SaaS application, you must _activate_ that SaaS application's integration. You do this by doing the following: - Select **Integrations** → **Active Integrations** in the Unified.to dashboard. - Find the SaaS application whose integration you want to activate. Click on that SaaS application's entry. - On the page that appears, make sure the **Authorizations** tab is selected, then click the **Activate** button. ## iPaaS URL: https://docs.unified.to/concepts/glossary/ipaas An evolution of an ETL solution that moves data from customer accounts from one source to another destination. ## Item / Product URL: https://docs.unified.to/concepts/glossary/item_product An **item** is a product or service that is sold in an online or physical store. ## KMS URL: https://docs.unified.to/concepts/glossary/kms A **knowledge management system (KMS)** is an application designed to facilitate the collection, organization, sharing, and retrieval of knowledge within an organization. ## OAuth 2 URL: https://docs.unified.to/concepts/glossary/oauth_2 OAuth 2, short for Open Authorization Protocol, version 2, is the industry standard method for authorization defined by the Internet Engineering Task Force (IETF). It enables a third-party application to obtain limited access to a user's resources hosted on a server, without exposing the user's credentials to the third-party application. It is the preferred method for allowing secure and controlled access to web APIs. It is widely used for applications to grant access to user information from various services such as Google, Facebook, and Twitter, and used by applications that use Unified.to's unified API to access end users' account on SaaS applications. OAuth 2 is based on these roles and objects: - **Resource owner:** This is the user or application who owns the data or services — the resources — and wants to grant access to them to a third-party application. - **Client:** This is the third-party application requesting access to the protected resources on behalf of the resource owner. - **Authorization server:** This is a server responsible for authenticating the resource owner and issuing permission in the form of access tokens (see below) to the third-party application (the Client) to access the resources after the Resource Owner grants authorization. It performs two important security tasks: verifying the identity of the Resource Owner and confirming that the Client is authorized to access the requested resources. - **Resource server:** This is the server hosting the resources that the Client wants to access; in other words, it houses the API. The Resource Server validates the access tokens presented by the Client, granting access to the requested resources if the tokens are valid. - **Access token:** An access token is a credential representing the authorization granted to the client by the resource owner. The client presents this token to the resource server to access the protected resources. Access tokens are short-lived and typically have limited scopes, which are permissions for access to specific resources and actions granted to the Client. - **Refresh token:** A refresh token is used to obtain a new access token when the current one expires, without requiring the user to re-authenticate. OAuth 2 offers the following benefits: - **Security**: It allows applications to access resources without exposing user credentials, reducing the risk of credential theft. - **User Experience**: It delivers a seamless user experience by allowing users to authorize applications without having to repeatedly entering their credentials. - **Scalability**: It supports various authorization flows to accommodate different types of applications and use cases. - **Interoperability**: As an open standard, OAuth 2.0 is widely adopted and supported by numerous services and platforms, enabling integration across different systems. ## Passthrough URL: https://docs.unified.to/concepts/glossary/passthrough A **passthrough** is a way to forward requests directly to an integration without going through the unified API. Unified.to's unified API is designed to handle the most common use cases for several API categories. For example, our ATS (application tracking system) API provides a unified model for working with entities that are common to ATS SaaS applications, such as managing candidates, jobs, and companies. Each of the ATS SaaS applications that we integrate with may have different names for those entities and different endpoints for creating, reading, updating, and deleting them, but with our unified API, you always use the Unified.to **candidate**, **job**, and **company** models and our endpoints for working with them, regardless of the SaaS integration you're working with. Our unified approach abstracts away the differences between SaaS applications for the majority of the tasks that your application will perform. However, there may be times when you need to access functionality or data that is specific to a particular SaaS application that isn't covered by our unified API. In these edge cases, your application can bypass the unified API and send a platform-specific request directly to an endpoint in that SaaS application's API. This request _passes through_ the unified API and directly to the endpoint without being altered, hence the name "passthrough." Unified.to's passthrough supports the following HTTP methods: - `POST`: Sends a POST request to the specified endpoint, complete with payload specific to the SaaS API. - `PUT`: Sends a PUT request to the specified endpoint, complete with payload specific to the SaaS API. - `GET`: Sends a GET request to the specified endpoint, complete with payload specific to the SaaS API. - `DELETE`: Sends a DELETE request to the specified endpoint, complete with payload specific to the SaaS API. ## Refresh Token URL: https://docs.unified.to/concepts/glossary/refresh_token A **refresh token** is security credential that allows an application to acquire a new access token to replace one that has expired without requiring the end user to re-authenticate (log in). In OAuth 2, when the authorization server issues an access token to an application, it often issues an accompanying refresh token. When the access token expires, the application submits the refresh token to get a new access token. Like access tokens, refresh tokens usually have a limited lifetime. This lifetime is often longer than an access token's lifetime; this longer lifetime means that the end user doesn't have to re-authenticate as often. Since refresh tokens are generally longer-lived and are used to obtain new access tokens, they need to be stored and handled with greater care than access tokens. ## Request URL: https://docs.unified.to/concepts/glossary/request A **request**, in the context of an API, is a message sent by a client to an API server to perform a specific action or retrieve data. API requests follow a defined protocol, preferably HTTPS but sometimes HTTP, and consist of the various components listed below that convey the necessary information for the server to process the request and return an appropriate response: - **HTTP method:** Specifies the type of action the request is asking the server to perform. Common HTTP methods include: - `GET`: Retrieve a resource or a list of resources from the API. - `POST`: Submit data to the API to create a new resource. - `PUT`: Update an existing resource. - `DELETE`: Remove an existing resource. - **URL (uniform resource locator):** The endpoint or address to which the request is sent. It typically includes the server address and the specific resource path, e.g. `https://api.example.com/v2/customer`. - **Headers:** Metadata included as part of the request that provide additional information. Common headers include: - **`Content-Type`**: Specifies the media type of the request body. For example, the content type of JSON requests is **`application/json`**. - **`Authorization`**: Contains credentials for authenticating the request. In requests made using the OAuth 2 protocol, the value of this header is the string **`Bearer token`** followed by the token string. - **Query parameters:** Key-value pairs added after the URL to pass additional data. They are typically used to filter or modify the response. For example, the following request asks for a list of only the customers who are active: `https://api.example.com/v2/customer?status=active`. - **Body:** The request's payload — the data to be sent to the API. This is mainly used with the `POST` and `PUT` methods. Here's an example of a JSON body: ```javascript { "id" : "01234", "name" : "Jane Doe", "email" : "jane.doe@example.com" } ``` ## Response URL: https://docs.unified.to/concepts/glossary/response A **response**, in the context of an API, is a message sent by the server back to the client after processing an API request. Responses contain information about the result of the request, including whether it was successful, any data requested, and details about any errors that occurred. They onsist of the various components listed below: - **Status code:** An HTTP response code, which is a three-digit numeric code indicating the result of the request. Common status codes include: - **`200 OK`**: The request was successful. - **`201 Created`**: A new resource was successfully created. - **`400 Bad Request`**: The request was malformed or invalid. - **`401 Unauthorized`**: Authentication is required or has failed. - **`404 Not Found`**: The requested resource could not be found. - **`500 Internal Server Error`**: An error occurred on the server. - **Headers:** Metadata included as part of the response that provide additional information. Common headers include: - **`Content-Type`**: The media type of the response body. One common type is JSON, represented by a content-type value of **`application/json`**. - **`Content-Length`**: The length of the response body in bytes. - **`Set-Cookie`**: Used to send cookies from the server to the client. - **Body:** The main content of the response, often containing the data requested by the client or details about the result of the request. The body is usually formatted in a standard data interchange format such as JSON or XML. ## An API Response Example Consider an example where a client application has made a **`GET`** request to retrieve a user's profile. Here's what the request might look like: ```javascript GET /v2/user/123 HTTP/1.1 Host: api.example.com Authorization: Bearer [ACCESS_TOKEN_GOES_HERE] ``` Here's what the corresponding response might look like: - Status code: `200 OK` - Headers: - **`Content-Type`**: **`application/json`** - **`Content-Length`**: `150` - Body: ```javascript { "id": "01234", "name": "Jane Doe", "email": "jane.doe@example.com", "created_at": "2024-05-25T12:34:56Z" } ``` ## REST URL: https://docs.unified.to/concepts/glossary/rest A REST API, also known as a RESTful API, is a specific type of application programming interface (API) that follows the Representational State Transfer (REST) architectural style. Here's a breakdown of the key points: **Concept:** - API: Think of it as a messenger between different software systems. It defines how programs can request and receive data from each other. - REST: A set of guidelines for designing APIs that favor simplicity, interoperability, and scalability. Think of it as best practices for API design. **Functioning:** - REST APIs use standard HTTP methods: GET to retrieve information, POST to create new data, PUT to update data, and DELETE to remove data. This makes them familiar and easy to use for developers. - Data is typically exchanged in JSON format, which is human-readable and widely supported. - Each resource (like a product or user) has a unique identifier (like a URL) and is accessed through specific endpoints. **Benefits:** - **Standardized**: Easier for developers to understand and integrate with. - **Interoperable**: Works across different platforms and programming languages. - **Scalable**: Can handle large amounts of data and traffic efficiently. - **Flexible**: Can be adapted to various use cases. **Examples:** - Many popular websites and services have REST APIs, like Twitter, Facebook, Google Maps, and Amazon. - Developers can use these APIs to integrate their own applications with these services, adding new features and functionality. **Additional points:** - Not all APIs are RESTful, but REST APIs are very common due to their advantages. - There are specific guidelines and constraints for building RESTful APIs. - Security is important when using any API, including REST APIs. ## Sandbox URL: https://docs.unified.to/concepts/glossary/sandbox A **sandbox** is an isolated environment where code can be executed safely, without affecting the system or real data. Sandboxes are used to test, run, and analyze code in a way that limits its potential impact on the wider system and data, allowing developers to learn and experiment without having adverse effects on systems, applications, or data that people and organizations rely on. Some APIs provide a sandbox environment — a testing environment that acts like the production environment but is isolated and controlled and uses dummy data. This allows developers to safely experiment, test, and integrate with the API without affecting the live data or system. ## Schema URL: https://docs.unified.to/concepts/glossary/schema A **schema** is a structured framework that defines the organization, structure, and constraints of data or information. Schemas are used in various fields to standardize and organize information, making it easier to manage, understand, and manipulate data. In the context of APIs, a schema defines the structure, format, and types of data that an API can accept and return. It defines both the request and response data formats, ensuring that clients and servers can communicate. Schemas are particularly important for maintaining consistency and validating data in API interactions. Key aspects of API schemas include: - **Structure:** What data is included in a given data structure, and how it is organized — scalars (single values), collections (objects or arrays), and nesting. - **Data types:** The specific type for each data element, which includes numbers, strings, booleans, arrays, and objects. - **Constraints:** Restrictions on the data, such as required fields, default values, minimum and maximum values, and specific formats (e.g., date, email). ## SCIM URL: https://docs.unified.to/concepts/glossary/scim SCIM (System for Cross-domain Identity Management) is an open standard protocol designed to simplify the management of user identities in cloud-based applications and services. It is primarily used for automating the exchange of user identity information between identity providers (such as companies with multiple individual users) and service providers (such as enterprise SaaS applications). SCIM makes it easier to provision, de-provision, and manage user accounts across various systems. It has the following features: - **Unified schema:** SCIM defines a common user schema that includes attributes such as username, email, phone number, and group membership. This standardized schema ensures consistency in how user information is represented across different applications. - **RESTful API:** SCIM uses a RESTful API, which allows for CRUD (Create, Read, Update, Delete) operations on user identities and groups. - **JSON payloads:** SCIM typically uses the JSON format for request and response payloads. - **Interoperability:** SCIM is designed to work across different domains and platforms, providing interoperability between various identity management systems and cloud services. ## Scopes URL: https://docs.unified.to/concepts/glossary/scopes **Scopes** are a mechanism specifying the access privileges or permissions that a client application is requesting on behalf of the end user. They define the range of resources and operations that an application is permitted to perform on behalf of the end user. By specifying scopes, an application can request only the permissions it needs. Scopes are a key part of access tokens in OAuth 2. When an end user authorizes an application in OAuth 2, they are granting the application access to specific parts of their account as defined by these scopes. Each scope represents a specific level or type of access, allowing granular control over what the application can and cannot do. Scopes are human-readable strings included within the access token that specify one or more types of access that an application is requesting. They are defined by the resource server (i.e., the API) and represent the permissions that the application calling the API can request. Here are some examples of OAuth 2 scopes and the privileges or permissions they grant to an application: - `profile`: Allows the application to access an end user's basic profile information (typically their names and photo URL). - `email`: Allows the application to access the end user's email address. - `https://www.googleapis.com/auth/drive`: Specific to Google Drive; allows the application to create, read, update, delete, and download files on the end user's Google Drive account. - `https://www.googleapis.com/auth/drive.readonly`: Specific to Google Drive; allows the application to only read and download files on the end user's Google Drive account. - `repo`: Specific to GitHub; allows the application full read and write access to the end user's public and private repositories, including code, commit statuses, repository invitations, collaborators, deployment statuses, and repository webhooks. - `public_repo`: Specific to GitHubl; similar to `repo`, except full read and write access is limited to the end user's public repositories. ## Unified API URL: https://docs.unified.to/concepts/glossary/unified_api A **unified API** (also sometimes called a **universal API**) is a single API interface that provides access to multiple APIs, presenting a consistent and standardized way to interact with them. They are essentially "middlemen" that simplify interactions with many different individual APIs. A unified API acts as a single, standardized interface that allows an application to access data and functionality from multiple sources using a single consistent set of methods. Key features of unified APIs include: - **Aggregation**: Combining APIs from multiple providers within a specific software category (e.g., accounting, HR). - **Abstraction**: Hiding the complexities of individual APIs and presenting a single point of access with consistent authentication, data formats, and syntax, regardless of the underlying APIs. Unified APIs offer the following benefits: - **Simplified integration**: Developers can connect to numerous software products with less effort and code compared to individual integrations. - **Faster development**: Reduced complexity and standardized procedures lead to quicker integration times. - **Improved consistency**: Provides a uniform user experience across different platforms and applications. - **Reduced costs**: Less development effort translates to lower overall costs. - **Streamlined workflows**: Data exchange between various systems becomes simpler and more efficient. While unified APIs provide the benefits listed above, there are some considerations developers must take into account before using them: - Unified APIs might not offer the full functionality of individual APIs. - They may have their own limitations and data models. - Choosing the right unified API depends on your specific needs and the software category you're interested in. **Example Use Cases for Unified APIs** - A unified API for HR systems might allow access to employee data from different providers like BambooHR and Hibob through a single interface. - A unified API for payment gateways could offer developers a consistent way to integrate with various payment processors like Stripe and PayPal. ## Unified Data Model URL: https://docs.unified.to/concepts/glossary/unified_data_model A **unified data model** is a comprehensive and standardized data model combining data from multiple sources into a single format and place. ## User ID URL: https://docs.unified.to/concepts/glossary/user_id {} ## Webhook URL: https://docs.unified.to/concepts/glossary/webhook A **webhook** is an HTTP callback function triggered by specific events. When such an event occurs in a service, the webhook makes an HTTP POST request to the URL configured when the webhook was created, with the POST request containing information about the event. This allows one application to notify another application when something of interest happens, enabling automated workflows and integrations. For example, when a candidate's information in an ATS (application tracking system) is updated or when a new deal in a CRM (customer relationship management) application is created, a webhook can notify a client application about it. Webhooks make it simpler for an application to stay up-to-date with changes in the data or state of a SaaS provider. Before webhooks, a client application would have to continually poll a SaaS provider to keep up with any changes to its data or state. Webhooks work in the following fashion: - The developer configures a webhook by specifying the URL of the endpoint in their application that will receive an HTTP POST when a certain event occurs. - When the specified event occurs in the SaaS application, it sends an HTTP POST request containing information about the event in its payload to the URL defined in the configuration step above. - The application receiving the HTTP POST request processes the data in the request's payload and performs any necessary actions in response. These actions could include updating its records, sending notifications to end users, or triggering further processes. ## Workspace URL: https://docs.unified.to/concepts/glossary/workspace A workspace contains all of your product team, their environments, configuration, activated integrations, and customer connections. Basically, each company on [Unified.to](http://unified.to/) has 1 workspace. --- # Accounting API – Endpoints & Data Models ## Accounting API data models URL: https://docs.unified.to/accounting/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /accounting/{connection_id}/account | Create an account | | GET | /accounting/{connection_id}/account | List all accounts | | GET | /accounting/{connection_id}/account/{id} | Retrieve an account | | PUT | /accounting/{connection_id}/account/{id} | Update an account | | DELETE | /accounting/{connection_id}/account/{id} | Remove an account | | POST | /accounting/{connection_id}/journal | Create a journal | | GET | /accounting/{connection_id}/journal | List all journals | | GET | /accounting/{connection_id}/journal/{id} | Retrieve a journal | | PUT | /accounting/{connection_id}/journal/{id} | Update a journal | | DELETE | /accounting/{connection_id}/journal/{id} | Remove a journal | | POST | /accounting/{connection_id}/transaction | Create a transaction | | GET | /accounting/{connection_id}/transaction | List all transactions | | GET | /accounting/{connection_id}/transaction/{id} | Retrieve a transaction | | PUT | /accounting/{connection_id}/transaction/{id} | Update a transaction | | DELETE | /accounting/{connection_id}/transaction/{id} | Remove a transaction | | POST | /accounting/{connection_id}/contact | Create a contact | | GET | /accounting/{connection_id}/contact | List all contacts | | GET | /accounting/{connection_id}/contact/{id} | Retrieve a contact | | PUT | /accounting/{connection_id}/contact/{id} | Update a contact | | DELETE | /accounting/{connection_id}/contact/{id} | Remove a contact | | POST | /accounting/{connection_id}/invoice | Create an invoice | | GET | /accounting/{connection_id}/invoice | List all invoices | | GET | /accounting/{connection_id}/invoice/{id} | Retrieve an invoice | | PUT | /accounting/{connection_id}/invoice/{id} | Update an invoice | | DELETE | /accounting/{connection_id}/invoice/{id} | Remove an invoice | | POST | /accounting/{connection_id}/bill | Create a bill | | GET | /accounting/{connection_id}/bill | List all bills | | GET | /accounting/{connection_id}/bill/{id} | Retrieve a bill | | PUT | /accounting/{connection_id}/bill/{id} | Update a bill | | DELETE | /accounting/{connection_id}/bill/{id} | Remove a bill | | POST | /accounting/{connection_id}/creditmemo | Create a creditmemo | | GET | /accounting/{connection_id}/creditmemo | List all creditmemoes | | GET | /accounting/{connection_id}/creditmemo/{id} | Retrieve a creditmemo | | PUT | /accounting/{connection_id}/creditmemo/{id} | Update a creditmemo | | DELETE | /accounting/{connection_id}/creditmemo/{id} | Remove a creditmemo | | POST | /accounting/{connection_id}/vendorcredit | Create a vendorcredit | | GET | /accounting/{connection_id}/vendorcredit | List all vendorcredits | | GET | /accounting/{connection_id}/vendorcredit/{id} | Retrieve a vendorcredit | | PUT | /accounting/{connection_id}/vendorcredit/{id} | Update a vendorcredit | | DELETE | /accounting/{connection_id}/vendorcredit/{id} | Remove a vendorcredit | | POST | /accounting/{connection_id}/taxrate | Create a taxrate | | GET | /accounting/{connection_id}/taxrate | List all taxrates | | GET | /accounting/{connection_id}/taxrate/{id} | Retrieve a taxrate | | PUT | /accounting/{connection_id}/taxrate/{id} | Update a taxrate | | DELETE | /accounting/{connection_id}/taxrate/{id} | Remove a taxrate | | POST | /accounting/{connection_id}/paymentterm | Create a paymentterm | | GET | /accounting/{connection_id}/paymentterm | List all paymentterms | | GET | /accounting/{connection_id}/paymentterm/{id} | Retrieve a paymentterm | | PUT | /accounting/{connection_id}/paymentterm/{id} | Update a paymentterm | | DELETE | /accounting/{connection_id}/paymentterm/{id} | Remove a paymentterm | | GET | /accounting/{connection_id}/organization/{id} | Retrieve an organization | | GET | /accounting/{connection_id}/organization | List all organizations | | POST | /accounting/{connection_id}/order | Create an order | | GET | /accounting/{connection_id}/order | List all orders | | GET | /accounting/{connection_id}/order/{id} | Retrieve an order | | PUT | /accounting/{connection_id}/order/{id} | Update an order | | DELETE | /accounting/{connection_id}/order/{id} | Remove an order | | POST | /accounting/{connection_id}/purchaseorder | Create a purchaseorder | | GET | /accounting/{connection_id}/purchaseorder | List all purchaseorders | | GET | /accounting/{connection_id}/purchaseorder/{id} | Retrieve a purchaseorder | | PUT | /accounting/{connection_id}/purchaseorder/{id} | Update a purchaseorder | | DELETE | /accounting/{connection_id}/purchaseorder/{id} | Remove a purchaseorder | | POST | /accounting/{connection_id}/salesorder | Create a salesorder | | GET | /accounting/{connection_id}/salesorder | List all salesorders | | GET | /accounting/{connection_id}/salesorder/{id} | Retrieve a salesorder | | PUT | /accounting/{connection_id}/salesorder/{id} | Update a salesorder | | DELETE | /accounting/{connection_id}/salesorder/{id} | Remove a salesorder | | GET | /accounting/{connection_id}/salesreceipt/{id} | Retrieve a salesreceipt | | GET | /accounting/{connection_id}/salesreceipt | List all salesreceipts | | GET | /accounting/{connection_id}/balancesheet/{id} | Retrieve a balancesheet | | GET | /accounting/{connection_id}/balancesheet | List all balancesheets | | GET | /accounting/{connection_id}/trialbalance/{id} | Retrieve a trialbalance | | GET | /accounting/{connection_id}/trialbalance | List all trialbalances | | GET | /accounting/{connection_id}/profitloss/{id} | Retrieve a profitloss | | GET | /accounting/{connection_id}/profitloss | List all profitlosses | | GET | /accounting/{connection_id}/cashflow/{id} | Retrieve a cashflow | | GET | /accounting/{connection_id}/cashflow | List all cashflows | | POST | /accounting/{connection_id}/category | Create a category | | GET | /accounting/{connection_id}/category | List all categories | | GET | /accounting/{connection_id}/category/{id} | Retrieve a category | | PUT | /accounting/{connection_id}/category/{id} | Update a category | | DELETE | /accounting/{connection_id}/category/{id} | Remove a category | | POST | /accounting/{connection_id}/expense | Create an expense | | GET | /accounting/{connection_id}/expense | List all expenses | | GET | /accounting/{connection_id}/expense/{id} | Retrieve an expense | | PUT | /accounting/{connection_id}/expense/{id} | Update an expense | | DELETE | /accounting/{connection_id}/expense/{id} | Remove an expense | | POST | /accounting/{connection_id}/expensereport | Create an expensereport | | GET | /accounting/{connection_id}/expensereport | List all expensereports | | GET | /accounting/{connection_id}/expensereport/{id} | Retrieve an expensereport | | PUT | /accounting/{connection_id}/expensereport/{id} | Update an expensereport | | DELETE | /accounting/{connection_id}/expensereport/{id} | Remove an expensereport | | GET | /accounting/{connection_id}/purchasereceipt/{id} | Retrieve a purchasereceipt | | GET | /accounting/{connection_id}/purchasereceipt | List all purchasereceipts | | POST | /accounting/{connection_id}/quote | Create a quote | | GET | /accounting/{connection_id}/quote | List all quotes | | GET | /accounting/{connection_id}/quote/{id} | Retrieve a quote | | PUT | /accounting/{connection_id}/quote/{id} | Update a quote | | DELETE | /accounting/{connection_id}/quote/{id} | Remove a quote | | GET | /accounting/{connection_id}/agedreceivable/{id} | Retrieve an agedreceivable | | GET | /accounting/{connection_id}/agedreceivable | List all agedreceivables | | GET | /accounting/{connection_id}/agedpayable/{id} | Retrieve an agedpayable | | GET | /accounting/{connection_id}/agedpayable | List all agedpayables | | POST | /accounting/{connection_id}/project | Create a project | | GET | /accounting/{connection_id}/project | List all projects | | GET | /accounting/{connection_id}/project/{id} | Retrieve a project | | PUT | /accounting/{connection_id}/project/{id} | Update a project | | DELETE | /accounting/{connection_id}/project/{id} | Remove a project | | POST | /accounting/{connection_id}/bankfeedaccount | Create a bankfeedaccount | | GET | /accounting/{connection_id}/bankfeedaccount | List all bankfeedaccounts | | GET | /accounting/{connection_id}/bankfeedaccount/{id} | Retrieve a bankfeedaccount | | PUT | /accounting/{connection_id}/bankfeedaccount/{id} | Update a bankfeedaccount | | DELETE | /accounting/{connection_id}/bankfeedaccount/{id} | Remove a bankfeedaccount | | POST | /accounting/{connection_id}/bankfeedtransaction | Create a bankfeedtransaction | | GET | /accounting/{connection_id}/bankfeedtransaction | List all bankfeedtransactions | | GET | /accounting/{connection_id}/bankfeedtransaction/{id} | Retrieve a bankfeedtransaction | | PUT | /accounting/{connection_id}/bankfeedtransaction/{id} | Update a bankfeedtransaction | | DELETE | /accounting/{connection_id}/bankfeedtransaction/{id} | Remove a bankfeedtransaction | #### Data Models ### AccountingAccount Chart of accounts | Field | Type | Required | Description | |---|---|---|---| | id | string | | Identifier used by the SaaS application to uniquely identify the Account | | created_at | string (date-time) | | Date and time when the Account was created, in ISO 8601 format and UTC (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | Date and time when the Account was last updated, in ISO 8601 format and UTC (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Account name | | description | string | | Account description | | type | string enum: ACCOUNTS_PAYABLE, ACCOUNTS_RECEIVABLE, BANK, CREDIT_CARD, FIXED_ASSET, LIABILITY, EQUITY, EXPENSE, REVENUE, OTHER | | Account type, such as BANK. EQUITY, ASSET, ... | | status | string enum: ACTIVE, ARCHIVED | | Account status, such as ACTIVE or ARCHIVED | | balance | number | | Balance of the account. | | currency | string | | Account’s currency, in ISO 4217 format (e.g. U.S. dollars is USD) | | customer_defined_code | string | | Identifier for tracking and categorizing accounts for reporting or analysis purposes that is defined by the end-customer | | is_payable | boolean | | True if the account is an “accounts payable” account | | parent_id | string | | The parent account ID for this account | | taxonomy | AccountingAccountTaxonomy[] | | | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | The original data from the integration's API | ### AccountingAgedpayable | Field | Type | Required | Description | |---|---|---|---| | id | string | | synthetic, one row per currency / date window | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | currency | string | | ISO 4217 | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_current_amount | number | | open balances not yet past due; distinct from the first past-due period | | total_overdue_amount | number | | sum of open balances across all past-due periods | | total_amount | number | | | | periods | AccountingAgedPeriod[] | | | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingAgedreceivable | Field | Type | Required | Description | |---|---|---|---| | id | string | | synthetic, one row per currency / date window | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | currency | string | | ISO 4217 | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_current_amount | number | | open balances not yet past due; distinct from the first past-due period | | total_overdue_amount | number | | sum of open balances across all past-due periods | | total_amount | number | | | | periods | AccountingAgedPeriod[] | | | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingBalancesheet | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | currency | string | | | | net_assets_amount | number | | | | assets | AccountingBalancesheetItem[] | | | | liabilities | AccountingBalancesheetItem[] | | | | equity | AccountingBalancesheetItem[] | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingBankfeedaccount | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | creation timestamp, in ISO 8601 format and UTC (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | last-update timestamp, in ISO 8601 format and UTC (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | organization_id | string | | ref -> accounting Organization this record belongs to | | name | string | | account nickname / source name | | bank_name | string | | bank / institution name | | account_number | string | | full account number where available | | account_number_last4 | string | | last-4 when the full number isn't returned | | routing_number | string | | bank routing number | | account_type | string enum: CHECKING, SAVINGS, CREDIT_CARD, LOAN, OTHER | | CHECKING, SAVINGS, CREDIT_CARD, LOAN, OTHER | | account_id | string | | ref -> AccountingAccount: the target GL account the feed posts into (reference to AccountingAccount) | | currency | string | | ISO 4217 currency of the account | | balance | number | | current balance of the source account | | feed_start_at | string (date-time) | | date the feed begins delivering transactions (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | status | string enum: ACTIVE, INACTIVE | | feed state: ACTIVE, INACTIVE | | metadata | AccountingMetadata[] | | | | raw | object | | The original data from the integration's API | ### AccountingBankfeedtransaction | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | creation timestamp, in ISO 8601 format and UTC (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | last-update timestamp, in ISO 8601 format and UTC (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | organization_id | string | | ref -> accounting Organization this record belongs to | | bankfeedaccount_id | string | | ref -> BankfeedAccount this transaction belongs to | | transaction_at | string (date-time) | | when the transaction occurred (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | posted_at | string (date-time) | | when it posted / cleared (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | amount | number | | transaction amount (positive; direction from type) | | type | string enum: DEBIT, CREDIT | | direction: DEBIT, CREDIT | | bank_category | string | | the bank's own category label | | description | string | | bank memo / statement text | | merchant_name | string | | counterparty as reported by the bank | | is_pending | boolean | | true if not yet cleared | | reference | string | | bank reference or check number | | currency | string | | ISO 4217 | | contact_id | string | | matched vendor/customer once reconciled (reference to AccountingContact) | | account_id | string | | GL account once categorized (reference to AccountingAccount) | | category_ids | AccountingBankfeedtransaction_category_ids | | dimension refs -> AccountingCategory (class/department/location) (reference to AccountingCategory) | | metadata | AccountingMetadata[] | | | | raw | object | | The original data from the integration's API | ### AccountingBill | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | bill_number | string | | External identifier for this invoice | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | paid_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | refunded_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | cancelled_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | | | paid_amount | number | | | | refund_amount | number | | | | tax_amount | number | | | | discount_amount | number | | | | balance_amount | number | | | | contact_id | string | | (reference to AccountingContact) | | currency | string | | | | notes | string | | | | extended_notes | AccountingExtendedNote[] | | | | refund_reason | string | | | | lineitems | AccountingLineitem[] | | The bill's line items: what is billed, with the quantity, price and amount of each | | status | string enum: DRAFT, VOIDED, AUTHORIZED, PAID, PARTIALLY_PAID, PARTIALLY_REFUNDED, REFUNDED, SUBMITTED, DELETED, OVERDUE | | | | url | string | | The public URL for the invoice to send to a customer to view or pay. | | payment_collection_method | string enum: send_invoice, charge_automatically | | When set to charging_automatically, an automated attempt will occur to pay this invoice using the default payment source attached to the contactcustomer. When set to send_invoice, an will email will be sent with this invoice to the contact/customer with payment instructions. | | attachments | AccountingAttachment[] | | | | send | boolean | | | | organization_id | string | | | | term | string enum: ON_RECEIPT, NET_7, NET_10, NET_15, NET_20, NET_25, NET_30, NET_45, NET_60, NET_90, OTHER | | @deprecated use payment_terms instead | | payment_terms | string enum: ON_RECEIPT, NET_7, NET_10, NET_15, NET_20, NET_25, NET_30, NET_45, NET_60, NET_90, OTHER | | | | paymentterm_id | string | | | | payments | AccountingPaymentReference[] | | read-only reciprocal of PaymentPayment.allocations; payments applied to this invoice | | category_ids | AccountingBill_category_ids | | (reference to AccountingCategory) | | project_id | string | | (reference to TaskProject) | | metadata | AccountingMetadata[] | | | | sub_total_amount | number | | Total before tax | | exchange_rate | number | | Exchange rate to the base/home currency at the document date | | purchaseorder_ids | AccountingBill_purchaseorder_ids | | refs -> AccountingPurchaseorder; the purchase orders this bill was raised from | | raw | object | | | ### AccountingCashflow Sections | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | category_ids | AccountingCashflow_category_ids | | (reference to AccountingCategory) | | contact_id | string | | (reference to AccountingContact) | | name | string | | e.g. "Cash Flow Statement Q1 2020" | | currency | string | | ISO 4217, e.g. "USD" | | cash_beginning_amount | number | | Cash at beginning of period | | cash_ending_amount | number | | Cash at end of period | | net_change_in_cash_amount | number | | Usually ending - beginning | | operating_sections | AccountingCashflowSection[] | | | | investing_sections | AccountingCashflowSection[] | | | | financing_sections | AccountingCashflowSection[] | | | | metadata | AccountingMetadata[] | | | | raw | object | | Original data from the integration | ### AccountingCategory | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | is_active | boolean | | | | parent_id | string | | | | type | string enum: CLASS, DEPARTMENT, LOCATION, PROJECT, TASK, CUSTOM, EXPENSE, INCOME | | The kind of dimension | | code | string | | platform-side tracking code | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingContact | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The contact's display name: the company name for a business, or the person's full name | | first_name | string | | | | last_name | string | | | | emails | AccountingEmail[] | | The contact's email addresses | | telephones | AccountingTelephone[] | | | | currency | string | | | | billing_address | AccountingContact_billing_address | | | | shipping_address | AccountingContact_shipping_address | | | | is_active | boolean | | | | tax_exemption | string enum: FEDERAL_GOV, REGION_GOV, LOCAL_GOV, TRIBAL_GOV, CHARITABLE_ORG, RELIGIOUS_ORG, EDUCATIONAL_ORG, MEDICAL_ORG, RESALE, FOREIGN, OTHER | | | | tax_number | string | | The ID/number of the customer's tax number. This is also known as the ABN (Australia), GST Number (New Zealand), VAT Number (UK) or Tax ID Number (US and global). | | is_customer | boolean | | | | is_supplier | boolean | | | | portal_url | string | | URL for the contact’s portal | | payment_methods | AccountingContactPaymentMethod[] | | | | company_name | string | | | | identification | string | | contact account numbers such as registration, A membership or identification reference to help to identify and search customers | | associated_contacts | AccountingAssociatedContact[] | | | | organization_id | string | | | | payment_terms | string enum: ON_RECEIPT, NET_7, NET_10, NET_15, NET_20, NET_25, NET_30, NET_45, NET_60, NET_90, OTHER | | | | paymentterm_id | string | | | | balance_amount | number | | Outstanding AR/AP balance for this contact | | website | string | | | | notes | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingCreditmemo | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | paid_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | refunded_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | cancelled_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | | | paid_amount | number | | | | refund_amount | number | | | | tax_amount | number | | | | discount_amount | number | | | | balance_amount | number | | | | creditmemo_number | string | | External identifier for this invoice | | contact_id | string | | (reference to AccountingContact) | | invoice_id | string | | (reference to AccountingInvoice) | | currency | string | | | | notes | string | | | | refund_reason | string | | | | lineitems | AccountingLineitem[] | | | | status | string enum: DRAFT, VOIDED, AUTHORIZED, PAID, PARTIALLY_PAID, PARTIALLY_REFUNDED, REFUNDED, SUBMITTED, DELETED, OVERDUE | | | | url | string | | The public URL for the invoice to send to a customer to view or pay. | | payment_collection_method | string enum: send_invoice, charge_automatically | | When set to charging_automatically, an automated attempt will occur to pay this invoice using the default payment source attached to the contactcustomer. When set to send_invoice, an will email will be sent with this invoice to the contact/customer with payment instructions. | | attachments | AccountingAttachment[] | | | | send | boolean | | | | organization_id | string | | | | apply_amount | number | | | | applications | AccountingCreditApplication[] | | What this credit memo was applied to (invoices/bills). Writable inline on create/update. | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingExpense | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | | references a HR employee/user (reference to HrisEmployee) | | contact_id | string | | (reference to AccountingContact) | | account_id | string | | (reference to AccountingAccount) | | name | string | | | | payment_method | string | | | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | | | currency | string | | | | tax_amount | number | | | | reimbursed_amount | number | | | | reimbursed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | approved_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | approver_user_id | string | | references HR employee/user (reference to HrisEmployee) | | lineitems | AccountingLineitem[] | | | | attachments | AccountingAttachment[] | | | | organization_id | string | | | | users | AccountingReference[] | | | | approver_users | AccountingReference[] | | expense approver(s); id is HR employee/user when resolved | | status | string enum: DRAFT, SUBMITTED, PENDING, APPROVED, REJECTED, PAID | | | | external_number | string | | | | category_ids | AccountingExpense_category_ids | | (reference to AccountingCategory) | | project_id | string | | (reference to TaskProject) | | metadata | AccountingMetadata[] | | | | taxrate_id | string | | ref -> AccountingTaxrate applied to the expense | | exchange_rate | number | | Exchange rate to the base/home currency at the expense date | | type | string enum: USER, COMPANY | | USER = employee spend; COMPANY = books/purchase expense | | raw | object | | | ### AccountingExpensereport | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | user_id | string | | references a HR employee/user (reference to HrisEmployee) | | approver_user_id | string | | references HR employee/user (reference to HrisEmployee) | | approver_users | AccountingReference[] | | expense approver(s); id is HR employee/user when resolved | | status | string enum: DRAFT, SUBMITTED, PENDING, APPROVED, REJECTED, PAID | | | | total_amount | number | | | | currency | string | | | | tax_amount | number | | | | reimbursed_amount | number | | | | reimbursed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | approved_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | external_number | string | | | | lineitems | AccountingLineitem[] | | | | attachments | AccountingAttachment[] | | | | category_ids | AccountingExpensereport_category_ids | | (reference to AccountingCategory) | | project_id | string | | (reference to TaskProject) | | users | AccountingReference[] | | | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingInvoice | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | paid_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | refunded_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | cancelled_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | | | paid_amount | number | | | | refund_amount | number | | | | tax_amount | number | | | | discount_amount | number | | | | balance_amount | number | | | | invoice_number | string | | External identifier (ie. reference) for this invoice | | reference | string | | | | contact_id | string | | (reference to AccountingContact) | | currency | string | | | | notes | string | | | | extended_notes | AccountingExtendedNote[] | | | | refund_reason | string | | | | term | string enum: ON_RECEIPT, NET_7, NET_10, NET_15, NET_20, NET_25, NET_30, NET_45, NET_60, NET_90, OTHER | | @deprecated use payment_terms instead | | payment_terms | string enum: ON_RECEIPT, NET_7, NET_10, NET_15, NET_20, NET_25, NET_30, NET_45, NET_60, NET_90, OTHER | | | | paymentterm_id | string | | | | lineitems | AccountingLineitem[] | | The invoice's line items: what is billed, with the quantity, price and amount of each | | status | string enum: DRAFT, VOIDED, AUTHORIZED, PAID, PARTIALLY_PAID, PARTIALLY_REFUNDED, REFUNDED, SUBMITTED, DELETED, OVERDUE | | | | url | string | | The public URL for the invoice to send to a customer to view or pay. | | payment_collection_method | string enum: send_invoice, charge_automatically | | When set to charging_automatically, an automated attempt will occur to pay this invoice using the default payment source attached to the contactcustomer. When set to send_invoice, an will email will be sent with this invoice to the contact/customer with payment instructions. | | type | string enum: INVOICE, CREDITMEMO | | BILL removed (use AccountingBill); remaining values: INVOICE, CREDITMEMO | | attachments | AccountingAttachment[] | | | | send | boolean | | | | organization_id | string | | | | payments | AccountingPaymentReference[] | | ead-only reciprocal of PaymentPayment.allocations; payments applied to this invoice | | category_ids | AccountingInvoice_category_ids | | (reference to AccountingCategory) | | project_id | string | | (reference to TaskProject) | | metadata | AccountingMetadata[] | | | | sub_total_amount | number | | Total before tax | | exchange_rate | number | | Exchange rate to the base/home currency at the document date | | is_inclusive_of_tax | boolean | | True when the document amounts already include tax | | salesorder_ids | AccountingInvoice_salesorder_ids | | refs -> AccountingSalesorder; the sales orders this invoice was raised from | | raw | object | | | ### AccountingJournal | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | reference | string | | | | tax_amount | number | | | | currency | string | | | | lineitems | AccountingJournalLineitem[] | | new field name | | taxrate_id | string | | | | description | string | | | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | source | string | | | | organization_id | string | | | | category_ids | AccountingJournal_category_ids | | (reference to AccountingCategory) | | project_id | string | | (reference to TaskProject) | | attachments | AccountingAttachment[] | | | | total_amount | number | | Total of the debit lines; equals the total of the credit lines | | exchange_rate | number | | Exchange rate to the base/home currency at the posting date | | status | string enum: DRAFT, POSTED, VOID, OTHER | | Posting state of the journal entry | | is_inclusive_of_tax | boolean | | True when the line amounts already include tax | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingOrder @deprecated; use AccountingSalesorder or AccountingPurchaseorder | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | contact_id | string | | Customer, Supplier (reference to AccountingContact) | | account_id | string | | (reference to AccountingAccount) | | type | string enum: SALES, PURCHASE | | | | currency | string | | | | total_amount | number | | | | shipping_address | AccountingOrder_shipping_address | | | | billing_address | AccountingOrder_billing_address | | | | status | string enum: DRAFT, VOIDED, AUTHORIZED, PAID, PARTIALLY_PAID, PARTIALLY_REFUNDED, REFUNDED, SUBMITTED, DELETED, OPEN, COMPLETED, CANCELED | | | | lineitems | AccountingLineitem[] | | | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingOrganization | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | legal_name | string | | | | currency | string | | Currency primarily used by the organization, in ISO 4217 format (e.g. U.S. dollars is USD). | | address | AccountingOrganization_address | | | | tax_number | string | | Organization's tax number. For example, in the U.S., this is an Employer Identification Number (EIN). | | timezone | string | | | | website | string | | | | parent_id | string | | | | fiscal_year_end_month | number | | Month of the year when the organization’s fiscal year ends (1 - 12, where January is 1) | | organization_code | string | | Identifier for tracking and categorizing organizations for reporting or analysis purposes that is defined by the end-customer | | type | string enum: COMPANY, SUBSIDIARY, DIVISION, LOCATION | | Entity type within a consolidation hierarchy | | is_elimination | boolean | | True for consolidation/elimination entities | | books_close_at | string (date-time) | | Company-wide books close / lock date; transactions on or before this date fall in a closed accounting period (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingPaymentterm | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | term label, e.g. "Net 30" | | description | string | | | | type | string enum: ON_RECEIPT, NET_7, NET_10, NET_15, NET_20, NET_25, NET_30, NET_45, NET_60, NET_90, OTHER | | | | category | string enum: STANDARD, DATE_DRIVEN, OTHER | | STANDARD (days-based) or DATE_DRIVEN (month-based) | | is_active | boolean | | | | due_days | number | | (standard) days until payment is due | | discount_days | number | | (standard) days within which an early-pay discount applies | | discount_percent | number | | (standard) early-pay discount rate | | day_of_month_due | number | | (date-driven) day of month payment is due (1–31) | | discount_day_of_month | number | | (date-driven) pay before this day of month for the discount | | due_next_month_days | number | | (date-driven) if invoiced within N days of month-end, due next month | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingProfitloss | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | category_ids | AccountingProfitloss_category_ids | | (reference to AccountingCategory) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | currency | string | | | | income_total_amount | number | | | | net_income_amount | number | | | | expenses_total_amount | number | | | | cost_of_goods_sold_total_amount | number | | | | income_sections | AccountingProfitlossSection[] | | | | expenses_sections | AccountingProfitlossSection[] | | | | cost_of_goods_sold_sections | AccountingProfitlossSection[] | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingProject | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | code | string | | provider-side project code/number (e.g. NetSuite entityId, Intacct PROJECTID, BC jobNumber) | | description | string | | | | status | string enum: NOT_STARTED, IN_PROGRESS, ON_HOLD, COMPLETED, CANCELLED, CLOSED | | | | is_active | boolean | | | | is_billable | boolean | | | | contact_id | string | | the customer the project is for (reference to AccountingContact) | | parent_id | string | | parent project / sub-project hierarchy | | manager_user_id | string | | references a HRIS employee/user (reference to HrisEmployee) | | started_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | due_at | string (date-time) | | planned/target end date (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | ended_at | string (date-time) | | actual completion (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | currency | string | | | | estimate_amount | number | | | | estimated_cost_amount | number | | | | actual_cost_amount | number | | | | invoiced_amount | number | | | | to_be_invoiced_amount | number | | | | expense_amount | number | | | | deposit_amount | number | | | | deposit_applied_amount | number | | | | credit_note_amount | number | | | | minutes_logged | number | | integer minutes, not hours | | minutes_to_be_invoiced | number | | integer minutes, not hours | | percent_complete | number | | 0-100 | | billing_type | string enum: FIXED_PRICE, TIME_AND_MATERIALS, MILESTONE, NON_BILLABLE | | | | category_ids | AccountingProject_category_ids | | (reference to AccountingCategory) | | location_id | string | | | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingPurchaseorder | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | contact_id | string | | Customer, Supplier (reference to AccountingContact) | | account_id | string | | (reference to AccountingAccount) | | currency | string | | | | total_amount | number | | | | shipping_address | AccountingPurchaseorder_shipping_address | | | | billing_address | AccountingPurchaseorder_billing_address | | | | status | string enum: DRAFT, VOIDED, AUTHORIZED, PAID, PARTIALLY_PAID, PARTIALLY_REFUNDED, REFUNDED, SUBMITTED, DELETED, OPEN, COMPLETED, CANCELED | | | | lineitems | AccountingLineitem[] | | | | organization_id | string | | | | category_ids | AccountingPurchaseorder_category_ids | | (reference to AccountingCategory) | | metadata | AccountingMetadata[] | | | | attachments | AccountingAttachment[] | | | | raw | object | | | ### AccountingPurchasereceipt | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | | receipt_number | string | | | | contact_id | string | | supplier (reference to AccountingContact) | | purchaseorder_id | string | | header when single-PO | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | status | string enum: POSTED, CANCELLED, OTHER | | | | currency | string | | | | total_amount | number | | | | notes | string | | supplier delivery note / packing slip / memo — NOT a separate reference field | | lineitems | AccountingPurchasereceiptLineitem[] | | | | attachments | AccountingAttachment[] | | | ### AccountingQuote | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | quote_number | string | | External identifier for this quote (Xero QuoteNumber, QBO DocNumber, NetSuite tranId) | | reference | string | | customer-side reference / PO number | | title | string | | short title (Xero Title, max 100) | | summary | string | | longer summary (Xero Summary, max 3000) | | payment_terms | string enum: ON_RECEIPT, NET_7, NET_10, NET_15, NET_20, NET_25, NET_30, NET_45, NET_60, NET_90, OTHER | | unified payment terms enum, as on accounting_invoice / accounting_bill | | customer_message | string | | customer-visible message | | memo | string | | internal note, not customer-visible | | contact_id | string | | (reference to AccountingContact) | | contacts | AccountingTransactionContact[] | | denormalized parties, reusing the transaction contact shape | | issued_at | string (date-time) | | Xero Date, QBO TxnDate (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | expires_at | string (date-time) | | Xero ExpiryDate, QBO ExpirationDate (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | sent_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | accepted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | declined_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | status | string enum: DRAFT, SENT, ACCEPTED, DECLINED, INVOICED, EXPIRED, VOIDED, DELETED | | where EXPIRED is derived (expires_at < now), the source status is preserved in raw | | currency | string | | | | currency_rate | number | | exchange rate to the organization's base currency | | sub_total_amount | number | | | | tax_amount | number | | | | discount_amount | number | | | | total_amount | number | | | | tax_mode | string enum: INCLUSIVE, EXCLUSIVE, NONE | | INCLUSIVE / EXCLUSIVE / NONE; maps Xero LineAmountTypes (a boolean cannot represent NoTax) | | lineitems | AccountingLineitem[] | | | | invoice_id | string | | populated once the quote is converted to an invoice; left null when the source cannot supply it (reference to AccountingInvoice) | | project_id | string | | points to an AccountingProject (accounting costing project) (reference to TaskProject) | | group_id | string | | points to a HRIS Group (reference to HrisGroup) | | category_ids | AccountingQuote_category_ids | | tracking categories / classes (AccountingCategory dimensions) (reference to AccountingCategory) | | url | string | | public online-quote link where exposed | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingSalesorder | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | contact_id | string | | Customer, Supplier (reference to AccountingContact) | | account_id | string | | (reference to AccountingAccount) | | currency | string | | | | total_amount | number | | | | shipping_address | AccountingSalesorder_shipping_address | | | | billing_address | AccountingSalesorder_billing_address | | | | status | string enum: DRAFT, VOIDED, AUTHORIZED, PAID, PARTIALLY_PAID, PARTIALLY_REFUNDED, REFUNDED, SUBMITTED, DELETED, OPEN, COMPLETED, CANCELED | | | | lineitems | AccountingLineitem[] | | | | sales_channel | string | | | | organization_id | string | | | | fees | AccountingFee[] | | | | category_ids | AccountingSalesorder_category_ids | | (reference to AccountingCategory) | | metadata | AccountingMetadata[] | | | | order_number | string | | human-facing receipt/ticket/check number, distinct from id | | location_id | string | | store/site; reference to a commerce_location or hris_location | | employee_user_id | string | | selling/serving HR employee (server, cashier) (reference to HrisEmployee) | | device_id | string | | POS register/terminal/device (HRIS Device) | | fulfillment_type | string enum: DINE_IN, TAKEOUT, DELIVERY, PICKUP, CURBSIDE, SHIPPING, DIGITAL, OTHER | | | | guest_count | number | | | | subtotal_amount | number | | pre-tax, pre-tip amount | | tax_amount | number | | | | discount_amount | number | | | | tip_amount | number | | | | service_charge_amount | number | | | | refunded_amount | number | | | | closed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | subscription_id | string | | payment_subscription that generated this order, if recurring | | payments | AccountingPaymentReference[] | | read-only reciprocal of PaymentPayment.allocations; payments applied to this sales order | | raw | object | | | ### AccountingSalesreceipt | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | raw | object | | | | receipt_number | string | | | | reference | string | | | | contact_id | string | | customer (reference to AccountingContact) | | account_id | string | | deposit-to GL account (reference to AccountingAccount) | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | currency | string | | | | exchange_rate | number | | | | sub_total_amount | number | | | | tax_amount | number | | | | discount_amount | number | | | | total_amount | number | | | | is_inclusive_of_tax | boolean | | | | payment_method | string | | | | notes | string | | | | lineitems | AccountingLineitem[] | | | | billing_address | AccountingSalesreceipt_billing_address | | | | shipping_address | AccountingSalesreceipt_shipping_address | | | | category_ids | AccountingSalesreceipt_category_ids | | (reference to AccountingCategory) | | project_id | string | | (reference to TaskProject) | | attachments | AccountingAttachment[] | | | | status | string enum: DRAFT, POSTED, VOID, OTHER | | | ### AccountingTaxrate | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | rate | number | | % | | is_active | boolean | | | | organization_id | string | | | | metadata | AccountingMetadata[] | | | | effective_rate | number | | Net effective rate in %, where it differs from the nominal rate | | total_rate | number | | Sum of all components, in % | | components | AccountingTaxrateComponent[] | | Component parts of a compound or multi-component tax | | type | string enum: SALES, PURCHASE, BOTH, OTHER | | Whether the rate applies to sales, purchases, or both | | is_compound | boolean | | True when the rate stacks on top of other taxes | | country | string | | Jurisdiction country | | region | string | | Jurisdiction state / province | | raw | object | | | ### AccountingTransaction | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | memo | string | | | | total_amount | number | | negative for CREDIT, positive for DEBIT | | tax_amount | number | | negative for CREDIT, positive for DEBIT | | account_id | string | | (reference to AccountingAccount) | | reference | string | | | | sub_total_amount | number | | | | split_account_id | string | | | | payment_method | string | | | | payment_terms | string | | | | customer_message | string | | | | type | string | | eg. CreditCardCharge, Check, Invoice, ReceivePayment, JournalEntry, Bill, CreditCardCredit, VendorCredit, Credit, BillPaymentCheck, BillPaymentCreditCard, Charge, Transfer, Deposit, BANK_DEPOSIT, BANK_TRANSFER, Statement, BillableCharge, TimeActivity, CashPurchase, SalesReceipt, CreditMemo, CreditRefund, Estimate, InventoryQuantityAdjustment, PurchaseOrder, GlobalTaxPayment, GlobalTaxAdjustment, Service Tax Refund, Service Tax Gross Adjustment, Service Tax Reversal, Service Tax Defer, Service Tax Partial Utilisation | | lineitems | AccountingTransactionLineItem[] | | | | currency | string | | | | contacts | AccountingTransactionContact[] | | | | organization_id | string | | | | category_ids | AccountingTransaction_category_ids | | (reference to AccountingCategory) | | project_id | string | | (reference to TaskProject) | | transaction_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | exchange_rate | number | | Exchange rate to the base/home currency at the transaction date | | status | string enum: PENDING, POSTED, VOID, OTHER | | Posting state of the transaction | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingTrialbalance | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | currency | string | | | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_debit_amount | number | | | | total_credit_amount | number | | | | sub_items | AccountingTrialbalanceSubItem[] | | | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingVendorcredit | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | | | balance_amount | number | | | | account_id | string | | (reference to AccountingAccount) | | currency | string | | | | contact_id | string | | (reference to AccountingContact) | | bill_id | string | | | | notes | string | | | | lineitems | AccountingLineitem[] | | | | status | string enum: DRAFT, VOIDED, AUTHORIZED, PAID, PARTIALLY_PAID, PARTIALLY_REFUNDED, REFUNDED, SUBMITTED, DELETED, OVERDUE | | | | organization_id | string | | | | apply_amount | number | | | | applications | AccountingCreditApplication[] | | What this vendor credit was applied to (invoices/bills). Writable inline on create/update. | | metadata | AccountingMetadata[] | | | | raw | object | | | ### AccountingAccountTaxonomy | Field | Type | Required | Description | |---|---|---|---| | type | string enum: CLASSIFICATION, GROUP, SUBGROUP, SYSTEM_ROLE, OTHER | Yes | | | original_type | string | | | | value | string | Yes | | ### AccountingAgedPeriod | Field | Type | Required | Description | |---|---|---|---| | name | string | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | | | sub_items | AccountingAgedRow[] | | | ### AccountingAgedRow | Field | Type | Required | Description | |---|---|---|---| | contact | AccountingAgedRow_contact | | | | transaction | AccountingAgedRow_transaction | | | | posted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | | | balance_amount | number | | | ### AccountingAgedRow_contact | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | ### AccountingAgedRow_transaction | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | ### AccountingAssociatedContact | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | | emails | AccountingEmail[] | | | ### AccountingAttachment | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | download_url | string | | | | name | string | | | | mime_type | string | | | ### AccountingBalancesheetItem | Field | Type | Required | Description | |---|---|---|---| | account_id | string | | (reference to AccountingAccount) | | name | string | | | | amount | number | | | | sub_items | AccountingBalancesheetItem_sub_items | | | ### AccountingBankfeedtransaction_category_ids dimension refs -> AccountingCategory (class/department/location) ### AccountingBill_category_ids ### AccountingBill_purchaseorder_ids refs -> AccountingPurchaseorder; the purchase orders this bill was raised from ### AccountingCashflowItem | Field | Type | Required | Description | |---|---|---|---| | account_id | string | | If attributable to a specific GL account (reference to AccountingAccount) | | name | string | | e.g. "Net Income", "Depreciation", "Equipment" | | amount | number | | Positive = inflow, Negative = outflow | | transaction_ids | AccountingCashflowItem_transaction_ids | | Optional linkage to transactions | | sub_items | AccountingCashflowItem_sub_items | | | ### AccountingCashflowItem_transaction_ids Optional linkage to transactions ### AccountingCashflowSection | Field | Type | Required | Description | |---|---|---|---| | section_name | string | | e.g. "Operating Activities" | | total_amount | number | | Net cash providedused by this section | | items | AccountingCashflowItem[] | | | ### AccountingCashflow_category_ids ### AccountingContactPaymentMethod | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | type | string enum: ACH, ALIPAY, CARD, GIROPAY, IDEAL, OTHER, PAYPAL, WIRE, CHECK | Yes | | | name | string | | | | default | boolean | | | ### AccountingContact_billing_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingContact_shipping_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingCreditApplication | Field | Type | Required | Description | |---|---|---|---| | object_type | string enum: INVOICE, BILL | | The type of object this credit was applied to | | object_id | string | | The id of the object this credit was applied to | | amount | number | | The amount of credit applied to this object | | applied_at | string (date-time) | | Date and time when the credit was applied, in ISO 8601 format and UTC (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### AccountingEmail | Field | Type | Required | Description | |---|---|---|---| | email | string | | | | type | string enum: WORK, HOME, OTHER | | | ### AccountingExpense_category_ids ### AccountingExpensereport_category_ids ### AccountingExtendedNote | Field | Type | Required | Description | |---|---|---|---| | note | string | | | | user | AccountingExtendedNote_user | | | | date | string (date-time) | | | | visibility | string enum: PUBLIC, PRIVATE | | | ### AccountingExtendedNote_user | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | ### AccountingFee | Field | Type | Required | Description | |---|---|---|---| | type | string enum: TAX, DISCOUNT, PROMOTION, SHIPPING, GIFT_WRAP, COD, SURCHARGE, TIP, SERVICE_CHARGE, OTHER | Yes | Unified fee category | | original_type | string | | The provider's original fee label, e.g. "VAT" or a shipping carrier name | | amount | number | Yes | | | currency | string | | ISO 4217 currency code, e.g. USD | ### AccountingInvoice_category_ids ### AccountingInvoice_salesorder_ids refs -> AccountingSalesorder; the sales orders this invoice was raised from ### AccountingJournalLineitem | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | tax_amount | number | | | | total_amount | number | | will be a positive value for a debit and negative for a credit | | debit_amount | number | | will replace total_amount (absolute value) | | credit_amount | number | | | | description | string | | | | account_id | string | | (reference to AccountingAccount) | | contact_id | string | | (reference to AccountingContact) | | payment_id | string | | link to PaymentPayment (reference to PaymentPayment) | | invoice_id | string | | (reference to AccountingInvoice) | | category_ids | AccountingJournalLineitem_category_ids | | (reference to AccountingCategory) | | group_id | string | | points to a HRIS Group (reference to HrisGroup) | | project_id | string | | (reference to TaskProject) | | organization_id | string | | points to a AccountingOrganization | | taxrate_id | string | | ref -> AccountingTaxrate applied to the line | ### AccountingJournalLineitem_category_ids ### AccountingJournal_category_ids ### AccountingLineitem | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | refunded_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | unit_quantity * unit_amount + tax_amount | | refund_amount | number | | | | discount_amount | number | | | | tax_amount | number | | | | item_id | string | | (reference to CommerceItem) | | unit_amount | number | | | | unit_quantity | number | | | | item_sku | string | | | | item_name | string | | | | item_description | string | | | | notes | string | | | | taxrate_id | string | | | | account_id | string | | (reference to AccountingAccount) | | category_ids | AccountingLineitem_category_ids | | (reference to AccountingCategory) | | locations | AccountingReference[] | | | | item_variants | AccountingReference[] | | | | fees | AccountingFee[] | | | | contact_id | string | | (reference to AccountingContact) | | project_id | string | | ref -> AccountingProject; the project this line is coded to (reference to TaskProject) | | exchange_rate | number | | The exchange rate applied to this line, for multi-currency documents | | is_billable | boolean | | True when the line is rebillable to a customer | ### AccountingLineitem_category_ids ### AccountingMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The ID of the custom field definition (from the metadata/metadata endpoint). | | slug | string | | The integration's API name (key) of an existing custom field. List valid slugs for this object with the metadata/metadata endpoint; unknown slugs are rejected by the integration. | | value | object | | The field's value, typed by `format`: DATE as ISO-8601 (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM:SSZ` for date-times); NUMBER, PRICE and CURRENCY as a number; BOOLEAN and YES_NO as true/false; MULTIPLE_SELECT as an array of option values; otherwise a string. | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | The data type of the custom field, which determines how `value` is formatted. | | extra_data | object | | | ### AccountingOrder_billing_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingOrder_shipping_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingOrganization_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingPaymentReference | Field | Type | Required | Description | |---|---|---|---| | payment_id | string | | references a PaymentPayment that was applied to this object (reference to PaymentPayment) | | amount | number | | amount of the payment applied to this object | | allocated_at | string (date-time) | | Date and time when the payment was applied, in ISO 8601 format and UTC (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### AccountingProfitlossAccount | Field | Type | Required | Description | |---|---|---|---| | account_id | string | | (reference to AccountingAccount) | | account_name | string | | | | total_amount | number | | | | transaction_ids | AccountingProfitlossAccount_transaction_ids | | | ### AccountingProfitlossAccount_transaction_ids ### AccountingProfitlossSection | Field | Type | Required | Description | |---|---|---|---| | section_type | string | | | | section_name | string | | | | total_amount | number | | | | accounts | AccountingProfitlossAccount[] | | | ### AccountingProfitloss_category_ids ### AccountingProject_category_ids ### AccountingPurchaseorder_billing_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingPurchaseorder_category_ids ### AccountingPurchaseorder_shipping_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingPurchasereceiptLineitem | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | item_id | string | | (reference to CommerceItem) | | item_sku | string | | | | item_name | string | | | | item_description | string | | | | unit_quantity_received | number | | | | unit_quantity_ordered | number | | | | unit_amount | number | | | | total_amount | number | | | | purchaseorder_id | string | | | | purchaseorder_line_identifier | string | | vendor PO line number for three-way match | | account_id | string | | (reference to AccountingAccount) | | locations | AccountingReference[] | | plant/warehouse/bin — same shape as IAccountingLineitem.locations | ### AccountingQuote_category_ids tracking categories / classes (AccountingCategory dimensions) ### AccountingReference | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | ### AccountingSalesorder_billing_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingSalesorder_category_ids ### AccountingSalesorder_shipping_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingSalesreceipt_billing_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingSalesreceipt_category_ids ### AccountingSalesreceipt_shipping_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AccountingTaxrateComponent | Field | Type | Required | Description | |---|---|---|---| | name | string | | Name of the tax component, e.g. GST or PST | | rate | number | | Rate of this component, in % | | is_compound | boolean | | True when this component is charged on top of the other components | ### AccountingTelephone | Field | Type | Required | Description | |---|---|---|---| | telephone | string | | | | type | string enum: WORK, HOME, OTHER, FAX, MOBILE | | | ### AccountingTransactionContact | Field | Type | Required | Description | |---|---|---|---| | id | string | Yes | | | is_customer | boolean | | | | is_supplier | boolean | | | ### AccountingTransactionLineItem | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | unit_quantity | number | | | | unit_amount | number | | | | total_amount | number | | will be a positive value for a debit and negative for a credit | | account_id | string | | (reference to AccountingAccount) | | object_type | string | | | | name | string | | | | description | string | | | | category_ids | AccountingTransactionLineItem_category_ids | | (reference to AccountingCategory) | | taxrate_id | string | | ref -> AccountingTaxrate applied to the line | ### AccountingTransactionLineItem_category_ids ### AccountingTransaction_category_ids ### AccountingTrialbalanceSubItem | Field | Type | Required | Description | |---|---|---|---| | amount | number | | | | account_id | string | | (reference to AccountingAccount) | | account_name | string | | | --- # Ads API – Endpoints & Data Models ## Ads API data models URL: https://docs.unified.to/ads/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /ads/{connection_id}/organization | Create an organization | | GET | /ads/{connection_id}/organization | List all organizations | | GET | /ads/{connection_id}/organization/{id} | Retrieve an organization | | PUT | /ads/{connection_id}/organization/{id} | Update an organization | | DELETE | /ads/{connection_id}/organization/{id} | Remove an organization | | POST | /ads/{connection_id}/campaign | Create a campaign | | GET | /ads/{connection_id}/campaign | List all campaigns | | GET | /ads/{connection_id}/campaign/{id} | Retrieve a campaign | | PUT | /ads/{connection_id}/campaign/{id} | Update a campaign | | DELETE | /ads/{connection_id}/campaign/{id} | Remove a campaign | | POST | /ads/{connection_id}/creative | Create a creative | | GET | /ads/{connection_id}/creative | List all creatives | | GET | /ads/{connection_id}/creative/{id} | Retrieve a creative | | PUT | /ads/{connection_id}/creative/{id} | Update a creative | | DELETE | /ads/{connection_id}/creative/{id} | Remove a creative | | POST | /ads/{connection_id}/asset | Create an asset | | GET | /ads/{connection_id}/asset | List all assets | | GET | /ads/{connection_id}/asset/{id} | Retrieve an asset | | POST | /ads/{connection_id}/insertionorder | Create an insertionorder | | GET | /ads/{connection_id}/insertionorder | List all insertionorders | | GET | /ads/{connection_id}/insertionorder/{id} | Retrieve an insertionorder | | PUT | /ads/{connection_id}/insertionorder/{id} | Update an insertionorder | | DELETE | /ads/{connection_id}/insertionorder/{id} | Remove an insertionorder | | POST | /ads/{connection_id}/group | Create a group | | GET | /ads/{connection_id}/group | List all groups | | GET | /ads/{connection_id}/group/{id} | Retrieve a group | | PUT | /ads/{connection_id}/group/{id} | Update a group | | DELETE | /ads/{connection_id}/group/{id} | Remove a group | | POST | /ads/{connection_id}/ad | Create an ad | | GET | /ads/{connection_id}/ad | List all ads | | GET | /ads/{connection_id}/ad/{id} | Retrieve an ad | | PUT | /ads/{connection_id}/ad/{id} | Update an ad | | DELETE | /ads/{connection_id}/ad/{id} | Remove an ad | | GET | /ads/{connection_id}/report | List all reports | | GET | /ads/{connection_id}/target/{id} | Retrieve a target | | GET | /ads/{connection_id}/target | List all targets | | GET | /ads/{connection_id}/promoted/{id} | Retrieve a promoted | | GET | /ads/{connection_id}/promoted | List all promoteds | #### Data Models ### AdsAd | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The ad's name | | campaign_id | string | | | | group_id | string | | (reference to AdsGroup) | | organization_id | string | | | | status | string enum: UNSPECIFIED, ACTIVE, PAUSED, ARCHIVED, DRAFT, SCHEDULED_FOR_DELETION, PROCESSING, PROCESSING_FAILED | | | | ad_type | string enum: TEXT, IMAGE, VIDEO, RESPONSIVE, SHOPPING, APP, CALL, CAROUSEL, SOCIAL, DISPLAY, SEARCH, AUDIO, YOUTUBE, NATIVE, CTV, DOOH, PERFORMANCE_MAX | | | | advertiser_name | string | | | | creative_ids | AdsAd_creative_ids | | | | creative_asset_url | string | | | | ad_copy | string | | | | headline | string | | | | description | string | | | | cta | string | | | | final_url | string | | | | display_url | string | | | | path1 | string | | | | path2 | string | | | | promoted | AdsPromoted[] | | | | logo_creative_id | string | | | | metadata | AdsMetadata[] | | | | raw | object | | | ### AdsAsset | Field | Type | Required | Description | |---|---|---|---| | id | string | | provider asset resource name/id (e.g. Google Ads customers/{cid}/assets/{id}, TikTok image/video id, DV360 mediaId) | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | organization_id | string | | | | name | string | | | | type | string enum: IMAGE, VIDEO, YOUTUBE_VIDEO, MEDIA_BUNDLE, TEXT | | | | url | string | | outbound: public asset URL; inbound (create): source URL to fetch bytes from when content is not supplied | | content | string | | inbound create-only: base64-encoded image/media bytes to upload | | mime_type | string | | | | width | number | | | | height | number | | | | file_size | number | | | | metadata | AdsMetadata[] | | | | raw | object | | | ### AdsCampaign | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The campaign's name | | organization_id | string | | The ID of the ad account the campaign belongs to (an AdsOrganization ID) | | status | string enum: UNSPECIFIED, ACTIVE, PAUSED, ARCHIVED, DRAFT, SCHEDULED_FOR_DELETION, PROCESSING, PROCESSING_FAILED | | | | effective_status | string enum: UNSPECIFIED, SERVING, LIMITED, LEARNING, PAUSED, PENDING, ENDED, MISCONFIGURED, NOT_ELIGIBLE, ARCHIVED, REMOVED | | read-only; controlled by the provider (whether/why the campaign can serve), unlike the user-managed status | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | budget_amount | number | | | | budget_period | string enum: DAILY, MONTHLY, TOTAL, LIFETIME | | | | budget_unit | string enum: UNSPECIFIED, CURRENCY, IMPRESSIONS | | | | total_spend_amount | number | | | | targeting | AdsCampaign_targeting | | | | goal | string enum: UNSPECIFIED, BRAND_AWARENESS, ENGAGEMENT, REACH, WEBSITE_TRAFFIC, LEADS, SALES, APP_PROMOTION | | | | planned_spend_amount | number | | | | frequency_cap | AdsCampaign_frequency_cap | | | | advertising_channel_type | string enum: TEXT, IMAGE, VIDEO, RESPONSIVE, SHOPPING, APP, CALL, CAROUSEL, SOCIAL, DISPLAY, SEARCH, AUDIO, YOUTUBE, NATIVE, CTV, DOOH, PERFORMANCE_MAX | | | | campaign_budget_identifier | string | | Resource name for existing/shared budget (Google) | | currency | string | | | | category | string | | Housing, employment, credit, NONE (Meta) | | has_eu_political_ads | boolean | | | | labels | AdsCampaign_labels | | | | kpi | AdsCampaign_kpi | | | | metadata | AdsMetadata[] | | | | raw | object | | | ### AdsCreative | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The creative's name | | campaign_id | string | | | | group_id | string | | (reference to AdsGroup) | | item_id | string | | references Commerce Item ID (reference to CommerceItem) | | organization_id | string | | | | status | string enum: UNSPECIFIED, ACTIVE, PAUSED, ARCHIVED, DRAFT, SCHEDULED_FOR_DELETION, PROCESSING, PROCESSING_FAILED | | | | creative_type | string enum: UNSPECIFIED, STANDARD, EXPANDABLE, VIDEO, NATIVE, AUDIO, PUBLISHER_HOSTED, ASSET_BASED, IMAGE, DOCUMENT, SHARE | | | | hosting_source | string enum: UNSPECIFIED, CM, THIRD_PARTY, HOSTED, RICH_MEDIA, PUBLISHER_HOSTED | | | | width | number | | | | height | number | | | | asset_urls | AdsCreative_asset_urls | | Hosted asset mode (best-effort, provider-specific | | cards | AdsCreativeCard[] | | Carousel cards, in display order | | link_url | string | | Destination URL for creatives with links | | url_tags | string | | Appended URL tracking params (e.g. utm_source=..&utm_medium=..); Meta url_tags, Google tracking template | | body | string | | | | title | string | | | | cta | string | | | | third_party_tag | string | | | | vast_tag_url | string | | | | external_creative_reference | string | | | | external_placement_reference | string | | | | external_ad_reference | string | | | | promoted | AdsPromoted[] | | | | path1 | string | | Display URL path 1 (Microsoft RSA Path1) | | path2 | string | | Display URL path 2 (Microsoft RSA Path2) | | data | string | | | | labels | AdsCreative_labels | | | | metadata | AdsMetadata[] | | | | raw | object | | | ### AdsGroup | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The ad group's name | | parent_id | string | | | | campaign_id | string | | The ID of the campaign the ad group belongs to (an AdsCampaign ID) | | organization_id | string | | | | insertionorder_id | string | | | | status | string enum: UNSPECIFIED, ACTIVE, PAUSED, ARCHIVED, DRAFT, SCHEDULED_FOR_DELETION, PROCESSING, PROCESSING_FAILED | | | | effective_status | string enum: UNSPECIFIED, SERVING, LIMITED, LEARNING, PAUSED, PENDING, ENDED, MISCONFIGURED, NOT_ELIGIBLE, ARCHIVED, REMOVED | | | | targeting | AdsGroup_targeting | | | | bid_amount | number | | | | bid_strategy | AdsGroup_bid_strategy | | YOUTUBE_AND_PARTNERS | | budget_amount | number | | | | budget_period | string enum: DAILY, MONTHLY, TOTAL, LIFETIME | | | | budget_allocation_type | string enum: UNSPECIFIED, AUTOMATIC, FIXED, UNLIMITED | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | budget_unit | string enum: UNSPECIFIED, CURRENCY, IMPRESSIONS | | | | budget_max_amount | number | | | | type | string enum: TEXT, IMAGE, VIDEO, RESPONSIVE, SHOPPING, APP, CALL, CAROUSEL, SOCIAL, DISPLAY, SEARCH, AUDIO, YOUTUBE, NATIVE, CTV, DOOH, PERFORMANCE_MAX | | | | has_eu_political_ads | boolean | | | | pacing | AdsGroup_pacing | | | | frequency_cap | AdsGroup_frequency_cap | | | | creative_ids | AdsGroup_creative_ids | | | | optimization_goal | string enum: REACH, IMPRESSIONS, LINK_CLICKS, LANDING_PAGE_VIEWS, CONVERSIONS, LEAD_GENERATION, APP_INSTALLS, APP_ENGAGEMENT, VIDEO_VIEWS, ENGAGEMENT, PAGE_LIKES, MESSAGES | | Optimization goals for ads_group (cross-platform; platform-specific values allowed as pass-through) | | billing_event | string enum: IMPRESSIONS, LINK_CLICKS, VIDEO_VIEWS, APP_INSTALLS, ENGAGEMENT, PAGE_LIKES, MESSAGES, POST_ENGAGEMENT, PURCHASE, NONE | | Billing events for ads_group (what you pay for; cross-platform; platform-specific values allowed as pass-through) | | currency | string | | | | promoted | AdsPromoted[] | | | | language_locale | string | | Language + region, ISO 639-1 with region (e.g. en-US); LinkedIn locale | | creative_selection | string enum: OPTIMIZED, ROUND_ROBIN | | Creative rotation strategy (LinkedIn creativeSelection) | | metadata | AdsMetadata[] | | | | raw | object | | | ### AdsInsertionorder | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | organization_id | string | | | | campaign_id | string | | | | status | string enum: UNSPECIFIED, ACTIVE, PAUSED, ARCHIVED, DRAFT, SCHEDULED_FOR_DELETION, PROCESSING, PROCESSING_FAILED | | | | pacing | AdsInsertionorder_pacing | | | | frequency_cap | AdsInsertionorder_frequency_cap | | | | kpi | AdsInsertionorder_kpi | | | | budget_unit | string enum: UNSPECIFIED, CURRENCY, IMPRESSIONS | | | | budget_segments | AdsInsertionorderBudgetSegment[] | | | | bid_strategy | AdsInsertionorder_bid_strategy | | YOUTUBE_AND_PARTNERS | | reference | string | | client's reference ID for the insertion order | | metadata | AdsMetadata[] | | | | raw | object | | | ### AdsOrganization | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | currency | string | | | | timezone | string | | | | parent_id | string | | | | status | string enum: UNSPECIFIED, ACTIVE, PAUSED, ARCHIVED, DRAFT, SCHEDULED_FOR_DELETION, PROCESSING, PROCESSING_FAILED | | | | account_number | string | | Integration-specific account number distinct from id (Microsoft AccountNumber) | | managers | AdsManager[] | | Manager/agency chain, top-most manager first, closest manager last (SA360 manager/sub_manager) | | metadata | AdsMetadata[] | | | | raw | object | | | ### AdsPromoted Promoted entity (e.g. page, app, product, tweet) for ads create | Field | Type | Required | Description | |---|---|---|---| | id | string | Yes | | | name | string | | | | type | string enum: PAGE_ID, APP_ID, STORE_URL, PIXEL_ID, CUSTOM_CONVERSION_ID, CATALOG_ID, PRODUCT_SET_ID, PRIORITIZED_SET_ID, EVENT_ID, OFFER_ID, LEAD_FORM_ID, MESSAGING_CHANNEL_ID, PRODUCT_ID, TWEET_ID, AD_GROUP_TYPE | Yes | Promoted entity types for ads_promoted list endpoint | | metadata | AdsMetadata[] | | | | raw | object | | | ### AdsTarget Targeting search result (for ads_target list endpoint) | Field | Type | Required | Description | |---|---|---|---| | id | string | Yes | | | name | string | | | | type | string enum: INTEREST, BEHAVIOR, LOCALE, COUNTRY, REGION, CITY, ZIP, US_DMA, TOPIC, USER_LIST, CARRIER, DEVICE_MODEL, OS_VERSION | | Targeting search types for ads_target list endpoint (Meta: adinterest, adbehavior, adlocale, adgeolocation; Google: geoTargetConstants, user_interest, topic_constant, language_constant) | | is_active | boolean | | | | parent_id | string | | | | audience_count_min | number | | | | audience_count_max | number | | | | metadata | AdsMetadata[] | | | | raw | object | | | ### AdSchedule | Field | Type | Required | Description | |---|---|---|---| | day_of_week | string enum: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY | Yes | | | start_hour | number | Yes | 0-23 | | start_minute | number | | 0, 15, 30, 45 | | end_hour | number | Yes | 0-24 (24 = end of day) | | end_minute | number | | 0, 15, 30, 45 | | bid_modifier | number | | 0.1-10.0 | ### AdsAd_creative_ids ### AdsCampaign_frequency_cap | Field | Type | Required | Description | |---|---|---|---| | is_unlimited | boolean | | | | time_unit | string enum: UNSPECIFIED, LIFETIME, MONTHS, WEEKS, DAYS, HOURS, MINUTES | | | | time_unit_count | number | | | | max_impressions | number | | | | max_views | number | | | ### AdsCampaign_kpi | Field | Type | Required | Description | |---|---|---|---| | type | string enum: UNSPECIFIED, CPM, CPC, CPA, CTR, VIEWABILITY, CPIAVC, CPE, CPV, CLICK_CVR, IMPRESSION_CVR, VCPM, VTR, AUDIO_COMPLETION_RATE, VIDEO_COMPLETION_RATE, CPCL, CPCV, TOS10, MAXIMIZE_PACING, CUSTOM_IMPRESSION_VALUE_OVER_COST, OTHER | | | | amount | number | | | | percentage | number | | | | string_value | string | | | | algorithm_id | string | | | ### AdsCampaign_labels ### AdsCampaign_targeting | Field | Type | Required | Description | |---|---|---|---| | geographic | AdsCampaign_targeting_geographic | | | | demographic | AdsCampaign_targeting_demographic | | Demographic targeting (Meta: age_min, age_max, genders) | | audience | AdsCampaign_targeting_audience | | | | placement | AdsCampaign_targeting_placement | | | | device | AdsCampaign_targeting_device | | | | language | TargetRef[] | | | | content | AdsCampaign_targeting_content | | | | brand_safety | AdsCampaign_targeting_brand_safety | | Brand safety (Meta: excluded_publisher_categories, etc.; Google | | schedule | AdSchedule[] | | | | optimization | AdsCampaign_targeting_optimization | | Optimization (Meta: targeting_automation; Google: observation vs targeting mode) | ### AdsCampaign_targeting_audience | Field | Type | Required | Description | |---|---|---|---| | custom_audiences | TargetRef[] | | | | excluded_custom_audiences | TargetRef[] | | | | lookalike_audiences | LookalikeAudience[] | | | | interests | TargetRef[] | | | | excluded_interests | TargetRef[] | | | | behaviors | TargetRef[] | | | | excluded_behaviors | TargetRef[] | | | | combination_spec | AudienceCombination[] | | | ### AdsCampaign_targeting_brand_safety Brand safety (Meta: excluded_publisher_categories, etc.; Google | Field | Type | Required | Description | |---|---|---|---| | excluded_publisher_categories | AdsCampaign_targeting_brand_safety_excluded_publisher_categories | | | | excluded_content_labels | AdsCampaign_targeting_brand_safety_excluded_content_labels | | Google Ads ContentLabelType | | brand_safety_content_filter_levels | AdsCampaign_targeting_brand_safety_brand_safety_content_filter_levels | | | | publisher_visibility_categories | AdsCampaign_targeting_brand_safety_publisher_visibility_categories | | | | block_list_ids | AdsCampaign_targeting_brand_safety_block_list_ids | | | ### AdsCampaign_targeting_brand_safety_block_list_ids ### AdsCampaign_targeting_brand_safety_brand_safety_content_filter_levels ### AdsCampaign_targeting_brand_safety_excluded_content_labels Google Ads ContentLabelType ### AdsCampaign_targeting_brand_safety_excluded_publisher_categories ### AdsCampaign_targeting_brand_safety_publisher_visibility_categories ### AdsCampaign_targeting_content | Field | Type | Required | Description | |---|---|---|---| | keywords | AdsKeyword[] | | | | excluded_keywords | AdsKeyword[] | | | | topics | TargetRef[] | | | | excluded_topics | TargetRef[] | | | | urls | AdsCampaign_targeting_content_urls | | | | excluded_urls | AdsCampaign_targeting_content_excluded_urls | | | | video | AdsCampaign_targeting_content_video | | | ### AdsCampaign_targeting_content_excluded_urls ### AdsCampaign_targeting_content_urls ### AdsCampaign_targeting_content_video | Field | Type | Required | Description | |---|---|---|---| | youtube_videos | AdsCampaign_targeting_content_video_youtube_videos | | | | excluded_youtube_videos | AdsCampaign_targeting_content_video_excluded_youtube_videos | | | | youtube_channels | AdsCampaign_targeting_content_video_youtube_channels | | | | excluded_youtube_channels | AdsCampaign_targeting_content_video_excluded_youtube_channels | | | | positions | AdsCampaign_targeting_content_video_positions | | | | player_sizes | AdsCampaign_targeting_content_video_player_sizes | | | | durations | AdsCampaign_targeting_content_video_durations | | | ### AdsCampaign_targeting_content_video_durations ### AdsCampaign_targeting_content_video_excluded_youtube_channels ### AdsCampaign_targeting_content_video_excluded_youtube_videos ### AdsCampaign_targeting_content_video_player_sizes ### AdsCampaign_targeting_content_video_positions ### AdsCampaign_targeting_content_video_youtube_channels ### AdsCampaign_targeting_content_video_youtube_videos ### AdsCampaign_targeting_demographic Demographic targeting (Meta: age_min, age_max, genders) | Field | Type | Required | Description | |---|---|---|---| | age_min | number | | | | age_max | number | | | | male | boolean | | | | female | boolean | | | ### AdsCampaign_targeting_device | Field | Type | Required | Description | |---|---|---|---| | types | TargetRef[] | | | | user_device | TargetRef[] | | | | user_os | TargetRef[] | | | | carriers | TargetRef[] | | | ### AdsCampaign_targeting_geographic | Field | Type | Required | Description | |---|---|---|---| | countries | TargetRef[] | | | | regions | TargetRef[] | | | | cities | CityTarget[] | | | | postal_codes | TargetRef[] | | | | us_dmas | TargetRef[] | | | | excluded_countries | TargetRef[] | | | | excluded_regions | TargetRef[] | | | | excluded_cities | CityTarget[] | | | | excluded_postal_codes | TargetRef[] | | | | excluded_us_dmas | TargetRef[] | | | | location_types | AdsCampaign_targeting_geographic_location_types | | | | presence_type | string enum: PRESENCE, PRESENCE_OR_INTEREST | | | ### AdsCampaign_targeting_geographic_location_types ### AdsCampaign_targeting_optimization Optimization (Meta: targeting_automation; Google: observation vs targeting mode) | Field | Type | Required | Description | |---|---|---|---| | mode | string enum: TARGETING, OBSERVATION | | Google: bid_only falsetrue | | advantage_audience | boolean | | | | advantage_placements | boolean | | | | targeting_optimization_expansion_all | boolean | | | ### AdsCampaign_targeting_placement | Field | Type | Required | Description | |---|---|---|---| | platforms | AdsCampaign_targeting_placement_platforms | | | | facebook_positions | AdsCampaign_targeting_placement_facebook_positions | | | | instagram_positions | AdsCampaign_targeting_placement_instagram_positions | | | | messenger_positions | AdsCampaign_targeting_placement_messenger_positions | | | | audience_network_positions | AdsCampaign_targeting_placement_audience_network_positions | | | ### AdsCampaign_targeting_placement_audience_network_positions ### AdsCampaign_targeting_placement_facebook_positions ### AdsCampaign_targeting_placement_instagram_positions ### AdsCampaign_targeting_placement_messenger_positions ### AdsCampaign_targeting_placement_platforms ### AdsCreativeCard | Field | Type | Required | Description | |---|---|---|---| | asset_url | string | | Same format as asset_urls (provider asset ref or URL) | | title | string | | | | description | string | | | | link_url | string | | | | cta | string | | | ### AdsCreative_asset_urls Hosted asset mode (best-effort, provider-specific ### AdsCreative_labels ### AdsGroup_bid_strategy YOUTUBE_AND_PARTNERS | Field | Type | Required | Description | |---|---|---|---| | type | string enum: FIXED_BID, MAXIMIZE_SPEND, PERFORMANCE_GOAL, YOUTUBE_AND_PARTNERS | Yes | | | fixed_bid_amount | number | | | | performance_goal_type | string enum: UNSPECIFIED, CPA, CPC, VIEWABLE_CPM, CUSTOM_ALGO, CIVA, IVO_TEN, AV_VIEWED, REACH | | | | performance_goal_amount | number | | | | max_average_cpm_bid_amount | number | | | | custom_bidding_algorithm_id | string | | | | raise_bid_for_deals | boolean | | | | youtube_and_partners_type | string enum: UNSPECIFIED, MANUAL_CPV, MANUAL_CPM, TARGET_CPA, TARGET_CPM, RESERVE_CPM, MAXIMIZE_LIFT, MAXIMIZE_CONVERSIONS, TARGET_CPV, TARGET_ROAS, MAXIMIZE_CONVERSION_VALUE | | | | youtube_and_partners_value | string | | | | target_roas | number | | Target ROAS e.g. 2.5 = 250% (Google) | ### AdsGroup_creative_ids ### AdsGroup_frequency_cap | Field | Type | Required | Description | |---|---|---|---| | is_unlimited | boolean | | | | time_unit | string enum: UNSPECIFIED, LIFETIME, MONTHS, WEEKS, DAYS, HOURS, MINUTES | | | | time_unit_count | number | | | | max_impressions | number | | | | max_views | number | | | ### AdsGroup_pacing | Field | Type | Required | Description | |---|---|---|---| | period | string enum: UNSPECIFIED, DAILY, FLIGHT | | | | type | string | | | | daily_max_amount | number | | | | daily_max_impressions | number | | | ### AdsGroup_targeting | Field | Type | Required | Description | |---|---|---|---| | geographic | AdsGroup_targeting_geographic | | | | demographic | AdsGroup_targeting_demographic | | Demographic targeting (Meta: age_min, age_max, genders) | | audience | AdsGroup_targeting_audience | | | | placement | AdsGroup_targeting_placement | | | | device | AdsGroup_targeting_device | | | | language | TargetRef[] | | | | content | AdsGroup_targeting_content | | | | brand_safety | AdsGroup_targeting_brand_safety | | Brand safety (Meta: excluded_publisher_categories, etc.; Google | | schedule | AdSchedule[] | | | | optimization | AdsGroup_targeting_optimization | | Optimization (Meta: targeting_automation; Google: observation vs targeting mode) | ### AdsGroup_targeting_audience | Field | Type | Required | Description | |---|---|---|---| | custom_audiences | TargetRef[] | | | | excluded_custom_audiences | TargetRef[] | | | | lookalike_audiences | LookalikeAudience[] | | | | interests | TargetRef[] | | | | excluded_interests | TargetRef[] | | | | behaviors | TargetRef[] | | | | excluded_behaviors | TargetRef[] | | | | combination_spec | AudienceCombination[] | | | ### AdsGroup_targeting_brand_safety Brand safety (Meta: excluded_publisher_categories, etc.; Google | Field | Type | Required | Description | |---|---|---|---| | excluded_publisher_categories | AdsGroup_targeting_brand_safety_excluded_publisher_categories | | | | excluded_content_labels | AdsGroup_targeting_brand_safety_excluded_content_labels | | Google Ads ContentLabelType | | brand_safety_content_filter_levels | AdsGroup_targeting_brand_safety_brand_safety_content_filter_levels | | | | publisher_visibility_categories | AdsGroup_targeting_brand_safety_publisher_visibility_categories | | | | block_list_ids | AdsGroup_targeting_brand_safety_block_list_ids | | | ### AdsGroup_targeting_brand_safety_block_list_ids ### AdsGroup_targeting_brand_safety_brand_safety_content_filter_levels ### AdsGroup_targeting_brand_safety_excluded_content_labels Google Ads ContentLabelType ### AdsGroup_targeting_brand_safety_excluded_publisher_categories ### AdsGroup_targeting_brand_safety_publisher_visibility_categories ### AdsGroup_targeting_content | Field | Type | Required | Description | |---|---|---|---| | keywords | AdsKeyword[] | | | | excluded_keywords | AdsKeyword[] | | | | topics | TargetRef[] | | | | excluded_topics | TargetRef[] | | | | urls | AdsGroup_targeting_content_urls | | | | excluded_urls | AdsGroup_targeting_content_excluded_urls | | | | video | AdsGroup_targeting_content_video | | | ### AdsGroup_targeting_content_excluded_urls ### AdsGroup_targeting_content_urls ### AdsGroup_targeting_content_video | Field | Type | Required | Description | |---|---|---|---| | youtube_videos | AdsGroup_targeting_content_video_youtube_videos | | | | excluded_youtube_videos | AdsGroup_targeting_content_video_excluded_youtube_videos | | | | youtube_channels | AdsGroup_targeting_content_video_youtube_channels | | | | excluded_youtube_channels | AdsGroup_targeting_content_video_excluded_youtube_channels | | | | positions | AdsGroup_targeting_content_video_positions | | | | player_sizes | AdsGroup_targeting_content_video_player_sizes | | | | durations | AdsGroup_targeting_content_video_durations | | | ### AdsGroup_targeting_content_video_durations ### AdsGroup_targeting_content_video_excluded_youtube_channels ### AdsGroup_targeting_content_video_excluded_youtube_videos ### AdsGroup_targeting_content_video_player_sizes ### AdsGroup_targeting_content_video_positions ### AdsGroup_targeting_content_video_youtube_channels ### AdsGroup_targeting_content_video_youtube_videos ### AdsGroup_targeting_demographic Demographic targeting (Meta: age_min, age_max, genders) | Field | Type | Required | Description | |---|---|---|---| | age_min | number | | | | age_max | number | | | | male | boolean | | | | female | boolean | | | ### AdsGroup_targeting_device | Field | Type | Required | Description | |---|---|---|---| | types | TargetRef[] | | | | user_device | TargetRef[] | | | | user_os | TargetRef[] | | | | carriers | TargetRef[] | | | ### AdsGroup_targeting_geographic | Field | Type | Required | Description | |---|---|---|---| | countries | TargetRef[] | | | | regions | TargetRef[] | | | | cities | CityTarget[] | | | | postal_codes | TargetRef[] | | | | us_dmas | TargetRef[] | | | | excluded_countries | TargetRef[] | | | | excluded_regions | TargetRef[] | | | | excluded_cities | CityTarget[] | | | | excluded_postal_codes | TargetRef[] | | | | excluded_us_dmas | TargetRef[] | | | | location_types | AdsGroup_targeting_geographic_location_types | | | | presence_type | string enum: PRESENCE, PRESENCE_OR_INTEREST | | | ### AdsGroup_targeting_geographic_location_types ### AdsGroup_targeting_optimization Optimization (Meta: targeting_automation; Google: observation vs targeting mode) | Field | Type | Required | Description | |---|---|---|---| | mode | string enum: TARGETING, OBSERVATION | | Google: bid_only falsetrue | | advantage_audience | boolean | | | | advantage_placements | boolean | | | | targeting_optimization_expansion_all | boolean | | | ### AdsGroup_targeting_placement | Field | Type | Required | Description | |---|---|---|---| | platforms | AdsGroup_targeting_placement_platforms | | | | facebook_positions | AdsGroup_targeting_placement_facebook_positions | | | | instagram_positions | AdsGroup_targeting_placement_instagram_positions | | | | messenger_positions | AdsGroup_targeting_placement_messenger_positions | | | | audience_network_positions | AdsGroup_targeting_placement_audience_network_positions | | | ### AdsGroup_targeting_placement_audience_network_positions ### AdsGroup_targeting_placement_facebook_positions ### AdsGroup_targeting_placement_instagram_positions ### AdsGroup_targeting_placement_messenger_positions ### AdsGroup_targeting_placement_platforms ### AdsInsertionorderBudgetSegment | Field | Type | Required | Description | |---|---|---|---| | budget_amount | number | | | | description | string | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### AdsInsertionorder_bid_strategy YOUTUBE_AND_PARTNERS | Field | Type | Required | Description | |---|---|---|---| | type | string enum: FIXED_BID, MAXIMIZE_SPEND, PERFORMANCE_GOAL, YOUTUBE_AND_PARTNERS | Yes | | | fixed_bid_amount | number | | | | performance_goal_type | string enum: UNSPECIFIED, CPA, CPC, VIEWABLE_CPM, CUSTOM_ALGO, CIVA, IVO_TEN, AV_VIEWED, REACH | | | | performance_goal_amount | number | | | | max_average_cpm_bid_amount | number | | | | custom_bidding_algorithm_id | string | | | | raise_bid_for_deals | boolean | | | | youtube_and_partners_type | string enum: UNSPECIFIED, MANUAL_CPV, MANUAL_CPM, TARGET_CPA, TARGET_CPM, RESERVE_CPM, MAXIMIZE_LIFT, MAXIMIZE_CONVERSIONS, TARGET_CPV, TARGET_ROAS, MAXIMIZE_CONVERSION_VALUE | | | | youtube_and_partners_value | string | | | | target_roas | number | | Target ROAS e.g. 2.5 = 250% (Google) | ### AdsInsertionorder_frequency_cap | Field | Type | Required | Description | |---|---|---|---| | is_unlimited | boolean | | | | time_unit | string enum: UNSPECIFIED, LIFETIME, MONTHS, WEEKS, DAYS, HOURS, MINUTES | | | | time_unit_count | number | | | | max_impressions | number | | | | max_views | number | | | ### AdsInsertionorder_kpi | Field | Type | Required | Description | |---|---|---|---| | type | string enum: UNSPECIFIED, CPM, CPC, CPA, CTR, VIEWABILITY, CPIAVC, CPE, CPV, CLICK_CVR, IMPRESSION_CVR, VCPM, VTR, AUDIO_COMPLETION_RATE, VIDEO_COMPLETION_RATE, CPCL, CPCV, TOS10, MAXIMIZE_PACING, CUSTOM_IMPRESSION_VALUE_OVER_COST, OTHER | | | | amount | number | | | | percentage | number | | | | string_value | string | | | | algorithm_id | string | | | ### AdsInsertionorder_pacing | Field | Type | Required | Description | |---|---|---|---| | period | string enum: UNSPECIFIED, DAILY, FLIGHT | | | | type | string | | | | daily_max_amount | number | | | | daily_max_impressions | number | | | ### AdsKeyword Content targeting (keywords, topics, placements, video) | Field | Type | Required | Description | |---|---|---|---| | text | string | Yes | | | match_type | string enum: BROAD, PHRASE, EXACT | | | ### AdsManager | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | ### AdsMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### AudienceCombination | Field | Type | Required | Description | |---|---|---|---| | interests | TargetRef[] | | | | behaviors | TargetRef[] | | | | references | TargetRef[] | | | | life_events | TargetRef[] | | | ### CityTarget | Field | Type | Required | Description | |---|---|---|---| | id | string | Yes | | | name | string | | | | radius | number | | | | radius_unit | string enum: MILES, KILOMETERS | | | ### LookalikeAudience | Field | Type | Required | Description | |---|---|---|---| | id | string | Yes | | | name | string | | | | similarity | number | | | | source_audience_id | string | | | ### TargetRef | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | --- # Analytics API – Endpoints & Data Models ## Analytics API data models URL: https://docs.unified.to/analytics/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /analytics/{connection_id}/property | Create a property | | GET | /analytics/{connection_id}/property | List all properties | | GET | /analytics/{connection_id}/property/{id} | Retrieve a property | | PUT | /analytics/{connection_id}/property/{id} | Update a property | | DELETE | /analytics/{connection_id}/property/{id} | Remove a property | | POST | /analytics/{connection_id}/event | Create an event | | GET | /analytics/{connection_id}/event | List all events | | GET | /analytics/{connection_id}/event/{id} | Retrieve an event | | GET | /analytics/{connection_id}/session/{id} | Retrieve a session | | GET | /analytics/{connection_id}/session | List all sessions | | POST | /analytics/{connection_id}/visitor | Create a visitor | | GET | /analytics/{connection_id}/visitor | List all visitors | | GET | /analytics/{connection_id}/visitor/{id} | Retrieve a visitor | | PUT | /analytics/{connection_id}/visitor/{id} | Update a visitor | | DELETE | /analytics/{connection_id}/visitor/{id} | Remove a visitor | | GET | /analytics/{connection_id}/report | List all reports | #### Data Models ### AnalyticsEvent | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | event_type | string enum: PAGE_VIEW, SCREEN_VIEW, CLICK, FORM_SUBMIT, PURCHASE, SIGN_UP, LOGIN, LOGOUT, SEARCH, VIDEO_PLAY, VIDEO_COMPLETE, FILE_DOWNLOAD, SCROLL, SESSION_START, FIRST_VISIT, CUSTOM | | | | visitor_id | string | | points to AnalyticsVisitor | | property_id | string | | points to AnalyticsProperty | | session_id | string | | points to AnalyticsSession | | page_url | string | | | | page_title | string | | | | page_referrer | string | | | | device | string | | | | browser | string | | | | os | string | | | | country | string | | | | country_code | string | | | | region | string | | | | city | string | | | | metadata | AnalyticsEvent_metadata | | | | value | number | | | | currency | string | | | | utm_source | string | | | | utm_medium | string | | | | utm_campaign | string | | | | utm_term | string | | | | utm_content | string | | | | raw | object | | | ### AnalyticsProperty | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | timezone | string | | | | currency | string | | | | industry | string | | | | parent_id | string | | points to AnalyticsProperty | | metadata | AnalyticsMetadata[] | | | | raw | object | | | ### AnalyticsSession | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | property_id | string | | points to AnalyticsProperty | | visitor_id | string | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | duration_seconds | number | | | | landing_page | string | | Session attributes | | exit_page | string | | Session attributes | | page_views | number | | Session attributes | | events_count | number | | Session attributes | | source | string | | Acquisition | | medium | string | | | | campaign | string | | | | device | string | | | | browser | string | | | | os | string | | | | country | string | | | | city | string | | | | is_bounce | boolean | | Engagement | | is_conversion | boolean | | Engagement | | metadata | AnalyticsMetadata[] | | | | raw | object | | | ### AnalyticsVisitor | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | property_id | string | | points to AnalyticsProperty | | email | string | | | | name | string | | | | first_name | string | | | | last_name | string | | | | age | number | | | | gender | string enum: MALE, FEMALE, INTERSEX, TRANS, NON_BINARY | | | | country | string | | | | country_code | string | | | | city | string | | | | language | string | | | | timezone | string | | | | first_seen_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | last_seen_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_sessions | number | | | | total_page_views | number | | | | total_events | number | | | | metadata | AnalyticsVisitor_metadata | | | | raw | object | | | ### AnalyticsEvent_metadata ### AnalyticsMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### AnalyticsVisitor_metadata --- # Assessment API – Endpoints & Data Models ## Assessment API data models URL: https://docs.unified.to/assessment/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /assessment/{connection_id}/package | Create an assessment package | | GET | /assessment/{connection_id}/package | List assessment packages | | GET | /assessment/{connection_id}/package/{id} | Get an assessment package | | PUT | /assessment/{connection_id}/package/{id} | Update an assessment package | | DELETE | /assessment/{connection_id}/package/{id} | Delete an assessment package | | POST | /assessment/{connection_id}/order | Create an order | | GET | /assessment/{connection_id}/order/{id} | Retrieve an order | | PUT | /assessment/{connection_id}/order/{id} | Update an order | #### Data Models ### AssessmentOrder | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | workspace_id | string | Yes | Workspace ID that this order belongs to (reference to KmsSpace) | | connection_id | string | Yes | Connection ID that this order belongs to | | webhook_id | string | | Webhook ID that this order belongs to | | package_id | string | | Assessment package ID | | parameters | AssessmentParameterInput[] | | Filled-in answers to the package's parameter questions | | target_url | string | | URL to redirect user to after assessment | | status | string enum: OPEN, IN_PROGRESS, COMPLETED, FAILED, REJECTED | | The current state of the assessment order | | reference | string | | ATS-specific reference ID | | application_id | string | | ATS application ID | | job_id | string | | ATS job ID | | company_id | string | | ATS company ID (reference to AtsCompany) | | candidate_id | string | | ATS candidate ID | | employee_id | string | | ATS employee ID | | profile_name | string | | | | profile_first_name | string | | | | profile_last_name | string | | | | profile_resume_url | string | | | | profile_ip_address | string | | XXX.XXX.XXX.XXX | | profile_date_of_birth | string | | YYYY-MM-DD | | profile_addresses | AssessmentAddress[] | | | | profile_gender | string enum: MALE, FEMALE, INTERSEX, TRANS, NON_BINARY | | | | profile_emails | AssessmentOrder_profile_emails | | | | profile_telephones | AssessmentOrder_profile_telephones | | | | profile_national_identifier | string | | | | profile_social_media_urls | AssessmentOrder_profile_social_media_urls | | | | response_completed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | response_expires_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | response_issued_at | string (date-time) | | Datetime that assessment was issued (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | response_status | string enum: OPEN, IN_PROGRESS, COMPLETED, FAILED, REJECTED | | | | response_score | number | | Assessment score (e.g., 0-100) | | response_max_score | number | | Maximum possible score | | response_url | string | | URL to view detailed results | | response_redirect_url | string | | URL to redirect the user to complete assessment | | response_download_urls | AssessmentOrder_response_download_urls | | Report download URLs | | response_details | AssessmentResponseDetail[] | | | | response_source | string | | | | response_attributes | AssessmentAttribute[] | | Additional result attributes | | metadata | AssessmentMetadata[] | | | | raw | object | | | ### AssessmentPackage Used by assessment providers to SUBMIT packages to ATS systems | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the assessment package | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | workspace_id | string | | Workspace ID that this package belongs to (reference to KmsSpace) (read-only) | | connection_id | string | | Connection ID that this package belongs to (read-only) | | integration_types | AssessmentPackage_integration_types | | Integration types that support this package | | name | string | | Name of the assessment package | | type | string enum: SKILLS_TEST, BEHAVIORAL_ASSESSMENT, VIDEO_INTERVIEW, BACKGROUND_CHECK, REFERENCE_CHECK, OTHER | Yes | | | aliases | AssessmentPackage_aliases | | Alternative namesidentifiers for this package | | tags | AssessmentPackage_tags | | Category tags (e.g., "Assessment", "Background Check") | | description | string | | Detailed description | | parameters | AssessmentParameter[] | | Questionsinputs needed for this assessment (aligned with verification) | | has_redirect_url | boolean | | Whether provider redirects user to complete assessment | | has_target_url | boolean | | Where provider redirects user after completion | | needs_ip_address | boolean | | Whether IP address is required | | max_score | number | | Maximum possible score if this assessment returns a score | | info_url | string | | URL to find additional information about this package | | regions | AssessmentPackageRegion[] | | | | metadata | AssessmentMetadata[] | | | | raw | object | | | ### AssessmentAddress | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area (e.g., state in the U.S., province in Canada) | | region_code | string | | Short form for regional area (e.g., two-letter stateprovince abbreviation) | | postal_code | string | | | | country | string | | | | country_code | string | | Country code in ISO 3166 A-2 format | ### AssessmentAttribute | Field | Type | Required | Description | |---|---|---|---| | type | string enum: TEXT, NUMBER, SUB_RESULT | Yes | | | label | string | Yes | | | value | string | | For TEXT and NUMBER types | | reference | string | | For SUB_RESULT type | | score_value | number | | | | score_max | number | | | | status | string enum: OPEN, IN_PROGRESS, COMPLETED, FAILED, REJECTED | | For SUB_RESULT type | ### AssessmentMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The ID of the custom field definition (from the metadata/metadata endpoint). | | slug | string | | The integration's API name (key) of an existing custom field. List valid slugs for this object with the metadata/metadata endpoint; unknown slugs are rejected by the integration. | | value | object | | The field's value, typed by `format`: DATE as ISO-8601 (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM:SSZ` for date-times); NUMBER, PRICE and CURRENCY as a number; BOOLEAN and YES_NO as true/false; MULTIPLE_SELECT as an array of option values; otherwise a string. | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | The data type of the custom field, which determines how `value` is formatted. | | extra_data | object | | | ### AssessmentOrder_profile_emails ### AssessmentOrder_profile_social_media_urls ### AssessmentOrder_profile_telephones ### AssessmentOrder_response_download_urls Report download URLs ### AssessmentPackageRegion | Field | Type | Required | Description | |---|---|---|---| | regions | AssessmentPackageRegion_regions | | Countryregion codes where this package is available ({country}-{state} or {country}) | | cost_amount | number | | | | currency | string | | ISO 4217 format | | processing_time | number | | Processing time in milliseconds | ### AssessmentPackageRegion_regions Countryregion codes where this package is available ({country}-{state} or {country}) ### AssessmentPackage_aliases Alternative namesidentifiers for this package ### AssessmentPackage_integration_types Integration types that support this package ### AssessmentPackage_tags Category tags (e.g., "Assessment", "Background Check") ### AssessmentParameter | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | | public_question | string | | Question to ask the candidateuser | | type | string enum: TEXT, NUMBER, MULTIPLE_CHOICE, MULTIPLE_SELECT, DATE, FILE | | | | options | AssessmentParameter_options | | Options for MULTIPLE_CHOICE and MULTIPLE_SELECT | | file_types | AssessmentParameter_file_types | | Valid file MIME types for FILE type | | valid_regions | AssessmentParameter_valid_regions | | Regions where this parameter is valid ({country}-{state} or {country}) | | is_required | boolean | | | ### AssessmentParameterInput | Field | Type | Required | Description | |---|---|---|---| | parameter_id | string | | | | name | string | | Name of parameter | | inputs | AssessmentParameterInput_inputs | | | ### AssessmentParameterInput_inputs ### AssessmentParameter_file_types Valid file MIME types for FILE type ### AssessmentParameter_options Options for MULTIPLE_CHOICE and MULTIPLE_SELECT ### AssessmentParameter_valid_regions Regions where this parameter is valid ({country}-{state} or {country}) ### AssessmentResponseDetail | Field | Type | Required | Description | |---|---|---|---| | title | string | | | | text | string | | | | is_private | boolean | | | | is_failed_reason | boolean | | | | parameter_id | string | | In reference to the parameter input | | download_url | string | | | --- # Ats API – Endpoints & Data Models ## Ats API data models URL: https://docs.unified.to/ats/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /ats/{connection_id}/activity | Create an activity | | GET | /ats/{connection_id}/activity | List all activities | | GET | /ats/{connection_id}/activity/{id} | Retrieve an activity | | PUT | /ats/{connection_id}/activity/{id} | Update an activity | | DELETE | /ats/{connection_id}/activity/{id} | Remove an activity | | POST | /ats/{connection_id}/candidate | Create a candidate | | GET | /ats/{connection_id}/candidate | List all candidates | | GET | /ats/{connection_id}/candidate/{id} | Retrieve a candidate | | PUT | /ats/{connection_id}/candidate/{id} | Update a candidate | | DELETE | /ats/{connection_id}/candidate/{id} | Remove a candidate | | POST | /ats/{connection_id}/job | Create a job | | GET | /ats/{connection_id}/job | List all jobs | | GET | /ats/{connection_id}/job/{id} | Retrieve a job | | PUT | /ats/{connection_id}/job/{id} | Update a job | | DELETE | /ats/{connection_id}/job/{id} | Remove a job | | POST | /ats/{connection_id}/interview | Create an interview | | GET | /ats/{connection_id}/interview | List all interviews | | GET | /ats/{connection_id}/interview/{id} | Retrieve an interview | | PUT | /ats/{connection_id}/interview/{id} | Update an interview | | DELETE | /ats/{connection_id}/interview/{id} | Remove an interview | | POST | /ats/{connection_id}/document | Create a document | | GET | /ats/{connection_id}/document | List all documents | | GET | /ats/{connection_id}/document/{id} | Retrieve a document | | PUT | /ats/{connection_id}/document/{id} | Update a document | | DELETE | /ats/{connection_id}/document/{id} | Remove a document | | GET | /ats/{connection_id}/applicationstatus/{id} | Retrieve an applicationstatus | | GET | /ats/{connection_id}/applicationstatus | List all applicationstatuses | | POST | /ats/{connection_id}/application | Create an application | | GET | /ats/{connection_id}/application | List all applications | | GET | /ats/{connection_id}/application/{id} | Retrieve an application | | PUT | /ats/{connection_id}/application/{id} | Update an application | | DELETE | /ats/{connection_id}/application/{id} | Remove an application | | POST | /ats/{connection_id}/scorecard | Create a scorecard | | GET | /ats/{connection_id}/scorecard | List all scorecards | | GET | /ats/{connection_id}/scorecard/{id} | Retrieve a scorecard | | PUT | /ats/{connection_id}/scorecard/{id} | Update a scorecard | | DELETE | /ats/{connection_id}/scorecard/{id} | Remove a scorecard | | POST | /ats/{connection_id}/company | Create a company | | GET | /ats/{connection_id}/company | List all companies | | GET | /ats/{connection_id}/company/{id} | Retrieve a company | | PUT | /ats/{connection_id}/company/{id} | Update a company | | DELETE | /ats/{connection_id}/company/{id} | Remove a company | #### Data Models ### AtsActivity | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | candidate_id | string | | The ID of the candidate the activity is about (an AtsCandidate ID) | | application_id | string | | | | job_id | string | | | | interview_id | string | | | | document_ids | AtsActivity_document_ids | | IDs for AtsDocument.get | | title | string | | | | description | string | | The activity's content, such as the text of a note or the body of an email | | is_private | boolean | | | | user_ids | AtsActivity_user_ids | | id values of the recruiters associated with the activity. (reference to HrisEmployee) | | type | string enum: NOTE, TASK, EMAIL | | | | from | AtsActivity_from | | | | to | AtsEmail[] | | | | cc | AtsEmail[] | | | | bcc | AtsEmail[] | | | | sub_type | string | | | | company_id | string | | (reference to AtsCompany) | | metadata | AtsMetadata[] | | | | raw | object | | | ### AtsApplication | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | candidate_id | string | | The ID of the candidate who is applying (an AtsCandidate ID) | | job_id | string | | The ID of the job being applied to (an AtsJob ID) | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | applied_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | hired_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | rejected_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | rejected_reason | string | | | | source | string | | | | status | string enum: NEW, REVIEWING, SCREENING, SUBMITTED, FIRST_INTERVIEW, SECOND_INTERVIEW, THIRD_INTERVIEW, BACKGROUND_CHECK, OFFERED, ACCEPTED, HIRED, REJECTED, DECLINED, WITHDRAWN | | | | original_status | string | | The integration's original status. Mostly used to have your customers chose the application status/stage so that your software can trigger an event. | | answers | AtsApplicationAnswer[] | | | | offers | AtsOffer[] | | | | user_id | string | | HR user/employee ID (reference to HrisEmployee) | | metadata | AtsMetadata[] | | | | original_substatus | string | | The provider's secondary/sub status, when distinct from original_status (e.g. UKG Ready hiring_stage) | | summary | string | | Application personal summary (e.g. Pinpoint Personal Summary) | | raw | object | | | ### AtsCandidate | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The candidate's full name | | first_name | string | | | | last_name | string | | | | emails | AtsEmail[] | | The candidate's email addresses | | title | string | | Candidate’s current job title | | telephones | AtsTelephone[] | | | | company_name | string | | Name of company where candidate currently works | | image_url | string | | | | tags | AtsCandidate_tags | | | | address | AtsCandidate_address | | | | external_identifier | string | | | | link_urls | AtsCandidate_link_urls | | URLs for web pages containing additional material about the candidate (LinkedIn, other social media, articles, etc.) | | origin | string enum: AGENCY, APPLIED, INTERNAL, REFERRED, SOURCED, UNIVERSITY | | | | company_id | string | | (reference to AtsCompany) | | sources | AtsCandidate_sources | | | | date_of_birth | string (date-time) | | | | user_id | string | | Employee ID that owns the relationship for this candidate (reference to HrisEmployee) | | user_ids | AtsCandidate_user_ids | | references hris employees (reference to HrisEmployee) | | web_url | string | | | | experiences | AtsCandidateExperience[] | | | | education | AtsCandidateEducation[] | | | | skills | AtsCandidate_skills | | | | job_ids | AtsCandidate_job_ids | | | | metadata | AtsMetadata[] | | | | summary | string | | Personal / professional bio | | raw | object | | | ### AtsCompany | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The company's name | | address | AtsCompany_address | | | | website_url | string | | | | phone | string | | | | parent_id | string | | | | recruiter_ids | AtsCompany_recruiter_ids | | | | metadata | AtsMetadata[] | | | | raw | object | | | ### AtsDocument | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | document_url | string | | This link expires after 1 hour. | | document_data | string | | base64 encoded file contents used for create/update actions. Use this field to send an attachment e.g. a resume, a profile, or other text content | | filename | string | | The document's file name, including its extension (e.g. resume.pdf) | | type | string enum: RESUME, COVER_LETTER, OFFER_PACKET, OFFER_LETTER, TAKE_HOME_TEST, OTHER | | The kind of document | | candidate_id | string | | The ID of the candidate the document belongs to (an AtsCandidate ID) | | application_id | string | | | | job_id | string | | | | user_id | string | | (reference to HrisEmployee) | | metadata | AtsMetadata[] | | | | raw | object | | | ### AtsInterview | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | candidate_id | string | | | | job_id | string | | | | application_id | string | | | | user_ids | AtsInterview_user_ids | | (reference to HrisEmployee) | | status | string enum: SCHEDULED, AWAITING_FEEDBACK, COMPLETE, CANCELED, NEEDS_SCHEDULING | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | location | string | | | | meeting_url | string | | the URL of the virtual/video meeting (eg. Zoom, Google Meet, Microsoft Teams) | | external_event_xref | string | | the ID of the event in a calendar | | metadata | AtsMetadata[] | | | | raw | object | | | ### AtsJob | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The job's title | | description | string | | | | recruiter_ids | AtsJob_recruiter_ids | | | | hiring_manager_ids | AtsJob_hiring_manager_ids | | | | hiring_managers | AtsReference[] | | | | status | string enum: ARCHIVED, PENDING, DRAFT, OPEN, CLOSED | | | | closed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | addresses | AtsAddress[] | | | | compensation | AtsCompensation[] | | | | employment_type | string enum: FULL_TIME, PART_TIME, CONTRACTOR, INTERN, CONSULTANT, VOLUNTEER, CASUAL, SEASONAL, FREELANCE, OTHER | | | | remote | boolean | | | | language_locale | string | | Preferred language for the job, in ISO 639-1 format (e.g., U.S. English is en-us) | | public_job_urls | AtsJob_public_job_urls | | URLs for pages containing public listings for the job | | number_of_openings | number | | Number of openings for the job | | company_id | string | | id value of the company associated with the job in the ATS (reference to AtsCompany) | | questions | AtsJobQuestion[] | | | | postings | AtsJobPosting[] | | Public job postings | | groups | AtsGroup[] | | The departments/divisions/teams that this job belongs to | | openings | AtsJobOpening[] | | | | minimum_experience_years | number | | | | minimum_degree | string | | | | skills | AtsJob_skills | | | | metadata | AtsMetadata[] | | | | user_id | string | | User id of the job owner in the ATS (references an HRIS employee) (reference to HrisEmployee) | | industry | string | | | | summary | string | | Short job blurb (distinct from full description when both exist) | | raw | object | | | ### AtsScorecard | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | application_id | string | | | | interviewer_id | string | | | | interview_id | string | | | | candidate_id | string | | | | job_id | string | | | | recommendation | string enum: DEFINITELY_NO, NO, YES, STRONG_YES | | | | comment | string | | | | questions | AtsScorecardQuestion[] | | | | metadata | AtsMetadata[] | | | | raw | object | | | ### AtsStatus | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | job_id | string | | The ID of the job this status/stage belongs to, when the provider scopes statuses per job. | | status | string enum: NEW, REVIEWING, SCREENING, SUBMITTED, FIRST_INTERVIEW, SECOND_INTERVIEW, THIRD_INTERVIEW, BACKGROUND_CHECK, OFFERED, ACCEPTED, HIRED, REJECTED, DECLINED, WITHDRAWN | | | | original_status | string | | | | description | string | | | | metadata | AtsMetadata[] | | | | raw | object | | | ### AtsActivity_document_ids IDs for AtsDocument.get ### AtsActivity_from | Field | Type | Required | Description | |---|---|---|---| | name | string | | | | email | string | Yes | | | type | string enum: WORK, HOME, OTHER | | | ### AtsActivity_user_ids id values of the recruiters associated with the activity. ### AtsAddress | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AtsApplicationAnswer | Field | Type | Required | Description | |---|---|---|---| | question_id | string | Yes | | | question | string | | | | answers | AtsApplicationAnswer_answers | Yes | | ### AtsApplicationAnswer_answers ### AtsCandidateEducation | Field | Type | Required | Description | |---|---|---|---| | institution | string | | | | level | string | | | | degree | string | | | | field_of_study | string | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### AtsCandidateExperience | Field | Type | Required | Description | |---|---|---|---| | company_name | string | | | | title | string | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### AtsCandidate_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AtsCandidate_job_ids ### AtsCandidate_link_urls URLs for web pages containing additional material about the candidate (LinkedIn, other social media, articles, etc.) ### AtsCandidate_skills ### AtsCandidate_sources ### AtsCandidate_tags ### AtsCandidate_user_ids references hris employees ### AtsCompany_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AtsCompany_recruiter_ids ### AtsCompensation | Field | Type | Required | Description | |---|---|---|---| | type | string enum: SALARY, BONUS, STOCK_OPTIONS, EQUITY, OTHER | | | | min | number | | | | max | number | | | | currency | string | | | | frequency | string enum: ONE_TIME, DAY, QUARTER, YEAR, HOUR, MONTH, WEEK | | | ### AtsEmail | Field | Type | Required | Description | |---|---|---|---| | name | string | | | | email | string | Yes | | | type | string enum: WORK, HOME, OTHER | | | ### AtsGroup | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | | type | string enum: TEAM, GROUP, DEPARTMENT, DIVISION, BUSINESS_UNIT, BRANCH, SUB_DEPARTMENT | | | ### AtsInterview_user_ids ### AtsJobOpening | Field | Type | Required | Description | |---|---|---|---| | status | string enum: OPEN, CLOSED | | | | opened_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | closed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | application_id | string | | points to an application | | close_reason | string | | | ### AtsJobPosting | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | posting_url | string | | | | name | string | | job-post-specific name | | description | string | | job-post-specific description | | address | AtsJobPosting_address | | job-post-specific address | | location | string | | job-post-specific location | | is_active | boolean | | | ### AtsJobPosting_address job-post-specific address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### AtsJobQuestion | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | question | string | Yes | | | required | boolean | | | | description | string | | | | prompt | string | | | | type | string enum: TEXT, NUMBER, DATE, BOOLEAN, MULTIPLE_CHOICE, FILE, TEXTAREA, MULTIPLE_SELECT, UNIVERSITY, YES_NO, CURRENCY, URL | Yes | | | options | AtsJobQuestion_options | | | | min_length | number | | minimum answer length (TEXT) | | max_length | number | | maximum answer length (TEXT) | | min_value | number | | minimum answer value (NUMBER) | | max_value | number | | maximum answer value (NUMBER) | | min_at | string (date-time) | | earliest allowed answer (DATE) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | max_at | string (date-time) | | latest allowed answer (DATE) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | parent_id | string | | id of the question in this job's questions list that this question depends on | | parent_answer_values | AtsJobQuestion_parent_answer_values | | only ask this question when the parent question's answer is one of these | ### AtsJobQuestion_options ### AtsJobQuestion_parent_answer_values only ask this question when the parent question's answer is one of these ### AtsJob_hiring_manager_ids ### AtsJob_public_job_urls URLs for pages containing public listings for the job ### AtsJob_recruiter_ids ### AtsJob_skills ### AtsMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The ID of the custom field definition (from the metadata/metadata endpoint). | | slug | string | | The integration's API name (key) of an existing custom field. List valid slugs for this object with the metadata/metadata endpoint; unknown slugs are rejected by the integration. | | value | object | | The field's value, typed by `format`: DATE as ISO-8601 (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM:SSZ` for date-times); NUMBER, PRICE and CURRENCY as a number; BOOLEAN and YES_NO as true/false; MULTIPLE_SELECT as an array of option values; otherwise a string. | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | The data type of the custom field, which determines how `value` is formatted. | | extra_data | object | | | ### AtsOffer | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | creator_user_id | string | | user id of the recruiter who created the offer (reference to HrisEmployee) | | employee_user_id | string | | newly hired employee id (reference to HrisEmployee) | | sent_at | string (date-time) | | date the offer was sent (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | accepted_at | string (date-time) | | date the offer was accepted (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | rejected_at | string (date-time) | | date the offer was rejected (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | start_at | string (date-time) | | date the employee starts (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | compensation | AtsCompensation[] | | compensation details for the offer | | status | string enum: CREATED, SENT, ACCEPTED, REJECTED | | | ### AtsReference | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | ### AtsScorecardQuestion | Field | Type | Required | Description | |---|---|---|---| | text | string | Yes | The question to ask the interviewer | | description | string | | | | answer | string | | Candidate’s answer to the question | ### AtsTelephone | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | | | type | string enum: WORK, HOME, OTHER, FAX, MOBILE | | | --- # Auth API – Endpoints & Data Models ## Auth API data models URL: https://docs.unified.to/auth/overview #### Endpoints | Method | Path | Description | |---|---|---| | GET | /unified/integration/login/{workspace_id}/{integration_type} | Sign in a user | | GET | /unified/integration/saml/{workspace_id}/{integration_type} | Sign in a user via SAML | #### Data Models --- # Calendar API – Endpoints & Data Models ## Calendar API data models URL: https://docs.unified.to/calendar/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /calendar/{connection_id}/calendar | Create a calendar | | GET | /calendar/{connection_id}/calendar | List all calendars | | GET | /calendar/{connection_id}/calendar/{id} | Retrieve a calendar | | PUT | /calendar/{connection_id}/calendar/{id} | Update a calendar | | DELETE | /calendar/{connection_id}/calendar/{id} | Remove a calendar | | POST | /calendar/{connection_id}/event | Create an event | | GET | /calendar/{connection_id}/event | List all events | | GET | /calendar/{connection_id}/event/{id} | Retrieve an event | | PUT | /calendar/{connection_id}/event/{id} | Update an event | | DELETE | /calendar/{connection_id}/event/{id} | Remove an event | | GET | /calendar/{connection_id}/busy | List all busies | | POST | /calendar/{connection_id}/link | Create a link | | GET | /calendar/{connection_id}/link | List all links | | GET | /calendar/{connection_id}/link/{id} | Retrieve a link | | PUT | /calendar/{connection_id}/link/{id} | Update a link | | DELETE | /calendar/{connection_id}/link/{id} | Remove a link | | GET | /calendar/{connection_id}/recording/{id} | Retrieve a recording | | GET | /calendar/{connection_id}/recording | List all recordings | | POST | /calendar/{connection_id}/webinar | Create a webinar | | GET | /calendar/{connection_id}/webinar | List all webinars | | GET | /calendar/{connection_id}/webinar/{id} | Retrieve a webinar | | PUT | /calendar/{connection_id}/webinar/{id} | Update a webinar | | DELETE | /calendar/{connection_id}/webinar/{id} | Remove a webinar | #### Data Models ### CalendarCalendar | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | timezone | string | | | | is_primary | boolean | | | | metadata | CalendarMetadata[] | | | | raw | object | | | ### CalendarEvent | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | calendar_id | string | | (reference to CalendarCalendar) | | subject | string | | | | start_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | is_all_day | boolean | | | | timezone | string | | | | notes | string | | | | location | string | | | | is_free | boolean | | | | is_private | boolean | | | | status | string enum: CANCELED, CONFIRMED, TENTATIVE | | | | organizer | CalendarEvent_organizer | | | | attendees | CalendarAttendee[] | | | | recurring_event_id | string | | (reference to CalendarEvent) | | recurrence | CalendarEventRecurrence[] | | | | web_url | string | | | | has_conference | boolean | | | | conference | CalendarConference[] | | | | attachments | CalendarAttachment[] | | | | send_notifications | boolean | | | | matter_id | string | | pointer to Legal Matter | | metadata | CalendarMetadata[] | | | | raw | object | | | ### CalendarLink | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | url | string | Yes | | | duration | number | | minutes | | description | string | | | | is_active | boolean | | | | price_amount | number | | | | price_currency | string | | | | metadata | CalendarMetadata[] | | | | raw | object | | | ### CalendarRecording | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | expires_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | event_id | string | | (reference to CalendarEvent) | | web_url | string | | | | media | CalendarRecordingMedia[] | | | | metadata | CalendarMetadata[] | | | | raw | object | | | ### CalendarWebinar | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | calendar_id | string | | (reference to CalendarCalendar) | | subject | string | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | timezone | string | | | | notes | string | | | | status | string enum: CANCELED, CONFIRMED, TENTATIVE | | | | organizer | CalendarWebinar_organizer | | | | join_url | string | | | | web_url | string | | information | | panelists | CalendarWebinarPanelist[] | | | | registrants | CalendarWebinarRegistrant[] | | | | panelist_password | string | | | | registrant_password | string | | | | conference | CalendarConference[] | | | | recurrence | CalendarEventRecurrence[] | | | | capacity | number | | | | is_webcast | boolean | | | | is_enabled | boolean | | | | is_auto_approve | boolean | | | | require_first_name | boolean | | | | require_last_name | boolean | | | | require_email | boolean | | | | require_company | boolean | | | | require_job_title | boolean | | | | require_address | boolean | | | | require_phone | boolean | | | | has_qa | boolean | | | | has_polls | boolean | | | | has_recording | boolean | | | | metadata | CalendarMetadata[] | | | | raw | object | | | ### CalendarAttachment | Field | Type | Required | Description | |---|---|---|---| | id | string | | ID for the storage_fileget endpoint | | name | string | | | | mime_type | string | | | | download_url | string | | | ### CalendarAttendee | Field | Type | Required | Description | |---|---|---|---| | user_id | string | | (reference to HrisEmployee) | | email | string | | | | name | string | | | | status | string enum: ACCEPTED, REJECTED, TENTATIVE | | | | required | boolean | | | | is_cohost | boolean | | | ### CalendarConference | Field | Type | Required | Description | |---|---|---|---| | conference_identifier | string | | | | url | string | | | | label | string | | | | telephone | string | | | | participant_access_code | string | | | | host_access_code | string | | | | notes | string | | | | country_code | string | | ISO 2-digit country code | | region_code | string | | ISO 2-digit region code | ### CalendarEventRecurrence | Field | Type | Required | Description | |---|---|---|---| | frequency | string enum: DAILY, WEEKLY, MONTHLY, YEARLY | | | | interval | number | | how many "units" between occurrences, defaults to 1 | | count | number | | how many occurrences, defaults to undefined (no limit) | | end_at | string (date-time) | | until date, defaults to undefined (no end date) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | on_days | CalendarEventRecurrence_on_days | | days of the week to repeat on, defaults to undefined (every day), only used if frequency is WEEKLY | | on_months | CalendarEventRecurrence_on_months | | months of the year to repeat on, defaults to undefined (every month), only used if frequency is YEARLY, January is 1 | | on_month_days | CalendarEventRecurrence_on_month_days | | days of the month to repeat on, defaults to undefined (every day), only used if frequency is MONTHLY | | on_weeks | CalendarEventRecurrence_on_weeks | | week ordinals for BYDAY (e.g., -1 for last, -2 for second-to-last, 1 for first, 2 for second), only used with on_days. 0 is used for days without week ordinals. | | on_year_days | CalendarEventRecurrence_on_year_days | | days of the year to repeat on, defaults to undefined (every day), only used if frequency is YEARLY | | week_start | string enum: SU, MO, TU, WE, TH, FR, SA | | week start day, defaults to undefined (no week start day) | | excluded_dates | CalendarEventRecurrence_excluded_dates | | dates to exclude from the recurrence, defaults to undefined (no exclusions) | | included_dates | CalendarEventRecurrence_included_dates | | dates to include in the recurrence, defaults to undefined (no inclusions) | | timezone | string | | timezone, defaults to undefined (no timezone) | ### CalendarEventRecurrence_on_days days of the week to repeat on, defaults to undefined (every day), only used if frequency is WEEKLY ### CalendarEvent_organizer | Field | Type | Required | Description | |---|---|---|---| | user_id | string | | (reference to HrisEmployee) | | email | string | | | | name | string | | | | status | string enum: ACCEPTED, REJECTED, TENTATIVE | | | | required | boolean | | | | is_cohost | boolean | | | ### CalendarMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### CalendarRecordingMedia | Field | Type | Required | Description | |---|---|---|---| | attendees | CalendarAttendee[] | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | language | string | | two digit ISO code | | transcripts | CalendarRecordingTranscript[] | | | | summary | string | | provider AI-generated meeting summary | | transcript_download_url | string | | | | summary_download_url | string | | download/export link for a provider AI summary document (e.g. Google Meet smart notes) | | recording_download_url | string | | | ### CalendarRecordingTranscript | Field | Type | Required | Description | |---|---|---|---| | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | text | string | Yes | | | attendee | CalendarRecordingTranscript_attendee | | | | language | string | | two digit ISO code | ### CalendarRecordingTranscript_attendee | Field | Type | Required | Description | |---|---|---|---| | user_id | string | | (reference to HrisEmployee) | | email | string | | | | name | string | | | | status | string enum: ACCEPTED, REJECTED, TENTATIVE | | | | required | boolean | | | | is_cohost | boolean | | | ### CalendarWebinarPanelist | Field | Type | Required | Description | |---|---|---|---| | email | string | | | | name | string | | | | is_required | boolean | | | | join_url | string | | | | join_password | string | | | | status | string enum: ACCEPTED, REJECTED, TENTATIVE | | | ### CalendarWebinarRegistrant | Field | Type | Required | Description | |---|---|---|---| | email | string | | | | name | string | | | | registration_reference | string | | | | registration_status | string enum: PENDING, APPROVED, REJECTED, CANCELLED | | | | registered_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### CalendarWebinar_organizer | Field | Type | Required | Description | |---|---|---|---| | user_id | string | | (reference to HrisEmployee) | | email | string | | | | name | string | | | | status | string enum: ACCEPTED, REJECTED, TENTATIVE | | | | required | boolean | | | | is_cohost | boolean | | | --- # Cdp API – Endpoints & Data Models ## Cdp API data models URL: https://docs.unified.to/cdp/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /cdp/{connection_id}/profile | Create a profile | | GET | /cdp/{connection_id}/profile | List all profiles | | GET | /cdp/{connection_id}/profile/{id} | Retrieve a profile | | PUT | /cdp/{connection_id}/profile/{id} | Update a profile | | DELETE | /cdp/{connection_id}/profile/{id} | Remove a profile | | POST | /cdp/{connection_id}/segment | Create a segment | | GET | /cdp/{connection_id}/segment | List all segments | | GET | /cdp/{connection_id}/segment/{id} | Retrieve a segment | | PUT | /cdp/{connection_id}/segment/{id} | Update a segment | | DELETE | /cdp/{connection_id}/segment/{id} | Remove a segment | | POST | /cdp/{connection_id}/event | Create an event | | GET | /cdp/{connection_id}/event | List all events | | GET | /cdp/{connection_id}/event/{id} | Retrieve an event | | PUT | /cdp/{connection_id}/event/{id} | Update an event | | DELETE | /cdp/{connection_id}/event/{id} | Remove an event | | POST | /cdp/{connection_id}/source | Create a source | | GET | /cdp/{connection_id}/source | List all sources | | GET | /cdp/{connection_id}/source/{id} | Retrieve a source | | PUT | /cdp/{connection_id}/source/{id} | Update a source | | DELETE | /cdp/{connection_id}/source/{id} | Remove a source | | POST | /cdp/{connection_id}/destination | Create a destination | | GET | /cdp/{connection_id}/destination | List all destinations | | GET | /cdp/{connection_id}/destination/{id} | Retrieve a destination | | PUT | /cdp/{connection_id}/destination/{id} | Update a destination | | DELETE | /cdp/{connection_id}/destination/{id} | Remove a destination | | POST | /cdp/{connection_id}/activation | Create an activation | | GET | /cdp/{connection_id}/activation | List all activations | | GET | /cdp/{connection_id}/activation/{id} | Retrieve an activation | | PUT | /cdp/{connection_id}/activation/{id} | Update an activation | | DELETE | /cdp/{connection_id}/activation/{id} | Remove an activation | #### Data Models ### CdpActivation | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this activation object | | created_at | string (date-time) | | The date that this activation object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this activation object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the activation/sync job | | segment_id | string | | The cdp_segment being activated; points to CdpSegment | | destination_id | string | | The cdp_destination the data is sent to; points to CdpDestination | | source_id | string | | The cdp_source the data originates from; points to CdpSource | | schedule | string | | The activation schedule or mode (eg. cron, interval, realtime, batch) | | status | string enum: ACTIVE, PAUSED, ERROR, PENDING | | The current status of the activation | | last_run_at | string (date-time) | | When the activation last ran (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | is_enabled | boolean | | Whether the activation is active/enabled | | metadata | CdpMetadata[] | | | | raw | object | | The raw data returned by the integration for this activation | ### CdpDestination | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this destination object | | created_at | string (date-time) | | The date that this destination object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this destination object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the outbound activation destination | | slug | string | | A stable handle/key for the destination | | type | string | | The connector/destination type (eg. the provider's connector slug or plugin id) | | direction | string enum: SOURCE, DESTINATION, BIDIRECTIONAL | | DESTINATION, or BIDIRECTIONAL for import+export connectors | | is_enabled | boolean | | Whether the destination is active/enabled | | metadata | CdpMetadata[] | | | | raw | object | | The raw data returned by the integration for this destination | ### CdpEvent | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this event object | | created_at | string (date-time) | | The date that this event object was ingested (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The specific event name (eg. "Product Purchased") | | type | string enum: TRACK, PAGE, SCREEN, IDENTIFY, GROUP, ALIAS | | The unified structural category of the event | | occurred_at | string (date-time) | | When the event actually happened (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | profile_id | string | | The profile this event is associated with; points to CdpProfile | | identifiers | CdpIdentifier[] | | The identifiers that link this event to a profile | | metadata | CdpMetadata[] | | The event payload/properties | | source | string | | The source that produced this event | | source_id | string | | The cdp_source this event originated from; points to CdpSource | | raw | object | | The raw data returned by the integration for this event | ### CdpProfile | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this profile object | | created_at | string (date-time) | | The date that this profile object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this profile object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The full name of the profile | | first_name | string | | The first name of the profile | | last_name | string | | The last name of the profile | | email | string | | The primary email address of the profile | | telephone | string | | The primary telephone number of the profile | | gender | string enum: MALE, FEMALE, INTERSEX, TRANS, NON_BINARY | | The gender of the profile | | birthdate | string | | The birth date of the profile | | title | string | | The job title of the profile | | company | string | | The company/organization name of the profile | | address | CdpProfile_address | | The address of the profile | | identifiers | CdpIdentifier[] | | The typed identifiers stitched to this profile | | metadata | CdpMetadata[] | | The traits/attributes of the profile | | segments | CdpProfileSegment[] | | The segments/audiences this profile currently belongs to | | is_anonymous | boolean | | Whether the profile is anonymous (no known identity) | | consent | CdpConsent[] | | The consent/privacy preferences of the profile | | source_id | string | | The cdp_source this profile originated from; points to CdpSource | | raw | object | | The raw data returned by the integration for this profile | ### CdpSegment | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this segment object | | created_at | string (date-time) | | The date that this segment object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this segment object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the segment/audience | | slug | string | | The data-plane handle/key used to identify segment membership in profiles and destinations | | description | string | | The description of the segment/audience | | definition | string | | The membership rule/query that defines the segment | | type | string enum: USERS, ACCOUNTS, LINKED | | The type of entity the segment targets (USERS, ACCOUNTS, LINKED) | | compute_mode | string enum: REALTIME, BATCH | | How the segment membership is computed (REALTIME or BATCH) | | size | number | | The number of profiles currently in the segment | | is_active | boolean | | Whether the segment is active/enabled | | source_id | string | | The cdp_source this segment originated from; points to CdpSource | | metadata | CdpMetadata[] | | | | raw | object | | The raw data returned by the integration for this segment | ### CdpSource | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this source object | | created_at | string (date-time) | | The date that this source object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this source object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the inbound data source | | slug | string | | A stable handle/key for the source | | type | string | | The connector/source type (eg. the provider's connector slug or plugin id) | | direction | string enum: SOURCE, DESTINATION, BIDIRECTIONAL | | SOURCE, or BIDIRECTIONAL for import+export connectors | | is_enabled | boolean | | Whether the source is active/enabled | | metadata | CdpMetadata[] | | | | raw | object | | The raw data returned by the integration for this source | ### CdpConsent | Field | Type | Required | Description | |---|---|---|---| | purpose | string | | The consent purpose or objective (eg. marketing, analytics) | | status | string enum: GRANTED, DENIED, PENDING | | The consent status for this purpose | | updated_at | string (date-time) | | When the consent status was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### CdpIdentifier | Field | Type | Required | Description | |---|---|---|---| | type | string enum: EMAIL, USER_ID, ANONYMOUS_ID, DEVICE_ID, PHONE, CRM_ID, LOYALTY_ID, OTHER | | The type of the identifier (eg. EMAIL, USER_ID, ANONYMOUS_ID) | | value | string | Yes | The identifier value that resolves to the profile | | is_primary | boolean | | Whether this is the primary identifier for the profile | | encoding | string | | The encoding of the identifier value (eg. raw, sha256) | | source | string | | The source that provided this identifier | ### CdpMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | The attribute/trait key (eg. lifetime_value) | | value | object | | The attribute/trait value | | namespace | string | | The scope or origin of the attribute (eg. computed, event, visitor) | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, ARRAY, OBJECT | | The data type of the attribute value | | extra_data | object | | | ### CdpProfileSegment | Field | Type | Required | Description | |---|---|---|---| | segment_id | string | | The segment/audience this profile belongs to; points to CdpSegment | | name | string | | The name of the segment/audience | | joined_at | string (date-time) | | When the profile joined the segment (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | expires_at | string (date-time) | | When the profile's membership expires (if applicable) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### CdpProfile_address The address of the profile | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | | --- # Clubs API – Endpoints & Data Models ## Clubs API data models URL: https://docs.unified.to/clubs/overview #### Endpoints | Method | Path | Description | |---|---|---| | GET | /clubs/{connection_id}/group/{id} | Retrieve a group | | GET | /clubs/{connection_id}/group | List all groups | | GET | /clubs/{connection_id}/member/{id} | Retrieve a member | | GET | /clubs/{connection_id}/member | List all members | | GET | /clubs/{connection_id}/activity/{id} | Retrieve an activity | | GET | /clubs/{connection_id}/activity | List all activities | | GET | /clubs/{connection_id}/event/{id} | Retrieve an event | | GET | /clubs/{connection_id}/event | List all events | | GET | /clubs/{connection_id}/location/{id} | Retrieve a location | | GET | /clubs/{connection_id}/location | List all locations | #### Data Models ### ClubsActivity ClubActivity (getClubActivitiesById, NO id) and the full DetailedActivity (getActivityById). | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this activity (not provided by ClubActivity) | | created_at | string (date-time) | | The date that this activity was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this activity was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | group_id | string | | The group this activity was listed under (reference to HrisGroup) | | name | string | | The name of the activity | | description | string | | The description of the activity (DetailedActivity only) | | type | string enum: RUNNING, CYCLING, SWIMMING, TRIATHLON, WALKING, HIKING, OTHER | | The activity type | | athlete_id | string | | The id of the athlete who performed the activity (DetailedActivity only) | | athlete_name | string | | The full name of the athlete | | distance | number | | The activity's distance, in meters | | moving_time | number | | The activity's moving time, in seconds | | elapsed_time | number | | The activity's elapsed time, in seconds | | total_elevation_gain | number | | The activity's total elevation gain, in meters | | elevation_high | number | | The activity's highest elevation, in meters (DetailedActivity only) | | elevation_low | number | | The activity's lowest elevation, in meters (DetailedActivity only) | | start_at | string (date-time) | | When the activity started (UTC) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | timezone | string | | The timezone of the activity | | achievement_count | number | | The number of achievements gained during this activity | | kudos_count | number | | The number of kudos given for this activity | | comment_count | number | | The number of comments for this activity | | athlete_count | number | | The number of athletes that took part in this activity | | photo_count | number | | The number of Instagram photos for this activity | | average_speed | number | | The activity's average speed, in meters per second | | max_speed | number | | The activity's max speed, in meters per second | | average_cadence | number | | The activity's average cadence | | average_heartrate | number | | The activity's average heart rate, in beats per minute | | max_heartrate | number | | The activity's max heart rate, in beats per minute | | average_watts | number | | The activity's average power output, in watts | | max_watts | number | | The activity's max power output, in watts | | weighted_average_watts | number | | The activity's weighted average power output, in watts | | kilojoules | number | | The total work done, in kilojoules | | calories | number | | The number of kilocalories consumed during this activity | | has_heartrate | boolean | | Whether the activity has heart rate data | | is_trainer | boolean | | Whether this activity was recorded on a training machine | | is_commute | boolean | | Whether this activity is a commute | | is_manual | boolean | | Whether this activity was created manually | | is_private | boolean | | Whether this activity is private | | map_polyline | string | | The detailed polyline of the activity's map | | map_summary_polyline | string | | The summary polyline of the activity's map | | workout_type | number | | The activity's workout type | | suffer_score | number | | The activity's relative effort score | | pr_count | number | | The number of personal records set during this activity | | metadata | ClubsMetadata[] | | | | raw | object | | The raw data returned by the integration for this activity | ### ClubsEvent (home-vs-away competitors with scores). Distinct from the fitness-oriented IClubsActivity. | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this event | | created_at | string (date-time) | | The date that this event was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this event was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | group_id | string | | The group this event belongs to (reference to HrisGroup) | | name | string | | The event name (or a "Home vs Away" matchup label) | | description | string | | A description or notes for the event | | type | string enum: GAME, PRACTICE, OTHER | | The event type | | status | string enum: SCHEDULED, CANCELED, TBD, FINAL | | The event status | | start_at | string (date-time) | | When the event starts (UTC) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | When the event ends (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | timezone | string | | The timezone of the event | | duration_minutes | number | | The scheduled duration, in minutes | | location_id | string | | The id of the location for this event (reference to ClubsLocation) | | location_name | string | | The name of the location for this event | | opponent_id | string | | The id of the opposing team (single-opponent providers) | | opponent_name | string | | The name of the opposing team | | home_team_name | string | | The home team's name (homeaway providers) | | away_team_name | string | | The away team's name (homeaway providers) | | home_score | number | | The home team's score | | away_score | number | | The away team's score | | score_for | number | | The connected groupteam's score (team-vs-opponent providers) | | score_against | number | | The opponent's score (team-vs-opponent providers) | | is_home | boolean | | Whether the connected groupteam is the home side | | url | string | | A URL to the event | | metadata | ClubsMetadata[] | | | | raw | object | | The raw data returned by the integration for this event | ### ClubsGroup SummaryClub / DetailedClub, but generic across providers (TeamSnap team, PlayHQ org, etc.). | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this group | | created_at | string (date-time) | | The date that this group was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this group was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The group's name | | description | string | | The group's description | | sport_type | string enum: RUNNING, CYCLING, SWIMMING, TRIATHLON, WALKING, HIKING, OTHER | | The primary sport of the group | | group_type | string enum: CASUAL_CLUB, RACING_TEAM, SHOP, COMPANY, OTHER | | The group type | | address | ClubsGroup_address | | The group's address | | member_count | number | | The number of members in the group | | following_count | number | | The number of athletes the group follows | | post_count | number | | The number of posts in the group | | is_private | boolean | | Whether the group is private | | is_verified | boolean | | Whether the group is verified | | is_featured | boolean | | Whether the group is featured | | is_admin | boolean | | Whether the authenticated user is an admin of the group | | is_owner | boolean | | Whether the authenticated user is the owner of the group | | membership_status | string enum: MEMBER, PENDING, NONE | | The authenticated user's membership status | | url | string | | The group's vanity URL | | profile_image_url | string | | URL to the group's profile picture | | cover_image_url | string | | URL to the group's cover photo | | activity_types | ClubsGroup_activity_types | | The activity types the group focuses on | | metadata | ClubsMetadata[] | | | | raw | object | | The raw data returned by the integration for this group | ### ClubsLocation formatted address string + lat/long) and PlayHQ venues (structured address parts). | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this location | | created_at | string (date-time) | | The date that this location was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this location was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | group_id | string | | The group this location is associated with (reference to HrisGroup) | | name | string | | The location name | | address | ClubsLocation_address | | | | latitude | number | | The latitude | | longitude | number | | The longitude | | telephone | string | | A contact phone number for the location | | url | string | | A URL for the location | | metadata | ClubsMetadata[] | | | | raw | object | | The raw data returned by the integration for this location | ### ClubsMember `lastname` is only a single initial. We do not synthesize an id, so `id` is typically absent. | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this member (not provided by Strava) | | created_at | string (date-time) | | The date that this member was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this member was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The full name of the member (first name + last initial for Strava) | | first_name | string | | The member's first name | | last_name | string | | The member's last name (a single initial for Strava) | | group_id | string | | The group this member belongs to (reference to HrisGroup) | | is_admin | boolean | | Whether the member is an admin of the group | | is_owner | boolean | | Whether the member is the owner of the group | | membership_status | string enum: MEMBER, PENDING, NONE | | The member's membership status | | metadata | ClubsMetadata[] | | | | raw | object | | The raw data returned by the integration for this member | ### ClubsGroup_activity_types The activity types the group focuses on ### ClubsGroup_address The group's address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### ClubsLocation_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### ClubsMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | --- # Commerce API – Endpoints & Data Models ## Commerce API data models URL: https://docs.unified.to/commerce/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /commerce/{connection_id}/item | Create an item | | GET | /commerce/{connection_id}/item | List all items | | GET | /commerce/{connection_id}/item/{id} | Retrieve an item | | PUT | /commerce/{connection_id}/item/{id} | Update an item | | DELETE | /commerce/{connection_id}/item/{id} | Remove an item | | POST | /commerce/{connection_id}/collection | Create a collection | | GET | /commerce/{connection_id}/collection | List all collections | | GET | /commerce/{connection_id}/collection/{id} | Retrieve a collection | | PUT | /commerce/{connection_id}/collection/{id} | Update a collection | | DELETE | /commerce/{connection_id}/collection/{id} | Remove a collection | | POST | /commerce/{connection_id}/inventory | Create an inventory | | GET | /commerce/{connection_id}/inventory | List all inventories | | GET | /commerce/{connection_id}/inventory/{id} | Retrieve an inventory | | PUT | /commerce/{connection_id}/inventory/{id} | Update an inventory | | DELETE | /commerce/{connection_id}/inventory/{id} | Remove an inventory | | POST | /commerce/{connection_id}/location | Create a location | | GET | /commerce/{connection_id}/location | List all locations | | GET | /commerce/{connection_id}/location/{id} | Retrieve a location | | PUT | /commerce/{connection_id}/location/{id} | Update a location | | DELETE | /commerce/{connection_id}/location/{id} | Remove a location | | POST | /commerce/{connection_id}/review | Create a review | | GET | /commerce/{connection_id}/review | List all reviews | | GET | /commerce/{connection_id}/review/{id} | Retrieve a review | | PUT | /commerce/{connection_id}/review/{id} | Update a review | | DELETE | /commerce/{connection_id}/review/{id} | Remove a review | | POST | /commerce/{connection_id}/reservation | Create a reservation | | GET | /commerce/{connection_id}/reservation | List all reservations | | GET | /commerce/{connection_id}/reservation/{id} | Retrieve a reservation | | PUT | /commerce/{connection_id}/reservation/{id} | Update a reservation | | DELETE | /commerce/{connection_id}/reservation/{id} | Remove a reservation | | GET | /commerce/{connection_id}/availability | List all availabilities | | POST | /commerce/{connection_id}/saleschannel | Create a saleschannel | | GET | /commerce/{connection_id}/saleschannel | List all saleschannels | | GET | /commerce/{connection_id}/saleschannel/{id} | Retrieve a saleschannel | | PUT | /commerce/{connection_id}/saleschannel/{id} | Update a saleschannel | | DELETE | /commerce/{connection_id}/saleschannel/{id} | Remove a saleschannel | | POST | /commerce/{connection_id}/itemvariant | Create an itemvariant | | GET | /commerce/{connection_id}/itemvariant | List all itemvariants | | GET | /commerce/{connection_id}/itemvariant/{id} | Retrieve an itemvariant | | PUT | /commerce/{connection_id}/itemvariant/{id} | Update an itemvariant | | DELETE | /commerce/{connection_id}/itemvariant/{id} | Remove an itemvariant | #### Data Models ### CommerceCollection A collection of items/products/services | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | Yes | | | public_name | string | | | | description | string | | | | public_description | string | | | | media | CommerceItemMedia[] | | | | is_visible | boolean | | | | is_active | boolean | | | | is_featured | boolean | | | | tags | CommerceCollection_tags | | | | type | string enum: COLLECTION, SAVED_SEARCH, CATEGORY | | | | parent_id | string | | | | metadata | CommerceMetadata[] | | | | item_metadata | CommerceMetadata[] | | | | raw | object | | | ### CommerceInventory | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | item_id | string | | (reference to CommerceItem) | | item_variant_id | string | | (reference to CommerceCommerceItemvariant) | | item_option_id | string | | | | location_id | string | | (reference to CommerceLocation) | | available | number | | | | metadata | CommerceMetadata[] | | | | raw | object | | | ### CommerceItem | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | public_name | string | | | | slug | string | | | | description | string | | | | global_code | string | | | | public_description | string | | | | is_active | boolean | | | | is_taxable | boolean | | | | vendor_name | string | | | | type | string | | product, service, digital-download, ... | | is_visible | boolean | | | | is_featured | boolean | | | | weight | number | | | | weight_unit | string enum: g, kg, oz, lb | | | | requires_shipping | boolean | | | | prices | CommerceItemPrice[] | | | | inventory_id | string | | | | total_stock | number | | | | tags | CommerceItem_tags | | | | media | CommerceItemMedia[] | | | | account_id | string | | Reference to Accounting Account (reference to AccountingAccount) | | metadata | CommerceMetadata[] | | | | collections | CommerceReference[] | | points to Collection with id, name, and type fields | | taxrate_id | string | | references AccountingTaxrate | | location_id | string | | (reference to CommerceLocation) | | duration | number | | minutes | | raw | object | | | ### CommerceItemvariant | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | public_name | string | | | | description | string | | | | public_description | string | | | | sku | string | | barcode, UPC, isbn, etc | | is_active | boolean | | | | is_visible | boolean | | | | is_featured | boolean | | | | media | CommerceItemMedia[] | | | | available_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | tags | CommerceItemvariant_tags | | | | width | number | | | | height | number | | | | length | number | | | | weight | number | | | | size_unit | string enum: cm, inch | | | | weight_unit | string enum: g, kg, oz, lb | | | | total_stock | number | | | | prices | CommerceItemPrice[] | | | | options | CommerceItemOption[] | | | | inventory_id | string | | | | requires_shipping | boolean | | | | metadata | CommerceMetadata[] | | | | items | CommerceReference[] | | references CommerceItem | | raw | object | | | ### CommerceLocation | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | address | CommerceLocation_address | | | | description | string | | | | is_active | boolean | | | | language_locale | string | | | | parent_id | string | | | | currency | string | | | | location_type | string enum: RESTAURANT, SALON, WAREHOUSE, STORE, OTHER | | | | telephones | CommerceTelephone[] | | | | rating | number | | | | review_count | number | | | | price_level | string | | | | latitude | number | | | | longitude | number | | | | image_url | string | | | | web_url | string | | | | media | CommerceItemMedia[] | | | | metadata | CommerceMetadata[] | | | | raw | object | | | | categories | CommerceLocation_categories | | | ### CommerceReservation | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | location_id | string | | (reference to CommerceLocation) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | size | number | | | | status | string enum: PENDING, CONFIRMED, CANCELLED, NO_SHOW, COMPLETED | | | | guest_name | string | | | | guest_phone | string | | | | guest_email | string | | | | notes | string | | | | item_id | string | | (reference to CommerceItem) | | item_name | string | | | | staff_user_id | string | | (reference to HrisEmployee) | | staff_name | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | url | string | | | | metadata | CommerceMetadata[] | | | | raw | object | | | ### CommerceReview | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | item_id | string | | Reference to the product being reviewed (reference to CommerceItem) | | item_variant_id | string | | Optional reference to specific variant if applicable (reference to CommerceCommerceItemvariant) | | location_id | string | | (reference to CommerceLocation) | | rating | number | | 1-5 rating | | title | string | | | | content | string | | | | author_name | string | | | | author_email | string | | | | author_avatar_url | string | | | | author_location | string | | | | verified_purchase | boolean | | | | helpful_votes | number | | | | unhelpful_votes | number | | | | media | CommerceItemMedia[] | | Photosvideos attached to the review | | status | string enum: PENDING, APPROVED, REJECTED, SPAM | | | | is_verified | boolean | | | | is_featured | boolean | | | | is_public | boolean | | | | comments | CommerceReviewComment[] | | | | metadata | CommerceMetadata[] | | | | url | string | | | | raw | object | | Original review data from source platform | ### CommerceSaleschannel | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | slug | string | | maps to code (name) a stable identifier per channel | | description | string | | | | is_active | boolean | | | | collections | CommerceReference[] | | points to a CommerceCollection | | metadata | CommerceMetadata[] | | | | raw | object | | | ### CommerceCollection_tags ### CommerceItemMedia | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | url | string | Yes | | | alt | string | | | | type | string enum: image, video | | | | height | number | | | | width | number | | | | position | number | | 1 is the first | | metadata | CommerceMetadata[] | | | ### CommerceItemOption | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | Yes | | | position | number | | | | values | CommerceItemOption_values | Yes | | ### CommerceItemOption_values ### CommerceItemPrice | Field | Type | Required | Description | |---|---|---|---| | price | number | Yes | | | compare_at_price | number | | The original price of the item before an adjustment or a sale. | | currency | string | | | ### CommerceItem_tags ### CommerceItemvariant_tags ### CommerceLocation_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### CommerceLocation_categories ### CommerceMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The ID of the custom field definition (from the metadata/metadata endpoint). | | value | object | | The field's value, typed by `format`: DATE as ISO-8601 (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM:SSZ` for date-times); NUMBER, PRICE and CURRENCY as a number; BOOLEAN and YES_NO as true/false; MULTIPLE_SELECT as an array of option values; otherwise a string. | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | The data type of the custom field, which determines how `value` is formatted. | | extra_data | object | | | | slug | string | | The integration's API name (key) of an existing custom field. List valid slugs for this object with the metadata/metadata endpoint; unknown slugs are rejected by the integration. | | description | string | | | | is_required | boolean | | | ### CommerceReference | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | | type | string | | | ### CommerceReviewComment | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | content | string | | | | author_name | string | | | | author_email | string | | | | author_avatar_url | string | | | | author_location | string | | | | helpful_votes | number | | | | unhelpful_votes | number | | | | status | string enum: PENDING, APPROVED, REJECTED, SPAM | | | | is_verified | boolean | | | | is_public | boolean | | | | metadata | CommerceMetadata[] | | | | raw | object | | Original review data from source platform | ### CommerceTelephone | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | | | type | string enum: WORK, HOME, FAX, MOBILE, OTHER | | | --- # Compute API – Endpoints & Data Models ## Compute API data models URL: https://docs.unified.to/compute/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /compute/{connection_id}/instance | Create an instance | | GET | /compute/{connection_id}/instance | List all instances | | GET | /compute/{connection_id}/instance/{id} | Retrieve an instance | | PUT | /compute/{connection_id}/instance/{id} | Update an instance | | DELETE | /compute/{connection_id}/instance/{id} | Remove an instance | | POST | /compute/{connection_id}/template | Create a template | | GET | /compute/{connection_id}/template | List all templates | | GET | /compute/{connection_id}/template/{id} | Retrieve a template | | PUT | /compute/{connection_id}/template/{id} | Update a template | | DELETE | /compute/{connection_id}/template/{id} | Remove a template | | GET | /compute/{connection_id}/gputype/{id} | Retrieve a gputype | | GET | /compute/{connection_id}/gputype | List all gputypes | | GET | /compute/{connection_id}/region/{id} | Retrieve a region | | GET | /compute/{connection_id}/region | List all regions | | POST | /compute/{connection_id}/volume | Create a volume | | GET | /compute/{connection_id}/volume | List all volumes | | GET | /compute/{connection_id}/volume/{id} | Retrieve a volume | | PUT | /compute/{connection_id}/volume/{id} | Update a volume | | DELETE | /compute/{connection_id}/volume/{id} | Remove a volume | | POST | /compute/{connection_id}/snapshot | Create a snapshot | | GET | /compute/{connection_id}/snapshot | List all snapshots | | GET | /compute/{connection_id}/snapshot/{id} | Retrieve a snapshot | | DELETE | /compute/{connection_id}/snapshot/{id} | Remove a snapshot | | GET | /compute/{connection_id}/usage | List all usages | | POST | /compute/{connection_id}/key | Create a key | | GET | /compute/{connection_id}/key | List all keys | | GET | /compute/{connection_id}/key/{id} | Retrieve a key | | PUT | /compute/{connection_id}/key/{id} | Update a key | | DELETE | /compute/{connection_id}/key/{id} | Remove a key | | POST | /compute/{connection_id}/service | Create a service | | GET | /compute/{connection_id}/service | List all services | | GET | /compute/{connection_id}/service/{id} | Retrieve a service | | PUT | /compute/{connection_id}/service/{id} | Update a service | | DELETE | /compute/{connection_id}/service/{id} | Remove a service | | POST | /compute/{connection_id}/deployment | Create a deployment | | GET | /compute/{connection_id}/deployment | List all deployments | | GET | /compute/{connection_id}/deployment/{id} | Retrieve a deployment | | PUT | /compute/{connection_id}/deployment/{id} | Update a deployment | | DELETE | /compute/{connection_id}/deployment/{id} | Remove a deployment | | POST | /compute/{connection_id}/domain | Create a domain | | GET | /compute/{connection_id}/domain | List all domains | | GET | /compute/{connection_id}/domain/{id} | Retrieve a domain | | PUT | /compute/{connection_id}/domain/{id} | Update a domain | | DELETE | /compute/{connection_id}/domain/{id} | Remove a domain | | POST | /compute/{connection_id}/envvar | Create an envvar | | GET | /compute/{connection_id}/envvar | List all envvars | | GET | /compute/{connection_id}/envvar/{id} | Retrieve an envvar | | PUT | /compute/{connection_id}/envvar/{id} | Update an envvar | | DELETE | /compute/{connection_id}/envvar/{id} | Remove an envvar | #### Data Models ### ComputeDeployment | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the deployment | | created_at | string (date-time) | | When the deployment was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the deployment was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | service_id | string | | The service this deployment belongs to | | status | string enum: QUEUED, BUILDING, DEPLOYING, LIVE, FAILED, CANCELED, SUPERSEDED, OTHER | | Normalized deployment status; set to CANCELED on update to cancel | | trigger | string enum: GIT, MANUAL, API, ROLLBACK, OTHER | | What started the deployment | | environment | string enum: PRODUCTION, PREVIEW, DEVELOPMENT, OTHER | | Target environment | | commit_identifier | string | | Source commit SHA (reference to RepoCommit) | | commit_message | string | | Source commit message | | branch | string | | Source branch | | image | string | | Container image deployed, for image-based services | | url | string | | URL where this deployment is served | | started_at | string (date-time) | | When the build started (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | finished_at | string (date-time) | | When the deployment finished (live, failed or canceled) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | error | string | | Failure description | | metadata | ComputeMetadata[] | | Vendor labels, tags and metadata | | raw | object | | Raw data from integration | ### ComputeDomain | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the domain | | created_at | string (date-time) | | When the domain was added (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the domain was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | service_id | string | | The service this domain routes to | | name | string | | The hostname (eg. app.example.com) | | status | string enum: PENDING, VERIFIED, FAILED, OTHER | | DNS/ownership verification status | | is_primary | boolean | | Whether this is the service's primary domain | | redirect_url | string | | Where the domain redirects, when it is a redirect | | metadata | ComputeMetadata[] | | | | raw | object | | Raw data from integration | ### ComputeEnvvar | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the environment variable | | created_at | string (date-time) | | When the variable was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the variable was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | service_id | string | | The service the variable belongs to | | name | string | | Variable name | | value | string | | Variable value (write-only; never returned) | | environments | ComputeEnvvar_environments | | Environments the variable applies to | | is_secret | boolean | | Whether the vendor stores the value as a secret | | metadata | ComputeMetadata[] | | | | raw | object | | Raw data from integration | ### ComputeGputype | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the GPU or instance type (vendor SKU) | | created_at | string (date-time) | | When the GPU type was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the GPU type was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Display name of the type | | gpu_model | string enum: NVIDIA_GB300, NVIDIA_B300, NVIDIA_GB200, NVIDIA_B200, NVIDIA_GH200, NVIDIA_H200, NVIDIA_H100_SXM, NVIDIA_H100_PCIE, NVIDIA_H100_NVL, NVIDIA_A100_SXM_80GB, NVIDIA_A100_PCIE_80GB, NVIDIA_A100_40GB, NVIDIA_L40S, NVIDIA_L40, NVIDIA_L4, NVIDIA_A40, NVIDIA_A10, NVIDIA_RTX_PRO_6000, NVIDIA_RTX_6000_ADA, NVIDIA_RTX_A6000, NVIDIA_RTX_A5000, NVIDIA_RTX_A4000, NVIDIA_RTX_5090, NVIDIA_RTX_4090, NVIDIA_RTX_3090, NVIDIA_V100, NVIDIA_T4, AMD_MI355X, AMD_MI325X, AMD_MI300X, AMD_MI250, OTHER | | Normalized GPU model | | gpu_manufacturer | string enum: NVIDIA, AMD, INTEL, OTHER | | GPU manufacturer | | sku | string | | The vendor's own SKU or type name | | gpu_count | number | | GPUs included in one unit of this type | | gpu_memory_mb | number | | Memory per GPU in MiB | | cpu_count | number | | vCPUs included in one unit of this type | | memory_mb | number | | System memory in MiB | | disk_mb | number | | Local disk in MiB | | architecture | string enum: X86_64, ARM64, OTHER | | CPU architecture | | prices | ComputePrice[] | | List prices by market and region | | availability | ComputeAvailability[] | | Availability by region | | metadata | ComputeMetadata[] | | | | raw | object | | Raw data from integration | ### ComputeInstance | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the instance | | created_at | string (date-time) | | When the instance was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the instance was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Display name of the instance | | type | string enum: CONTAINER, VM, BAREMETAL, SANDBOX, OTHER | | The kind of compute unit | | status | string enum: PROVISIONING, STARTING, RUNNING, STOPPING, STOPPED, PAUSING, PAUSED, RESUMING, RESTARTING, TERMINATING, TERMINATED, PREEMPTED, ERROR, OTHER | | Normalized lifecycle status; set on update to start, stop, pause, resume, restart or terminate | | status_reason | string | | Vendor explanation of the current status (eg. shutdown or failure reason) | | allowed_actions | ComputeInstance_allowed_actions | | Lifecycle actions the vendor currently allows | | market | string enum: ON_DEMAND, SPOT, RESERVED, COMMUNITY, OTHER | | Pricing market the instance runs in | | region_id | string | | The region the instance runs in | | template_id | string | | The template the instance was created from | | image | string | | Container or OS image, when not created from a template | | source_snapshot_id | string | | The snapshot the instance was restored or forked from | | parent_instance_id | string | | The instance this one was forked from | | gputype_id | string | | The GPU or instance type of this instance | | gpu_model | string enum: NVIDIA_GB300, NVIDIA_B300, NVIDIA_GB200, NVIDIA_B200, NVIDIA_GH200, NVIDIA_H200, NVIDIA_H100_SXM, NVIDIA_H100_PCIE, NVIDIA_H100_NVL, NVIDIA_A100_SXM_80GB, NVIDIA_A100_PCIE_80GB, NVIDIA_A100_40GB, NVIDIA_L40S, NVIDIA_L40, NVIDIA_L4, NVIDIA_A40, NVIDIA_A10, NVIDIA_RTX_PRO_6000, NVIDIA_RTX_6000_ADA, NVIDIA_RTX_A6000, NVIDIA_RTX_A5000, NVIDIA_RTX_A4000, NVIDIA_RTX_5090, NVIDIA_RTX_4090, NVIDIA_RTX_3090, NVIDIA_V100, NVIDIA_T4, AMD_MI355X, AMD_MI325X, AMD_MI300X, AMD_MI250, OTHER | | Normalized GPU model | | gpu_count | number | | Number of GPUs attached (0 for CPU-only) | | sku | string | | The vendor's own SKU or size name | | cpu_count | number | | Number of vCPUs; may be fractional | | memory_mb | number | | System memory in MiB | | disk_mb | number | | Disk size in MiB | | architecture | string enum: X86_64, ARM64, OTHER | | CPU architecture | | price_amount | number | | List price for the instance | | price_unit | string enum: PER_GPU_HOUR, PER_INSTANCE_HOUR, OTHER | | Unit that price_amount is quoted in | | currency | string | | Currency of price_amount (ISO 4217) | | volume_ids | ComputeInstance_volume_ids | | Volumes mounted on the instance | | endpoints | ComputeEndpoint[] | | Exposed ports and their URLs | | ssh | ComputeInstance_ssh | | SSH connection details | | has_internet_access | boolean | | Whether outbound network access is allowed | | timeout_seconds | number | | Seconds until the instance expires; set on update to extend it | | expires_at | string (date-time) | | When the instance will expire (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | on_expire | string enum: TERMINATE, PAUSE, STOP, OTHER | | What happens when the instance expires | | idle_timeout_seconds | number | | Seconds of inactivity before on_idle applies | | on_idle | string enum: TERMINATE, PAUSE, STOP, OTHER | | What happens when the instance is idle | | project_identifier | string | | Vendor project, team or app that groups the instance (reference to TaskProject) | | service_id | string | | The service (app) this instance runs for, on hosting platforms | | env | ComputeEnv[] | | Environment variables (write-only values) | | started_at | string (date-time) | | When the instance last started (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | terminated_at | string (date-time) | | When the instance was terminated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | ComputeMetadata[] | | Vendor labels, tags and metadata | | raw | object | | Raw data from integration | ### ComputeKey | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the key | | created_at | string (date-time) | | When the key was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the key was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Display name of the key | | type | string enum: SSH, REGISTRY, SECRET, OTHER | | The kind of credential | | public_key | string | | SSH public key | | private_key | string | | SSH private key generated by the vendor; returned only in the create response | | fingerprint | string | | SSH key fingerprint | | registry_url | string | | Container registry URL (REGISTRY keys) | | username | string | | Registry or SSH username | | secret | string | | Registry password or secret value (write-only; never returned) | | instance_id | string | | The instance this key is scoped to, for per-instance keys | | expires_at | string (date-time) | | When the key expires (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | ComputeMetadata[] | | | | raw | object | | Raw data from integration | ### ComputeRegion | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the region or data center | | created_at | string (date-time) | | When the region was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the region was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Display name of the region | | location | string | | Free-text location (eg. city or state) | | country | string | | Country code | | gpu_models | ComputeRegion_gpu_models | | GPU models offered in this region | | metadata | ComputeMetadata[] | | | | raw | object | | Raw data from integration | ### ComputeService | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the service (app or project) | | created_at | string (date-time) | | When the service was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the service was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Display name of the service | | type | string enum: WEB, WORKER, CRON, STATIC, PRIVATE, OTHER | | The kind of service | | status | string enum: CREATING, DEPLOYING, LIVE, SUSPENDED, FAILED, OTHER | | Normalized status; set to SUSPENDED or LIVE on update to suspend or resume | | runtime | string | | Runtime or framework (eg. node, python, docker, nextjs) | | region_id | string | | The region the service runs in | | gputype_id | string | | The plan or instance type the service runs on | | sku | string | | The vendor's own plan or size name | | instance_count | number | | Current number of running instances | | min_instance_count | number | | Autoscaling minimum | | max_instance_count | number | | Autoscaling maximum | | repo_url | string | | Source repository URL | | branch | string | | Source branch deployed | | image | string | | Container image, for image-based services | | build_command | string | | Build command | | start_command | string | | Start command | | url | string | | Public URL of the service | | project_identifier | string | | Vendor project, team or environment that groups the service (reference to TaskProject) | | env | ComputeEnv[] | | Environment variables (write-only values) set when creating the service | | metadata | ComputeMetadata[] | | Vendor labels, tags and metadata | | raw | object | | Raw data from integration | ### ComputeSnapshot | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the snapshot | | created_at | string (date-time) | | When the snapshot was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the snapshot was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Display name of the snapshot | | instance_id | string | | The instance the snapshot was taken from | | volume_id | string | | The volume the snapshot was taken from | | type | string enum: MEMORY, DISK, OTHER | | Whether memory state is included | | status | string enum: CREATING, READY, FAILED, DELETING, OTHER | | Normalized snapshot status | | size_mb | number | | Snapshot size in MiB | | expires_at | string (date-time) | | When the snapshot will be deleted (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | ComputeMetadata[] | | Vendor labels, tags and metadata | | raw | object | | Raw data from integration | ### ComputeTemplate | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the template | | created_at | string (date-time) | | When the template was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the template was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Display name of the template | | aliases | ComputeTemplate_aliases | | Alternative names or tags for the template | | image | string | | Container or OS image | | dockerfile | string | | Dockerfile used to build the template | | start_command | string | | Command run when an instance starts | | status | string enum: PENDING, BUILDING, READY, FAILED, OTHER | | Normalized build status | | gpu_model | string enum: NVIDIA_GB300, NVIDIA_B300, NVIDIA_GB200, NVIDIA_B200, NVIDIA_GH200, NVIDIA_H200, NVIDIA_H100_SXM, NVIDIA_H100_PCIE, NVIDIA_H100_NVL, NVIDIA_A100_SXM_80GB, NVIDIA_A100_PCIE_80GB, NVIDIA_A100_40GB, NVIDIA_L40S, NVIDIA_L40, NVIDIA_L4, NVIDIA_A40, NVIDIA_A10, NVIDIA_RTX_PRO_6000, NVIDIA_RTX_6000_ADA, NVIDIA_RTX_A6000, NVIDIA_RTX_A5000, NVIDIA_RTX_A4000, NVIDIA_RTX_5090, NVIDIA_RTX_4090, NVIDIA_RTX_3090, NVIDIA_V100, NVIDIA_T4, AMD_MI355X, AMD_MI325X, AMD_MI300X, AMD_MI250, OTHER | | Default GPU model | | gpu_count | number | | Default number of GPUs | | cpu_count | number | | Default number of vCPUs | | memory_mb | number | | Default memory in MiB | | disk_mb | number | | Default disk in MiB | | ports | ComputeTemplate_ports | | Ports exposed by instances from this template | | env | ComputeEnv[] | | Default environment variables (write-only values) | | is_public | boolean | | Whether the template is publicly shared | | last_used_at | string (date-time) | | When an instance was last started from this template (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | ComputeMetadata[] | | Vendor labels, tags and metadata | | raw | object | | Raw data from integration | ### ComputeVolume | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the volume | | created_at | string (date-time) | | When the volume was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the volume was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Display name of the volume | | type | string enum: STANDARD, HIGH_PERFORMANCE, SHARED, LOCAL, OTHER | | The kind of volume | | status | string enum: CREATING, AVAILABLE, IN_USE, DELETING, ERROR, OTHER | | Normalized volume status | | size_mb | number | | Provisioned size in MiB | | used_mb | number | | Used space in MiB | | region_id | string | | The region the volume lives in | | availability_zone | string | | The availability zone within the region, where the vendor places volumes per zone | | instance_ids | ComputeVolume_instance_ids | | Instances the volume is mounted on | | metadata | ComputeMetadata[] | | Vendor labels, tags and metadata | | raw | object | | Raw data from integration | ### ComputeAvailability | Field | Type | Required | Description | |---|---|---|---| | region_id | string | | The region this availability applies to | | level | string enum: NONE, LOW, MEDIUM, HIGH, OTHER | | Normalized stock level | | quantity | number | | Number of units available, when the vendor reports a count | ### ComputeEndpoint | Field | Type | Required | Description | |---|---|---|---| | port | number | | The port inside the instance | | protocol | string enum: HTTP, HTTPS, TCP, UDP, OTHER | | The protocol served on this port | | url | string | | Public or proxied URL for this port | | is_auth_required | boolean | | Whether the URL requires a vendor access token | ### ComputeEnv | Field | Type | Required | Description | |---|---|---|---| | name | string | Yes | Environment variable name | | value | string | | Environment variable value (write-only; never returned) | ### ComputeEnvvar_environments Environments the variable applies to ### ComputeInstance_allowed_actions Lifecycle actions the vendor currently allows ### ComputeInstance_ssh SSH connection details | Field | Type | Required | Description | |---|---|---|---| | host | string | | SSH host | | port | number | | SSH port | | username | string | | SSH username | ### ComputeInstance_volume_ids Volumes mounted on the instance ### ComputeMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The label, tag or metadata identifier | | slug | string | | The label, tag or metadata key | | value | object | | The label, tag or metadata value | | namespace | string | | The origin of the value (eg. labels, tags, metadata) | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, ARRAY, OBJECT | | The data type of the value | | extra_data | object | | Additional data about the value | ### ComputePrice | Field | Type | Required | Description | |---|---|---|---| | market | string enum: ON_DEMAND, SPOT, RESERVED, COMMUNITY, OTHER | | Pricing market | | region_id | string | | Region the price applies to, when it varies by region | | price_amount | number | | List price | | price_unit | string enum: PER_GPU_HOUR, PER_INSTANCE_HOUR, OTHER | | Unit that price_amount is quoted in | | currency | string | | Currency of price_amount (ISO 4217) | ### ComputeRegion_gpu_models GPU models offered in this region ### ComputeTemplate_aliases Alternative names or tags for the template ### ComputeVolume_instance_ids Instances the volume is mounted on --- # Crm API – Endpoints & Data Models ## Crm API data models URL: https://docs.unified.to/crm/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /crm/{connection_id}/deal | Create a deal | | GET | /crm/{connection_id}/deal | List all deals | | GET | /crm/{connection_id}/deal/{id} | Retrieve a deal | | PUT | /crm/{connection_id}/deal/{id} | Update a deal | | DELETE | /crm/{connection_id}/deal/{id} | Remove a deal | | POST | /crm/{connection_id}/contact | Create a contact | | GET | /crm/{connection_id}/contact | List all contacts | | GET | /crm/{connection_id}/contact/{id} | Retrieve a contact | | PUT | /crm/{connection_id}/contact/{id} | Update a contact | | DELETE | /crm/{connection_id}/contact/{id} | Remove a contact | | POST | /crm/{connection_id}/company | Create a company | | GET | /crm/{connection_id}/company | List all companies | | GET | /crm/{connection_id}/company/{id} | Retrieve a company | | PUT | /crm/{connection_id}/company/{id} | Update a company | | DELETE | /crm/{connection_id}/company/{id} | Remove a company | | POST | /crm/{connection_id}/event | Create an event | | GET | /crm/{connection_id}/event | List all events | | GET | /crm/{connection_id}/event/{id} | Retrieve an event | | PUT | /crm/{connection_id}/event/{id} | Update an event | | DELETE | /crm/{connection_id}/event/{id} | Remove an event | | POST | /crm/{connection_id}/lead | Create a lead | | GET | /crm/{connection_id}/lead | List all leads | | GET | /crm/{connection_id}/lead/{id} | Retrieve a lead | | PUT | /crm/{connection_id}/lead/{id} | Update a lead | | DELETE | /crm/{connection_id}/lead/{id} | Remove a lead | | GET | /crm/{connection_id}/taxonomy | List all taxonomies | | POST | /crm/{connection_id}/pipeline | Create a pipeline | | GET | /crm/{connection_id}/pipeline | List all pipelines | | GET | /crm/{connection_id}/pipeline/{id} | Retrieve a pipeline | | PUT | /crm/{connection_id}/pipeline/{id} | Update a pipeline | | DELETE | /crm/{connection_id}/pipeline/{id} | Remove a pipeline | #### Data Models ### CrmCompany A company represents an organization that optionally is associated with a deal and/or contacts | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this company object | | created_at | string (date-time) | | The date that this company object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this company object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the company | | deal_ids | CrmCompany_deal_ids | | An array of deal IDs associated with this contact (reference to CrmDeal) | | contact_ids | CrmCompany_contact_ids | | An array of contact IDs associated with this company (reference to CrmContact) | | emails | CrmEmail[] | | | | telephones | CrmTelephone[] | | | | websites | CrmCompany_websites | | | | address | CrmCompany_address | | | | is_active | boolean | | | | tags | CrmCompany_tags | | | | description | string | | | | industry | string | | | | link_urls | CrmCompany_link_urls | | Additional URLs associated with the contact e.g., LinkedIn, website, etc | | employees | number | | | | timezone | string | | | | user_id | string | | (reference to HrisEmployee) | | metadata | CrmMetadata[] | | | | domains | CrmCompany_domains | | | | raw | object | | The raw data returned by the integration for this company | ### CrmContact A contact represents a person that optionally is associated with a deal and/or a company | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this contact object | | created_at | string (date-time) | | The date that this contact object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this contact object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the contact | | first_name | string | | | | last_name | string | | | | title | string | | The job title of the contact | | company | string | | The companyorganization name of the contact | | emails | CrmEmail[] | | An array of email addresses for this contact | | telephones | CrmTelephone[] | | An array of telephones for this contact | | deal_ids | CrmContact_deal_ids | | An array of deal IDs associated with this contact (reference to CrmDeal) | | company_ids | CrmContact_company_ids | | An array of company IDs associated with this contact (reference to CrmCompany) | | address | CrmContact_address | | | | user_id | string | | (reference to HrisEmployee) | | link_urls | CrmContact_link_urls | | Additional URLs associated with the contact e.g., LinkedIn, website, etc | | metadata | CrmMetadata[] | | | | department | string | | | | image_url | string | | | | raw | object | | The raw data returned by the integration for this contact | ### CrmDeal A deal represents an opportunity with companies and/or contacts | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this deal object | | created_at | string (date-time) | | The date that this deal object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this deal object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of this deal | | amount | number | | The potential amount that deal could be worth | | currency | string | | The currency for the deal amount (3 letter ISO code; eg. USD) | | closed_at | string (date-time) | | The date that this deal closed (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | closing_at | string (date-time) | | expected closing date (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | stages | CrmReference[] | | | | pipelines | CrmReference[] | | | | source | string | | The source for this deal | | probability | number | | | | tags | CrmDeal_tags | | | | lost_reason | string | | | | won_reason | string | | | | user_id | string | | (reference to HrisEmployee) | | contact_ids | CrmDeal_contact_ids | | (reference to CrmContact) | | company_ids | CrmDeal_company_ids | | (reference to CrmCompany) | | description | string | | | | metadata | CrmMetadata[] | | | | raw | object | | The raw data returned by the integration for this deal | ### CrmEvent An event represents an event, activity, or engagement and is always associated with a deal, contact, or company | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this event object | | created_at | string (date-time) | | The date that this event object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this event object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | type | string enum: NOTE, EMAIL, TASK, MEETING, CALL, MARKETING_EMAIL, FORM, PAGE_VIEW | | The type of event | | note | CrmEvent_note | | The note object, when type = note | | meeting | CrmEvent_meeting | | The meeting object, when type = meeting | | email | CrmEvent_email | | The email object, when type = email | | call | CrmEvent_call | | The call object, when type = call | | task | CrmEvent_task | | The task object, when type = task | | marketing_email | CrmEvent_marketing_email | | | | form | CrmEvent_form | | | | page_view | CrmEvent_page_view | | | | deal_ids | CrmEvent_deal_ids | | An array of deal IDs associated with this event (reference to CrmDeal) | | company_ids | CrmEvent_company_ids | | An array of company IDs associated with this event (reference to CrmCompany) | | contact_ids | CrmEvent_contact_ids | | An array of contact IDs associated with this event (reference to CrmContact) | | lead_ids | CrmEvent_lead_ids | | (reference to CrmLead) | | user_id | string | | (reference to HrisEmployee) | | metadata | CrmMetadata[] | | | | raw | object | | The raw data returned by the integration for this event. | ### CrmLead | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The lead's full name | | first_name | string | | | | last_name | string | | | | user_id | string | | (reference to HrisEmployee) | | creator_user_id | string | | (reference to HrisEmployee) | | contact_id | string | | (reference to CrmContact) | | company_id | string | | (reference to CrmCompany) | | company_name | string | | The name of the company the lead works for | | is_active | boolean | | | | address | CrmLead_address | | | | emails | CrmEmail[] | | | | telephones | CrmTelephone[] | | | | source | string | | | | status | string | | | | title | string | | The job title of the lead | | link_urls | CrmLead_link_urls | | | | metadata | CrmMetadata[] | | Custom field values. Include only existing custom fields and only the ones being set; omit to leave them unchanged. | | raw | object | | | ### CrmPipeline | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | is_active | boolean | | | | deal_probability | number | | | | display_order | number | | | | stages | CrmStage[] | | | | metadata | CrmMetadata[] | | | | raw | object | | | ### CrmCompany_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### CrmCompany_contact_ids An array of contact IDs associated with this company ### CrmCompany_deal_ids An array of deal IDs associated with this contact ### CrmCompany_domains ### CrmCompany_link_urls Additional URLs associated with the contact e.g., LinkedIn, website, etc ### CrmCompany_tags ### CrmCompany_websites ### CrmContact_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### CrmContact_company_ids An array of company IDs associated with this contact ### CrmContact_deal_ids An array of deal IDs associated with this contact ### CrmContact_link_urls Additional URLs associated with the contact e.g., LinkedIn, website, etc ### CrmDeal_company_ids ### CrmDeal_contact_ids ### CrmDeal_tags ### CrmEmail | Field | Type | Required | Description | |---|---|---|---| | email | string | | | | type | string enum: WORK, HOME, OTHER | | | ### CrmEventFormField | Field | Type | Required | Description | |---|---|---|---| | name | string | | | | type | string enum: TEXT, NUMBER, DATE, BOOLEAN, MULTIPLE_CHOICE, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, EMAIL, PHONE, YES_NO, CURRENCY, URL | | | | options | CrmEventFormOption[] | | | | required | boolean | | | ### CrmEventFormOption | Field | Type | Required | Description | |---|---|---|---| | label | string | | | | value | string | | | ### CrmEvent_call The call object, when type = call | Field | Type | Required | Description | |---|---|---|---| | duration | number | | The event call's duration in minutes | | description | string | | The event call's descriptionnotes | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### CrmEvent_company_ids An array of company IDs associated with this event ### CrmEvent_contact_ids An array of contact IDs associated with this event ### CrmEvent_deal_ids An array of deal IDs associated with this event ### CrmEvent_email The email object, when type = email | Field | Type | Required | Description | |---|---|---|---| | from | string | | The event email's from name & email address (name ) | | to | CrmEvent_email_to | | The event email's "to" name & email (name ) | | cc | CrmEvent_email_cc | | The event email's cc name & email (name ) | | subject | string | | The event email's subject | | body | string | | The event email's body | | attachment_file_ids | CrmEvent_email_attachment_file_ids | | | ### CrmEvent_email_attachment_file_ids ### CrmEvent_email_cc The event email's cc name & email (name ) ### CrmEvent_email_to The event email's "to" name & email (name ) ### CrmEvent_form | Field | Type | Required | Description | |---|---|---|---| | name | string | | | | archived_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | fields | CrmEventFormField[] | | | | redirect_url | string | | | ### CrmEvent_lead_ids ### CrmEvent_marketing_email | Field | Type | Required | Description | |---|---|---|---| | name | string | | | | from | string | | The event email's from name & email address (name ) | | to | CrmEvent_marketing_email_to | | The event email's "to" name & email (name ) | | cc | CrmEvent_marketing_email_cc | | The event email's cc name & email (name ) | | subject | string | | The event email's subject | | body | string | | The event email's body | | attachment_file_ids | CrmEvent_marketing_email_attachment_file_ids | | | ### CrmEvent_marketing_email_attachment_file_ids ### CrmEvent_marketing_email_cc The event email's cc name & email (name ) ### CrmEvent_marketing_email_to The event email's "to" name & email (name ) ### CrmEvent_meeting The meeting object, when type = meeting | Field | Type | Required | Description | |---|---|---|---| | start_at | string (date-time) | | The event meeting's start datetime (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | The event meeting's end datetime (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | title | string | | The event meeting's topicsubject | | description | string | | The event meeting's agendadescription | ### CrmEvent_note The note object, when type = note | Field | Type | Required | Description | |---|---|---|---| | description | string | | The event note's description | | title | string | | | ### CrmEvent_page_view | Field | Type | Required | Description | |---|---|---|---| | count | number | | | | average | number | | | | url | string | | | ### CrmEvent_task The task object, when type = task | Field | Type | Required | Description | |---|---|---|---| | name | string | | The event task's nametitle/subject | | status | string enum: COMPLETED, NOT_STARTED, WORK_IN_PROGRESS, DEFERRED | | The event task's status | | description | string | | The event task's descriptionnote | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | priority | string enum: HIGH, MEDIUM, LOW | | | ### CrmLead_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### CrmLead_link_urls ### CrmMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The ID of the custom field definition (from the metadata/metadata endpoint). | | slug | string | | The integration's API name of an existing custom field (e.g. `Region__c` in Salesforce). List valid slugs for this object with the metadata/metadata endpoint; unknown slugs are rejected by the integration. | | value | object | | The field's value, typed by `format`: DATE as ISO-8601 (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM:SSZ` for date-times); NUMBER, PRICE and CURRENCY as a number; BOOLEAN and YES_NO as true/false; MULTIPLE_SELECT as an array of option values; otherwise a string. | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | The data type of the custom field, which determines how `value` is formatted. | | extra_data | object | | | ### CrmReference | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | | type | string | | | ### CrmStage | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | active | boolean | | | | deal_probability | number | | | | is_closed | boolean | | | | display_order | number | | | ### CrmTelephone | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | | | type | string enum: WORK, HOME, OTHER, FAX, MOBILE | | | --- # Datastore API – Endpoints & Data Models ## Datastore API data models URL: https://docs.unified.to/datastore/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /datastore/{connection_id}/database | Create a database | | GET | /datastore/{connection_id}/database | List all databases | | GET | /datastore/{connection_id}/database/{id} | Retrieve a database | | PUT | /datastore/{connection_id}/database/{id} | Update a database | | DELETE | /datastore/{connection_id}/database/{id} | Remove a database | | POST | /datastore/{connection_id}/table | Create a table | | GET | /datastore/{connection_id}/table | List all tables | | GET | /datastore/{connection_id}/table/{id} | Retrieve a table | | PUT | /datastore/{connection_id}/table/{id} | Update a table | | DELETE | /datastore/{connection_id}/table/{id} | Remove a table | | POST | /datastore/{connection_id}/record | Create a record | | GET | /datastore/{connection_id}/record | List all records | | GET | /datastore/{connection_id}/record/{id} | Retrieve a record | | PUT | /datastore/{connection_id}/record/{id} | Update a record | | DELETE | /datastore/{connection_id}/record/{id} | Remove a record | | POST | /datastore/{connection_id}/query | Create a query | #### Data Models ### DatastoreDatabase | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The database's name | | description | string | | | | web_url | string | | | | is_active | boolean | | | | password | string | | | | region | string | | | | metadata | DatastoreMetadata[] | | | | raw | object | | | ### DatastoreQuery | Field | Type | Required | Description | |---|---|---|---| | table_id | string | | | | database_id | string | | | | query | DatastoreQuery_query | | What to run: a raw `sql` statement, or a structured query (select, filter, sort, limit/offset, aggregate) | | response | DatastoreQuery_response | | | | metadata | DatastoreMetadata[] | | | | raw | object | | | ### DatastoreRecord | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | database_id | string | | source Database ID | | table_id | string | | source Database Table ID | | fields | DatastoreRecord_fields | Yes | The record's values, keyed by the table's field (column) names; each value is a typed value object (e.g. { "string": "Acme" } or { "number": 3 }) | | row_number | number | | | | metadata | DatastoreMetadata[] | | | | raw | object | | | ### DatastoreTable | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | database_id | string | | source Database ID | | fields | DatastoreField[] | | | | relationships | DatastoreRelationship[] | | | | web_url | string | | | | parent_id | string | | | | metadata | DatastoreMetadata[] | | | | raw | object | | | ### DatastoreField | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | Yes | | | type | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, CURRENCY, URL, EMAIL, PHONE, LINKED_RECORD, RELATION | Yes | | | original_type | string | Yes | | | is_required | boolean | | | | is_indexed | boolean | | | | is_primary_key | boolean | | | | is_unique | boolean | | | | is_nullable | boolean | | | | max_length | number | | | | precision | number | | | | scale | number | | | | default_value | DatastoreField_default_value | | | | description | string | | | | raw | object | | | ### DatastoreFieldValue | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFieldValue_selection | | | | is_null | boolean | | | ### DatastoreFieldValue_selection ### DatastoreField_default_value | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreField_default_value_selection | | | | is_null | boolean | | | ### DatastoreField_default_value_selection ### DatastoreFilter | Field | Type | Required | Description | |---|---|---|---| | field | string | Yes | | | condition | DatastoreFilter_condition | Yes | | ### DatastoreFilter_condition | Field | Type | Required | Description | |---|---|---|---| | eq | DatastoreFilter_condition_eq | | | | neq | DatastoreFilter_condition_neq | | | | gt | DatastoreFilter_condition_gt | | | | gte | DatastoreFilter_condition_gte | | | | lt | DatastoreFilter_condition_lt | | | | lte | DatastoreFilter_condition_lte | | | | like | DatastoreFilter_condition_like | | | | ilike | DatastoreFilter_condition_ilike | | | | in | DatastoreFieldValue[] | | | | contains | DatastoreFilter_condition_contains | | | | not_contains | DatastoreFilter_condition_not_contains | | | | is_null | boolean | | | | is_not_null | boolean | | | ### DatastoreFilter_condition_contains | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFilter_condition_contains_selection | | | | is_null | boolean | | | ### DatastoreFilter_condition_contains_selection ### DatastoreFilter_condition_eq | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFilter_condition_eq_selection | | | | is_null | boolean | | | ### DatastoreFilter_condition_eq_selection ### DatastoreFilter_condition_gt | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFilter_condition_gt_selection | | | | is_null | boolean | | | ### DatastoreFilter_condition_gt_selection ### DatastoreFilter_condition_gte | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFilter_condition_gte_selection | | | | is_null | boolean | | | ### DatastoreFilter_condition_gte_selection ### DatastoreFilter_condition_ilike | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFilter_condition_ilike_selection | | | | is_null | boolean | | | ### DatastoreFilter_condition_ilike_selection ### DatastoreFilter_condition_like | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFilter_condition_like_selection | | | | is_null | boolean | | | ### DatastoreFilter_condition_like_selection ### DatastoreFilter_condition_lt | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFilter_condition_lt_selection | | | | is_null | boolean | | | ### DatastoreFilter_condition_lt_selection ### DatastoreFilter_condition_lte | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFilter_condition_lte_selection | | | | is_null | boolean | | | ### DatastoreFilter_condition_lte_selection ### DatastoreFilter_condition_neq | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFilter_condition_neq_selection | | | | is_null | boolean | | | ### DatastoreFilter_condition_neq_selection ### DatastoreFilter_condition_not_contains | Field | Type | Required | Description | |---|---|---|---| | string | string | | | | number | number | | | | boolean | boolean | | | | date | string | | | | selection | DatastoreFilter_condition_not_contains_selection | | | | is_null | boolean | | | ### DatastoreFilter_condition_not_contains_selection ### DatastoreMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The ID of the custom field definition (from the metadata/metadata endpoint). | | slug | string | | The integration's API name (key) of an existing custom field. List valid slugs for this object with the metadata/metadata endpoint; unknown slugs are rejected by the integration. | | value | object | | The field's value, typed by `format`: DATE as ISO-8601 (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM:SSZ` for date-times); NUMBER, PRICE and CURRENCY as a number; BOOLEAN and YES_NO as true/false; MULTIPLE_SELECT as an array of option values; otherwise a string. | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | The data type of the custom field, which determines how `value` is formatted. | | extra_data | object | | | ### DatastoreQuery_query What to run: a raw `sql` statement, or a structured query (select, filter, sort, limit/offset, aggregate) | Field | Type | Required | Description | |---|---|---|---| | sql | string | | Raw SQL – when set, executed directly (table_id optional) | | select | DatastoreQuery_query_select | | | | expand | DatastoreQuery_query_expand | | | | filter | DatastoreQuery_query_filter | | | | sort_fields | DatastoreQuery_query_sort_fields | | | | sort_order | string enum: asc, desc | | | | limit | number | | | | offset | number | | | | aggregate | DatastoreQuery_query_aggregate | | | | is_count_only | boolean | | | ### DatastoreQuery_query_aggregate | Field | Type | Required | Description | |---|---|---|---| | count | boolean | | | | sum | DatastoreQuery_query_aggregate_sum | | | | avg | DatastoreQuery_query_aggregate_avg | | | | min | DatastoreQuery_query_aggregate_min | | | | max | DatastoreQuery_query_aggregate_max | | | | group_by | DatastoreQuery_query_aggregate_group_by | | | ### DatastoreQuery_query_aggregate_avg | Field | Type | Required | Description | |---|---|---|---| | field | string | Yes | | | alias | string | | | ### DatastoreQuery_query_aggregate_group_by ### DatastoreQuery_query_aggregate_max | Field | Type | Required | Description | |---|---|---|---| | field | string | Yes | | | alias | string | | | ### DatastoreQuery_query_aggregate_min | Field | Type | Required | Description | |---|---|---|---| | field | string | Yes | | | alias | string | | | ### DatastoreQuery_query_aggregate_sum | Field | Type | Required | Description | |---|---|---|---| | field | string | Yes | | | alias | string | | | ### DatastoreQuery_query_expand ### DatastoreQuery_query_filter | Field | Type | Required | Description | |---|---|---|---| | type | string enum: FILTER, AND, OR | Yes | | | filters | DatastoreFilter[] | | | | and | DatastoreQuery_query_filter_and | | | | or | DatastoreQuery_query_filter_or | | | ### DatastoreQuery_query_select ### DatastoreQuery_query_sort_fields ### DatastoreQuery_response | Field | Type | Required | Description | |---|---|---|---| | items | DatastoreRecord[] | | | | aggregates | DatastoreQuery_response_aggregates | | | | total_count | number | | | ### DatastoreQuery_response_aggregates | Field | Type | Required | Description | |---|---|---|---| | count | number | | | | sum | DatastoreQuery_response_aggregates_sum | | | | avg | DatastoreQuery_response_aggregates_avg | | | | min | DatastoreQuery_response_aggregates_min | | | | max | DatastoreQuery_response_aggregates_max | | | | groups | DatastoreQuery_response_aggregates_groups | | Each item is one group row: a map of field name to value | ### DatastoreQuery_response_aggregates_avg ### DatastoreQuery_response_aggregates_max ### DatastoreQuery_response_aggregates_min ### DatastoreQuery_response_aggregates_sum ### DatastoreRecord_fields The record's values, keyed by the table's field (column) names; each value is a typed value object (e.g. { "string": "Acme" } or { "number": 3 }) ### DatastoreRelationship | Field | Type | Required | Description | |---|---|---|---| | name | string | Yes | | | field | string | Yes | | | table_id | string | Yes | target Database Table ID | | type | string enum: ONE, MANY | Yes | | --- # Enrich API – Endpoints & Data Models ## Enrich API data models URL: https://docs.unified.to/enrich/overview #### Endpoints | Method | Path | Description | |---|---|---| | GET | /enrich/{connection_id}/person | Retrieve enrichment information for a person | | GET | /enrich/{connection_id}/company | Retrieve enrichment information for a company | #### Data Models ### EnrichCompany A company object from an enrichment integration | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this company object | | created_at | string (date-time) | | The date that this company object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this company object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the company | | description | string | | The description of the company | | domain | string | | The webmail domain of the company | | twitter_handle | string | | The twitter handle of the company | | twitter_url | string | | The twitter URL of the company | | address | EnrichCompany_address | | The address of the company | | telephones | EnrichTelephone[] | | An array of telephones for this company | | linkedin_url | string | | The LinkedIn URL of the company | | crunchbase_url | string | | The Crunchbase URL of the company | | facebook_url | string | | The Facebook URL of the company | | youtube_url | string | | The Youtube URL of the company | | instagram_url | string | | The Crunchbase URL of the company | | yelp_url | string | | The Yelp URL of the company | | logo_url | string | | The URL of the logo of the company | | exchange | string | | The public exchange of the company (eg. NASDAQ) | | stock | string | | The stock ticker of the company (eg. AMZN) | | year_founded | number | | The year that the company was founded | | alexa_rank | number | | The Alexa rank of the company | | naics_code | number | | The NAICS code of the company | | sic_code | number | | The SIC code of the company | | employees | string | | The number of employees at the company | | revenue | string | | The approximate revenue of the company | | industry | string | | The industry of the company | | raw | object | | The raw data returned by the integration for this company | ### EnrichPerson A person object from an enrichment integration | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this person object | | created_at | string (date-time) | | The date that this person object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this person object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the person | | first_name | string | | | | last_name | string | | | | title | string | | The job title of the person | | birthdate | string | | The birth date of the person | | image_url | string | | The image URL of the person | | bio | string | | The biography description of the person | | company | string | | The companyorganization name of the person | | company_domain | string | | The company's domain | | emails | EnrichEmail[] | | An array of email addresses for this person | | telephones | EnrichTelephone[] | | An array of telephones for this person | | twitter_handle | string | | The twitter handle of the person | | linkedin_url | string | | The LinkedIn URL of the person | | github_url | string | | The GitHub URL of the person | | github_username | string | | The GitHub username of the person | | twitter_url | string | | The twitter URL of the person | | facebook_url | string | | The Facebook URL of the person | | gender | string enum: MALE, FEMALE | | The gender of the person | | address | EnrichPerson_address | | The address of the person | | timezone | string | | The timezone code of the person | | utc_offset | number | | The timezone's hourly offset from UTC of the person | | work_histories | EnrichPersonWorkHistory[] | | | | raw | object | | The raw data returned by the integration for this person | ### EnrichCompany_address The address of the company | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | | ### EnrichEmail | Field | Type | Required | Description | |---|---|---|---| | email | string | Yes | | | type | string enum: WORK, HOME, OTHER | | | | is_verified | boolean | | | ### EnrichPersonWorkHistory | Field | Type | Required | Description | |---|---|---|---| | company_id | string | | (reference to EnrichCompany) | | company_name | string | | | | title | string | Yes | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | location | string | | | | company_domain | string | | | ### EnrichPerson_address The address of the person | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | | ### EnrichTelephone | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | | | type | string enum: WORK, HOME, OTHER, FAX, MOBILE | | | --- # Forms API – Endpoints & Data Models ## Forms API data models URL: https://docs.unified.to/forms/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /forms/{connection_id}/form | Create a form | | GET | /forms/{connection_id}/form | List all forms | | GET | /forms/{connection_id}/form/{id} | Retrieve a form | | PUT | /forms/{connection_id}/form/{id} | Update a form | | DELETE | /forms/{connection_id}/form/{id} | Remove a form | | GET | /forms/{connection_id}/submission/{id} | Retrieve a submission | | GET | /forms/{connection_id}/submission | List all submissions | #### Data Models ### FormsForm | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | fields | FormField[] | | | | is_active | boolean | | | | published_url | string | | | | response_count | number | | | | has_multiple_submissions | boolean | | | | has_progress_bar | boolean | | | | has_shuffle_questions | boolean | | | | confirmation_message | string | | | | confirmation_redirect_url | string | | | | metadata | FormsMetadata[] | | | | raw | object | | | ### FormsSubmission | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | form_id | string | Yes | | | respondent_email | string | | | | respondent_name | string | | | | answers | FormAnswer[] | Yes | | | metadata | FormsMetadata[] | | | | raw | object | | | ### FormAnswer | Field | Type | Required | Description | |---|---|---|---| | field_id | string | Yes | | | field_name | string | | | | value | string | | Can be string, number, boolean, array, etc. | | file_ids | FormAnswer_file_ids | | references StorageFile ID | ### FormAnswer_file_ids references StorageFile ID ### FormField | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | Yes | | | description | string | | | | type | string enum: TEXT, TEXTAREA, NUMBER, EMAIL, URL, DATE, TIME, DATETIME, PHONE, BOOLEAN, SINGLE_SELECT, MULTIPLE_SELECT, FILE_UPLOAD, RATING, SCALE, MATRIX, SECTION_HEADER, OTHER | Yes | | | is_required | boolean | | | | is_active | boolean | | | | choices | FormField_choices | | For select/radio/checkbox fields | | default_value | string | | | | min | number | | | | max | number | | | | pattern | string | | | | min_length | number | | | | max_length | number | | | | order | number | | | ### FormField_choices For select/radio/checkbox fields ### FormsMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | --- # Genai API – Endpoints & Data Models ## Genai API data models URL: https://docs.unified.to/genai/overview #### Endpoints | Method | Path | Description | |---|---|---| | GET | /genai/{connection_id}/model/{id} | Retrieve a model | | GET | /genai/{connection_id}/model | List all models | | POST | /genai/{connection_id}/prompt | Create a prompt | | POST | /genai/{connection_id}/embedding | Create an embedding | | POST | /genai/{connection_id}/task | Create a task | | GET | /genai/{connection_id}/task | List all tasks | | GET | /genai/{connection_id}/task/{id} | Retrieve a task | | PUT | /genai/{connection_id}/task/{id} | Update a task | | DELETE | /genai/{connection_id}/task/{id} | Remove a task | | POST | /genai/{connection_id}/agent | Create an agent | | GET | /genai/{connection_id}/agent | List all agents | | GET | /genai/{connection_id}/agent/{id} | Retrieve an agent | | PUT | /genai/{connection_id}/agent/{id} | Update an agent | | DELETE | /genai/{connection_id}/agent/{id} | Remove an agent | | POST | /genai/{connection_id}/organization | Create an organization | | GET | /genai/{connection_id}/organization | List all organizations | | GET | /genai/{connection_id}/organization/{id} | Retrieve an organization | | PUT | /genai/{connection_id}/organization/{id} | Update an organization | | DELETE | /genai/{connection_id}/organization/{id} | Remove an organization | | GET | /genai/{connection_id}/skill/{id} | Retrieve a skill | | DELETE | /genai/{connection_id}/skill/{id} | Remove a skill | | GET | /genai/{connection_id}/skill | List all skills | | POST | /genai/{connection_id}/memory | Create a memory | | GET | /genai/{connection_id}/memory | List all memories | | GET | /genai/{connection_id}/memory/{id} | Retrieve a memory | | PUT | /genai/{connection_id}/memory/{id} | Update a memory | | DELETE | /genai/{connection_id}/memory/{id} | Remove a memory | #### Data Models ### GenaiAgent | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | instructions | string | | The system prompt or stored procedure that the agent follows. | | model_id | string | | The model that the agent runs on. | | is_active | boolean | | | | raw | object | | | ### GenaiEmbedding | Field | Type | Required | Description | |---|---|---|---| | model_id | string | | | | content | GenaiEmbeddingContent[] | | The texts to create embeddings for | | enconding_format | string enum: FLOAT, UINT8, INT8, BINARY, UBINARY, BASE64 | | | | type | string | | 'SEARCH_DOC' \| 'SEARCH_QUERY' \| 'CLUSTERING' \| 'CLASSIFICATION'; | | dimension | number | | 256, 512, 1024, and 1536 | | max_tokens | number | | | | embeddings | string | | JSON string based off of the format (read-only) | | tokens_used | number | | (read-only) | | raw | object | | | | id | string | | | ### GenaiMemory | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | type | string enum: FOLDER, MEMORY | | FOLDER for a memory store or a sub-folder in one, MEMORY for a memory. | | parent_id | string | | id of the parent genai_memory folder. Empty on root-level nodes. | | user_identifier | string | | The customer's own identifier for the end user that the memory belongs to. | | name | string | | The folder name, or the memory's file name where the integration organizes memories by path. | | content | string | | The memory text. MEMORY nodes only. | | description | string | | The memory store's description. Store-level folders only. | | metadata | GenaiMetadata[] | | | | raw | object | | | ### GenaiModel | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | | description | string | | | | max_tokens | number | | The maximum number of tokens that the model can process in a single response. This limits ensures computational efficiency and resource management. | | web_url | string | | | | has_temperature | boolean | | Controls randomness of responses. A lower temperature leads to more predictable outputs while a higher temperature results in more varies and sometimes more creative outputs. 0-1 | | has_questions | boolean | | Whether the model is a decision model (such as TypeSafe Jev or Cloudflare Clef) that answers the typed questions of a prompt with answers instead of generating text responses. | | has_vision | boolean | | Whether the model accepts images as input (see the images on each prompt message). | | raw | object | | | ### GenaiOrganization | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | is_active | boolean | | | | raw | object | | | ### GenaiPrompt | Field | Type | Required | Description | |---|---|---|---| | model_id | string | | The ID of the model to use (a GenaiModel ID) | | messages | GenaiContent[] | | The conversation to send to the model, in order; each message has a role and its content | | temperature | number | | 0-1 | | max_tokens | number | | a float between 0-1 | | responses | GenaiPrompt_responses | | | | tokens_used | number | | | | mcp_url | string | | Supply a remote MCP URL to send to the LLM API for it to call its tools. Note: Some LLM APIs do not yet support remote MCP URLs. | | mcp_deferred_tools | GenaiPrompt_mcp_deferred_tools | | | | mcp_authorization_token | string | | OAuth Bearer token for MCP servers that require authentication. | | questions | GenaiQuestion[] | | Typed questions to evaluate against the messages (for decision models such as TypeSafe Jev). | | answers | GenaiAnswer[] | | One answer per question, matched by id. | | skill_ids | GenaiPrompt_skill_ids | | ids of the genai_skill objects (Agent Skills) to make available to the LLM while it answers. | | raw | object | | | ### GenaiSkill | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | What the skill does and when to use it. | | version | string | | The version that is used when a prompt does not pin one. | | raw | object | | | ### GenaiTask | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | A short human-readable title for the coding agent task. | | instructions | string | | The natural-language instructions given to the coding agent. | | status | string enum: PENDING, RUNNING, BLOCKED, COMPLETED, FAILED, CANCELLED, EXPIRED, OTHER | | | | model_id | string | | | | user_id | string | | (reference to HrisEmployee) | | repo_url | string | | The git repository that the coding agent works in. | | source_branch_identifier | string | | The name of the branch that the coding agent starts its work from. | | target_branch_identifier | string | | The name of the branch that the coding agent pushed its work to. | | pullrequest_url | string | | The pull/merge request that the coding agent opened for its work. | | web_url | string | | The provider's own web page for this task. | | messages | GenaiContent[] | | The conversation between the user and the coding agent. | | summary | string | | The coding agent's summary of the work that it performed. | | error | string | | The failure description (when the status is FAILED). | | files_changed | number | | | | lines_added | number | | | | lines_deleted | number | | | | tokens_used | number | | | | started_at | string (date-time) | | When the coding agent started working on the task. (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | completed_at | string (date-time) | | When the coding agent stopped working on the task. (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | agent_id | string | | id of the genai_agent (the reusable agent/preset configuration) that runs this task. | | organization_id | string | | id of the genai_organization (the organization or execution environment) that this task runs under. | | raw | object | | | ### GenaiAnswer | Field | Type | Required | Description | |---|---|---|---| | id | string | Yes | The id of the genai_prompt question that this answers. | | type | string enum: BOOLEAN, CHOICE, SCORE | Yes | | | value | string | | CHOICE: the selected option. | | score | number | | SCORE: the probability-weighted level index; BOOLEAN: the probability (0-1) that the answer is yes. | | probabilities | GenaiAnswerProbability[] | | CHOICE/SCORE: the probability of every option or level. | | confidence | number | | 0-1: how certain the model is of the answer. | ### GenaiAnswerProbability | Field | Type | Required | Description | |---|---|---|---| | option | string | Yes | CHOICE: the option; SCORE: the level description. | | probability | number | Yes | 0-1 | ### GenaiContent | Field | Type | Required | Description | |---|---|---|---| | role | string enum: SYSTEM, USER, ASSISTANT | | | | content | string | Yes | | | images | GenaiContent_images | | Images for the model to evaluate with this message, as base64 data URLs (data:image/png;base64,...). Only for models with has_vision. | ### GenaiContent_images Images for the model to evaluate with this message, as base64 data URLs (data:image/png;base64,...). Only for models with has_vision. ### GenaiEmbeddingContent | Field | Type | Required | Description | |---|---|---|---| | text | string | Yes | | ### GenaiMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### GenaiPrompt_mcp_deferred_tools ### GenaiPrompt_responses ### GenaiPrompt_skill_ids ids of the genai_skill objects (Agent Skills) to make available to the LLM while it answers. ### GenaiQuestion | Field | Type | Required | Description | |---|---|---|---| | id | string | Yes | A caller-chosen key; the matching answer is returned with the same id. | | type | string enum: BOOLEAN, CHOICE, SCORE | Yes | | | instructions | string | Yes | The question to evaluate. | | options | GenaiQuestion_options | | CHOICE: the options to select from; SCORE: the ordered levels (lowest first). | ### GenaiQuestion_options CHOICE: the options to select from; SCORE: the ordered levels (lowest first). --- # Healthcare API – Endpoints & Data Models ## Healthcare API data models URL: https://docs.unified.to/healthcare/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /healthcare/{connection_id}/patient | Create a patient | | GET | /healthcare/{connection_id}/patient | List all patients | | GET | /healthcare/{connection_id}/patient/{id} | Retrieve a patient | | PUT | /healthcare/{connection_id}/patient/{id} | Update a patient | | GET | /healthcare/{connection_id}/practitioner/{id} | Retrieve a practitioner | | GET | /healthcare/{connection_id}/practitioner | List all practitioners | | GET | /healthcare/{connection_id}/location/{id} | Retrieve a location | | GET | /healthcare/{connection_id}/location | List all locations | | POST | /healthcare/{connection_id}/appointment | Create an appointment | | GET | /healthcare/{connection_id}/appointment | List all appointments | | GET | /healthcare/{connection_id}/appointment/{id} | Retrieve an appointment | | PUT | /healthcare/{connection_id}/appointment/{id} | Update an appointment | | DELETE | /healthcare/{connection_id}/appointment/{id} | Remove an appointment | | GET | /healthcare/{connection_id}/slot/{id} | Retrieve a slot | | GET | /healthcare/{connection_id}/slot | List all slots | | GET | /healthcare/{connection_id}/encounter/{id} | Retrieve an encounter | | GET | /healthcare/{connection_id}/encounter | List all encounters | | GET | /healthcare/{connection_id}/condition/{id} | Retrieve a condition | | GET | /healthcare/{connection_id}/condition | List all conditions | | GET | /healthcare/{connection_id}/allergy/{id} | Retrieve an allergy | | GET | /healthcare/{connection_id}/allergy | List all allergies | | GET | /healthcare/{connection_id}/medication/{id} | Retrieve a medication | | GET | /healthcare/{connection_id}/medication | List all medications | | POST | /healthcare/{connection_id}/observation | Create an observation | | GET | /healthcare/{connection_id}/observation | List all observations | | GET | /healthcare/{connection_id}/observation/{id} | Retrieve an observation | | GET | /healthcare/{connection_id}/immunization/{id} | Retrieve an immunization | | GET | /healthcare/{connection_id}/immunization | List all immunizations | #### Data Models ### HealthcareAllergy | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the allergy/intolerance | | created_at | string (date-time) | | When the allergy record was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the allergy record was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | patient_id | string | | The patient who has the allergy | | encounter_id | string | | The encounter in which the allergy was recorded | | practitioner_id | string | | The practitioner who recorded or asserted the allergy | | name | string | | Human-readable name of the substance | | codes | HealthcareCode[] | | Codings for the substance (e.g. RxNorm, SNOMED CT) | | status | string enum: ACTIVE, INACTIVE, RESOLVED, OTHER | | Clinical status of the allergy | | type | string enum: FOOD, MEDICATION, ENVIRONMENT, BIOLOGIC, OTHER | | Category of the substance | | criticality | string enum: LOW, HIGH, UNABLE_TO_ASSESS, OTHER | | Potential severity of future reactions | | reactions | HealthcareCode[] | | Codings for observed reaction manifestations (e.g. hives, anaphylaxis) | | onset_at | string (date-time) | | When the allergy was first identified (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | recorded_at | string (date-time) | | When the allergy was recorded (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | notes | string | | Free-text notes about the allergy | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcareAppointment | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the appointment | | created_at | string (date-time) | | When the appointment was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the appointment was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | patient_id | string | | The patient attending the appointment | | practitioner_ids | HealthcareAppointment_practitioner_ids | | The practitioners participating in the appointment | | location_id | string | | Where the appointment takes place | | slot_ids | HealthcareAppointment_slot_ids | | The schedule slots this appointment occupies | | status | string enum: PROPOSED, PENDING, BOOKED, ARRIVED, CHECKED_IN, FULFILLED, CANCELLED, NOSHOW, WAITLIST, ENTERED_IN_ERROR, OTHER | | Status of the appointment | | start_at | string (date-time) | | When the appointment starts (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | When the appointment ends (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | description | string | | Short description shown on the appointment | | service_types | HealthcareCode[] | | Codings for the service/visit type being booked | | reasons | HealthcareCode[] | | Codings for the reason for the visit | | cancellation_reason | string | | Why the appointment was cancelled | | notes | string | | Free-text notes or comments | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcareCondition | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the condition | | created_at | string (date-time) | | When the condition record was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the condition record was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | patient_id | string | | The patient who has the condition | | encounter_id | string | | The encounter in which the condition was recorded | | practitioner_id | string | | The practitioner who recorded or asserted the condition | | name | string | | Human-readable name of the condition | | codes | HealthcareCode[] | | Codings for the condition (e.g. ICD-10, SNOMED CT) | | status | string enum: ACTIVE, RECURRENCE, RELAPSE, INACTIVE, REMISSION, RESOLVED, OTHER | | Clinical status of the condition | | type | string enum: PROBLEM, DIAGNOSIS, HEALTH_CONCERN, OTHER | | Category of the condition | | onset_at | string (date-time) | | When the condition began (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | resolved_at | string (date-time) | | When the condition resolved (abatement) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | recorded_at | string (date-time) | | When the condition was recorded (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | notes | string | | Free-text notes about the condition | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcareEncounter | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the encounter | | created_at | string (date-time) | | When the encounter record was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the encounter record was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | patient_id | string | | The patient seen in the encounter | | practitioner_ids | HealthcareEncounter_practitioner_ids | | The practitioners who participated in the encounter | | location_id | string | | Where the encounter took place | | appointment_id | string | | The appointment that scheduled this encounter | | condition_ids | HealthcareEncounter_condition_ids | | Conditions diagnosed or addressed during the encounter | | status | string enum: PLANNED, ARRIVED, TRIAGED, IN_PROGRESS, ONLEAVE, FINISHED, CANCELLED, ENTERED_IN_ERROR, UNKNOWN, OTHER | | Status of the encounter | | type | string enum: AMBULATORY, EMERGENCY, INPATIENT, OBSERVATION, HOME_HEALTH, VIRTUAL, OTHER | | Class of the encounter (e.g. ambulatory, inpatient) | | reasons | HealthcareCode[] | | Codings for the reason for the encounter | | start_at | string (date-time) | | When the encounter started (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | When the encounter ended (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcareImmunization | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the immunization | | created_at | string (date-time) | | When the immunization record was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the immunization record was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | patient_id | string | | The patient who was immunized | | encounter_id | string | | The encounter in which the vaccine was administered | | practitioner_id | string | | The practitioner who administered the vaccine | | location_id | string | | Where the vaccine was administered | | name | string | | Human-readable name of the vaccine | | codes | HealthcareCode[] | | Codings for the vaccine (e.g. CVX) | | status | string enum: COMPLETED, NOT_DONE, ENTERED_IN_ERROR, OTHER | | Status of the immunization | | administered_at | string (date-time) | | When the vaccine was administered (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | lot_number | string | | Vaccine lot number | | expires_at | string (date-time) | | Vaccine expiration date (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | notes | string | | Free-text notes about the immunization | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcareLocation | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the location | | created_at | string (date-time) | | When the location record was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the location record was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Name of the location | | description | string | | Description of the location | | parent_id | string | | The location this one is part of (e.g. a department within a hospital) | | organization_name | string | | Name of the organization that manages the location | | address | HealthcareLocation_address | | Postal address of the location | | telephones | HealthcareTelephone[] | | Phone and fax numbers for the location | | emails | HealthcareEmail[] | | Email addresses for the location | | latitude | number | | Latitude of the location | | longitude | number | | Longitude of the location | | identifiers | HealthcareIdentifier[] | | Business identifiers for the location | | is_active | boolean | | Whether the location is in active use | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcareMedication | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the medication order/prescription | | created_at | string (date-time) | | When the medication record was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the medication record was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | patient_id | string | | The patient the medication is for | | encounter_id | string | | The encounter in which the medication was ordered | | practitioner_id | string | | The practitioner who prescribed the medication | | name | string | | Human-readable name of the medication | | codes | HealthcareCode[] | | Codings for the medication (e.g. RxNorm) | | status | string enum: DRAFT, ACTIVE, ON_HOLD, COMPLETED, STOPPED, CANCELLED, ENTERED_IN_ERROR, UNKNOWN, OTHER | | Status of the medication order | | dosage | string | | Free-text dosage instructions (e.g. "1 tablet by mouth twice daily") | | refill_count | number | | Number of refills authorized | | authored_at | string (date-time) | | When the medication was prescribed (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | start_at | string (date-time) | | When the patient should start taking the medication (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | When the patient should stop taking the medication (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | notes | string | | Free-text notes about the medication | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcareObservation | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the observation | | created_at | string (date-time) | | When the observation record was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the observation record was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | patient_id | string | | The patient the observation is about | | encounter_id | string | | The encounter in which the observation was made | | practitioner_id | string | | The practitioner who performed the observation | | name | string | | Human-readable name of what was observed | | codes | HealthcareCode[] | | Codings for what was observed (e.g. LOINC) | | status | string enum: REGISTERED, PRELIMINARY, FINAL, AMENDED, CORRECTED, CANCELLED, ENTERED_IN_ERROR, UNKNOWN, OTHER | | Status of the observation result | | type | string enum: LABORATORY, VITAL_SIGNS, SOCIAL_HISTORY, IMAGING, SURVEY, EXAM, PROCEDURE, THERAPY, ACTIVITY, OTHER | | Category of the observation | | value_number | number | | Numeric result value | | value_unit | string | | Unit of the numeric result (e.g. mg/dL) | | value_text | string | | Text or coded result value | | interpretation | string enum: NORMAL, ABNORMAL, LOW, HIGH, CRITICAL_LOW, CRITICAL_HIGH, OTHER | | Interpretation of the result (e.g. high, low) | | reference_range | HealthcareObservation_reference_range | | Normal range for the result | | components | HealthcareObservationComponent[] | | Component results (e.g. systolic and diastolic blood pressure) | | observed_at | string (date-time) | | When the observation was made (effective time) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | issued_at | string (date-time) | | When the result was released (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | notes | string | | Free-text notes about the observation | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcarePatient | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the patient | | created_at | string (date-time) | | When the patient record was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the patient record was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Full name of the patient | | first_name | string | | Given name of the patient | | last_name | string | | Family name of the patient | | gender | string enum: MALE, FEMALE, OTHER, UNKNOWN | | Administrative gender of the patient | | birthdate | string | | Date of birth (YYYY-MM-DD) | | is_deceased | boolean | | Whether the patient is deceased | | deceased_at | string (date-time) | | When the patient died (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | emails | HealthcareEmail[] | | Email addresses of the patient | | telephones | HealthcareTelephone[] | | Phone numbers of the patient | | address | HealthcarePatient_address | | Home address of the patient | | language | string | | Preferred language code of the patient | | identifiers | HealthcareIdentifier[] | | Business identifiers (e.g. MRN) | | practitioner_ids | HealthcarePatient_practitioner_ids | | The patient's primary care practitioners | | is_active | boolean | | Whether the patient record is in active use | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcarePractitioner | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the practitioner | | created_at | string (date-time) | | When the practitioner record was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the practitioner record was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Full name of the practitioner | | first_name | string | | Given name of the practitioner | | last_name | string | | Family name of the practitioner | | title | string | | Professional title or prefix (e.g. Dr.) | | gender | string enum: MALE, FEMALE, OTHER, UNKNOWN | | Administrative gender of the practitioner | | emails | HealthcareEmail[] | | Email addresses of the practitioner | | telephones | HealthcareTelephone[] | | Phone numbers of the practitioner | | address | HealthcarePractitioner_address | | Work address of the practitioner | | image_url | string | | Photo of the practitioner | | identifiers | HealthcareIdentifier[] | | Business identifiers (e.g. NPI) | | specialties | HealthcareCode[] | | Codings for the practitioner's specialties | | qualifications | HealthcareCode[] | | Codings for certifications, licenses and degrees | | languages | HealthcarePractitioner_languages | | Language codes the practitioner can communicate in | | location_ids | HealthcarePractitioner_location_ids | | Locations where the practitioner provides care | | is_active | boolean | | Whether the practitioner record is in active use | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcareSlot | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the slot | | created_at | string (date-time) | | When the slot was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the slot was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | practitioner_id | string | | The practitioner whose schedule the slot belongs to | | location_id | string | | Where the slot takes place | | status | string enum: FREE, BUSY, BUSY_UNAVAILABLE, BUSY_TENTATIVE, ENTERED_IN_ERROR, OTHER | | Availability of the slot | | start_at | string (date-time) | | When the slot starts (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | When the slot ends (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | service_types | HealthcareCode[] | | Codings for the services that can be booked in the slot | | is_overbooked | boolean | | Whether the slot has been overbooked | | notes | string | | Free-text notes about the slot | | metadata | HealthcareMetadata[] | | Custom field values | | raw | object | | Raw data from integration | ### HealthcareAppointment_practitioner_ids The practitioners participating in the appointment ### HealthcareAppointment_slot_ids The schedule slots this appointment occupies ### HealthcareCode | Field | Type | Required | Description | |---|---|---|---| | system | string | | The code system URI (e.g. http://loinc.org, http://snomed.info/sct) | | code | string | | The code within the system | | display | string | | Human-readable display text for the code | ### HealthcareEmail | Field | Type | Required | Description | |---|---|---|---| | email | string | Yes | The email address | | type | string enum: WORK, HOME, OTHER | | The kind of email address (work, home or other) | ### HealthcareEncounter_condition_ids Conditions diagnosed or addressed during the encounter ### HealthcareEncounter_practitioner_ids The practitioners who participated in the encounter ### HealthcareIdentifier | Field | Type | Required | Description | |---|---|---|---| | value | string | Yes | The identifier value | | type | string enum: MRN, NPI, SSN, DRIVERS_LICENSE, PASSPORT, INSURANCE, OTHER | | The kind of identifier | | system | string | | The namespace/system URI that issued the identifier | ### HealthcareLocation_address Postal address of the location | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | First line of the street address | | address2 | string | | Second line of the street address | | city | string | | City or locality | | region | string | | Regional area (e.g., state in the U.S., province in Canada) | | postal_code | string | | Postal or ZIP code | | country | string | | Country | ### HealthcareMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | Identifier of the custom field | | value | object | | The custom field value | | namespace | string | | The scope or origin of the custom field | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, ARRAY, OBJECT | | The data type of the value | | extra_data | object | | Additional data about the custom field | | slug | string | | The custom field key | ### HealthcareObservationComponent | Field | Type | Required | Description | |---|---|---|---| | name | string | | Human-readable name of the component | | codes | HealthcareCode[] | | Codings for the component (e.g. LOINC) | | value_number | number | | Numeric component value | | value_unit | string | | Unit of the numeric component value | | value_text | string | | Text or coded component value | | interpretation | string enum: NORMAL, ABNORMAL, LOW, HIGH, CRITICAL_LOW, CRITICAL_HIGH, OTHER | | Interpretation of the component value | ### HealthcareObservation_reference_range Normal range for the result | Field | Type | Required | Description | |---|---|---|---| | low_value | number | | Lower bound of the range | | high_value | number | | Upper bound of the range | | unit | string | | Unit of the range bounds | | text | string | | Text description of the range | ### HealthcarePatient_address Home address of the patient | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | First line of the street address | | address2 | string | | Second line of the street address | | city | string | | City or locality | | region | string | | Regional area (e.g., state in the U.S., province in Canada) | | postal_code | string | | Postal or ZIP code | | country | string | | Country | ### HealthcarePatient_practitioner_ids The patient's primary care practitioners ### HealthcarePractitioner_address Work address of the practitioner | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | First line of the street address | | address2 | string | | Second line of the street address | | city | string | | City or locality | | region | string | | Regional area (e.g., state in the U.S., province in Canada) | | postal_code | string | | Postal or ZIP code | | country | string | | Country | ### HealthcarePractitioner_languages Language codes the practitioner can communicate in ### HealthcarePractitioner_location_ids Locations where the practitioner provides care ### HealthcareTelephone | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | The telephone number | | type | string enum: WORK, HOME, MOBILE, FAX, OTHER | | The kind of telephone number (work, home, mobile, fax or other) | --- # Hris API – Endpoints & Data Models ## Hris API data models URL: https://docs.unified.to/hris/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /hris/{connection_id}/employee | Create an employee | | GET | /hris/{connection_id}/employee | List all employees | | GET | /hris/{connection_id}/employee/{id} | Retrieve an employee | | PUT | /hris/{connection_id}/employee/{id} | Update an employee | | DELETE | /hris/{connection_id}/employee/{id} | Remove an employee | | POST | /hris/{connection_id}/company | Create a company | | GET | /hris/{connection_id}/company | List all companies | | GET | /hris/{connection_id}/company/{id} | Retrieve a company | | PUT | /hris/{connection_id}/company/{id} | Update a company | | DELETE | /hris/{connection_id}/company/{id} | Remove a company | | POST | /hris/{connection_id}/location | Create a location | | GET | /hris/{connection_id}/location | List all locations | | GET | /hris/{connection_id}/location/{id} | Retrieve a location | | PUT | /hris/{connection_id}/location/{id} | Update a location | | DELETE | /hris/{connection_id}/location/{id} | Remove a location | | POST | /hris/{connection_id}/group | Create a group | | GET | /hris/{connection_id}/group | List all groups | | GET | /hris/{connection_id}/group/{id} | Retrieve a group | | PUT | /hris/{connection_id}/group/{id} | Update a group | | DELETE | /hris/{connection_id}/group/{id} | Remove a group | | POST | /hris/{connection_id}/job | Create a job | | GET | /hris/{connection_id}/job | List all jobs | | GET | /hris/{connection_id}/job/{id} | Retrieve a job | | PUT | /hris/{connection_id}/job/{id} | Update a job | | DELETE | /hris/{connection_id}/job/{id} | Remove a job | | POST | /hris/{connection_id}/timeoff | Create a timeoff | | GET | /hris/{connection_id}/timeoff | List all timeoffs | | GET | /hris/{connection_id}/timeoff/{id} | Retrieve a timeoff | | PUT | /hris/{connection_id}/timeoff/{id} | Update a timeoff | | DELETE | /hris/{connection_id}/timeoff/{id} | Remove a timeoff | | GET | /hris/{connection_id}/payslip/{id} | Retrieve a payslip | | GET | /hris/{connection_id}/payslip | List all payslips | | GET | /hris/{connection_id}/payroll/{id} | Retrieve a payroll | | GET | /hris/{connection_id}/payroll | List all payrolls | | GET | /hris/{connection_id}/paycode/{id} | Retrieve a paycode | | GET | /hris/{connection_id}/paycode | List all paycodes | | POST | /hris/{connection_id}/earning | Create an earning | | GET | /hris/{connection_id}/earning | List all earnings | | GET | /hris/{connection_id}/earning/{id} | Retrieve an earning | | PUT | /hris/{connection_id}/earning/{id} | Update an earning | | DELETE | /hris/{connection_id}/earning/{id} | Remove an earning | | POST | /hris/{connection_id}/device | Create a device | | GET | /hris/{connection_id}/device | List all devices | | GET | /hris/{connection_id}/device/{id} | Retrieve a device | | PUT | /hris/{connection_id}/device/{id} | Update a device | | DELETE | /hris/{connection_id}/device/{id} | Remove a device | | POST | /hris/{connection_id}/timeshift | Create a timeshift | | GET | /hris/{connection_id}/timeshift | List all timeshifts | | GET | /hris/{connection_id}/timeshift/{id} | Retrieve a timeshift | | PUT | /hris/{connection_id}/timeshift/{id} | Update a timeshift | | DELETE | /hris/{connection_id}/timeshift/{id} | Remove a timeshift | | POST | /hris/{connection_id}/attendance | Create an attendance | | GET | /hris/{connection_id}/attendance | List all attendances | | GET | /hris/{connection_id}/attendance/{id} | Retrieve an attendance | | PUT | /hris/{connection_id}/attendance/{id} | Update an attendance | | DELETE | /hris/{connection_id}/attendance/{id} | Remove an attendance | | POST | /hris/{connection_id}/benefit | Create a benefit | | GET | /hris/{connection_id}/benefit | List all benefits | | GET | /hris/{connection_id}/benefit/{id} | Retrieve a benefit | | PUT | /hris/{connection_id}/benefit/{id} | Update a benefit | | DELETE | /hris/{connection_id}/benefit/{id} | Remove a benefit | | POST | /hris/{connection_id}/deduction | Create a deduction | | GET | /hris/{connection_id}/deduction | List all deductions | | GET | /hris/{connection_id}/deduction/{id} | Retrieve a deduction | | PUT | /hris/{connection_id}/deduction/{id} | Update a deduction | | DELETE | /hris/{connection_id}/deduction/{id} | Remove a deduction | | POST | /hris/{connection_id}/bankaccount | Create a bankaccount | | GET | /hris/{connection_id}/bankaccount | List all bankaccounts | | GET | /hris/{connection_id}/bankaccount/{id} | Retrieve a bankaccount | | PUT | /hris/{connection_id}/bankaccount/{id} | Update a bankaccount | | DELETE | /hris/{connection_id}/bankaccount/{id} | Remove a bankaccount | | POST | /hris/{connection_id}/taxonomy | Create a taxonomy | | GET | /hris/{connection_id}/taxonomy | List all taxonomies | | GET | /hris/{connection_id}/taxonomy/{id} | Retrieve a taxonomy | | POST | /hris/{connection_id}/document | Create a document | | GET | /hris/{connection_id}/document | List all documents | | GET | /hris/{connection_id}/document/{id} | Retrieve a document | | PUT | /hris/{connection_id}/document/{id} | Update a document | | DELETE | /hris/{connection_id}/document/{id} | Remove a document | #### Data Models ### HrisAttendance Also called timesheets | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | employee_user_id | string | Yes | (reference to HrisEmployee) | | start_at | string (date-time) | Yes | clock-in (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | Yes | clock-out (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | timeshift_id | string | | the scheduled hris_timeshift this attendance record fulfils | | location_id | string | | (reference to HrisLocation) | | company_id | string | | (reference to HrisCompany) | | group_id | string | | department, team, etc. (reference to HrisGroup) | | timezone | string | | | | address | HrisAttendance_address | | | | hours | number | | worked hours | | currency | string | | pairs with hourly_rate and tip amounts | | status | string enum: OPEN, CLOSED, APPROVED | | | | breaks | HrisAttendanceBreak[] | | | | declared_tips_amount | number | | cash tips declared by the employee for this attendance record | | non_cash_tips_amount | number | | card/non-cash tips attributed to this attendance record | | job_name | string | | | | hourly_rate | number | | | | approver_user_id | string | | (reference to HrisEmployee) | | approved_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisBankaccount | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | | employee ID (required for listcreate) (reference to HrisEmployee) | | company_id | string | | (reference to HrisCompany) | | account_type | string enum: CHECKING, SAVINGS | | | | bank_name | string | | | | routing_number | string | | 9-digit US routing number | | account_number | string | | | | account_number_last4 | string | | when full number not returned | | name | string | | account nickname (e.g. "BoA Checking") | | is_primary | boolean | | primary for direct deposit | | iban | string | | International Bank Account Number (non-US accounts) | | bic | string | | BIC / SWIFT code | | country_code | string | | ISO 2-digit country code of the bank account | | currency | string | | | | split_type | string enum: PERCENTAGE, FIXED, REMAINDER | | how net pay is split into this account when an employee has several accounts | | split_amount | number | | percentage (when split_type is PERCENTAGE) or money (when FIXED) | | priority | number | | order in which split accounts are funded (1 = first) | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisBenefit Company-wide benefit plans available to employees. | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | e.g. "401(k) Plan" | | description | string | | e.g. "Company 401(k) retirement plan" | | company_id | string | | (reference to HrisCompany) | | type | string enum: RETIREMENT, HEALTH, DENTAL, VISION, LIFE, HSA, FSA, SHORT_TERM_DISABILITY, LONG_TERM_DISABILITY, WORKERS_COMP, HOUSING_STIPEND, EMPLOYER_TAX_CONTRIBUTION, GARNISHMENT, LOAN_REPAYMENT, CHARITABLE_CONTRIBUTION, OTHER, RETIREMENT_ROTH, COMMUTER, DEPENDENT_CARE, PENSION | | | | tax | string enum: PRE_TAX, POST_TAX, TAXABLE, NON_TAXABLE, TAX | | Tax describes the tax treatment of the benefit | | frequency | string enum: ONE_TIME, DAY, QUARTER, YEAR, HOUR, MONTH, WEEK, BIWEEKLY, SEMIMONTHLY | | Frequency that costs accrue for this benefit. For insurance/benefits, usually "MONTHLY". For compensation, matches pay cycle (WEEKLY, BIWEEKLY, etc). | | is_active | boolean | | | | employer_contribution_type | string enum: PERCENTAGE, FIXED | | | | employer_contribution_amount | number | | percentage or money | | employer_contribution_max_amount | number | | always money | | coverage_level | string enum: EMPLOYEE_ONLY, EMPLOYEE_SPOUSE, EMPLOYEE_CHILD, EMPLOYEE_CHILDREN, EMPLOYEE_FAMILY, FAMILY, OTHER | | | | currency | string | | | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisCompany | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | legal_name | string | | | | address | HrisCompany_address | | | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisDeduction Employee-specific deduction/benefit enrolment. | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | | employee ID (reference to HrisEmployee) | | company_id | string | | (reference to HrisCompany) | | benefit_id | string | | | | amount | number | | percentage or absolute amount (employee's portion) | | type | string enum: FIXED, PERCENTAGE | | | | coverage_level | string enum: EMPLOYEE_ONLY, EMPLOYEE_SPOUSE, EMPLOYEE_CHILD, EMPLOYEE_CHILDREN, EMPLOYEE_FAMILY, FAMILY, OTHER | | Level selected by employee (e.g. "FAMILY", "EMPLOYEE_ONLY", or other) | | frequency | string enum: ONE_TIME, DAY, QUARTER, YEAR, HOUR, MONTH, WEEK, BIWEEKLY, SEMIMONTHLY | | Frequency for this deduction (should always be set, matches IHrisBenefit.frequency) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | is_active | boolean | | | | notes | string | | | | employer_amount | number | | percentage or absolute amount (employer's portion/match) | | employer_type | string enum: PERCENTAGE, FIXED | | | | max_amount | number | | annual maximum employee amount (always money) | | paycode_id | string | | the hris_paycode (deduction code) this deduction uses | | garnishment_type | string enum: CHILD_SUPPORT, TAX_LEVY, STUDENT_LOAN, CREDITOR, BANKRUPTCY, OTHER | | set when this deduction is a court-ordered garnishment | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisDevice | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | asset_tag | string | | | | version | string | | device version | | manufacturer | string | | device manufacturer | | model | string | | device model | | os | string | | operating system name | | os_version | string | | | | user_ids | HrisDevice_user_ids | | users who have this device (reference to HrisEmployee) | | admin_user_ids | HrisDevice_admin_user_ids | | (reference to HrisEmployee) | | location_id | string | | pointer to HR Location object (reference to HrisLocation) | | has_antivirus | boolean | | | | has_password_manager | boolean | | | | has_firewall | boolean | | | | has_hd_encrypted | boolean | | | | has_screenlock | boolean | | | | is_missing | boolean | | | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisDocument | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | document_url | string | | download link, expires after 1 hour | | document_data | string | | base64 encoded file contents for create/update | | filename | string | | | | type | string enum: CONTRACT, OFFER_LETTER, POLICY, TAX, ID, VISA, PAYSLIP, BENEFITS, CERTIFICATION, PERFORMANCE_REVIEW, ONBOARDING, TERMINATION, MEDICAL, OTHER | | unified, normalized document type (OTHER when unmappable) | | user_id | string | | pointer to the HR Employee object that owns this document (reference to HrisEmployee) | | company_id | string | | (reference to HrisCompany) | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisEarning | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | | employee ID (reference to HrisEmployee) | | company_id | string | | (reference to HrisCompany) | | paycode_id | string | | the hris_paycode (earning code) this earning is paid under | | amount | number | | money amount added to each pay run it applies to | | currency | string | | | | hours | number | | | | rate_amount | number | | rate per hour/unit | | frequency | string enum: ONE_TIME, DAY, QUARTER, YEAR, HOUR, MONTH, WEEK, BIWEEKLY, SEMIMONTHLY | | ONE_TIME for a single addition to the next unprocessed pay run, otherwise recurring | | start_at | string (date-time) | | recurring: first effective date; ONE_TIME: requested pay date (empty = next unprocessed pay run) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | recurring: last effective date (empty = until stopped) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | status | string enum: PENDING, ACTIVE, PROCESSED, ENDED, CANCELLED, FAILED | | | | payroll_id | string | | the hris_payroll (pay run) a ONE_TIME earning targets or was paid in | | payslip_id | string | | the hris_payslip a ONE_TIME earning was paid on | | notes | string | | | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisEmployee | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The employee's full name | | first_name | string | | The employee's first (given) name | | last_name | string | | The employee's last (family) name | | emails | HrisEmail[] | | The employee's email addresses | | title | string | | | | job_id | string | | the hris_job (position) this employee holds; their primary job when they hold more than one | | manager_id | string | | | | employment_status | string enum: ACTIVE, INACTIVE | | | | gender | string enum: MALE, FEMALE, INTERSEX, TRANS, NON_BINARY | | | | telephones | HrisTelephone[] | | | | date_of_birth | string (date-time) | | YYYYMM/DD | | employee_number | string | | the company's ID for this employee | | hired_at | string (date-time) | | The employee's start date (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | terminated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | termination_reason | string | | | | marital_status | string enum: MARRIED, SINGLE | | | | employment_type | string enum: FULL_TIME, PART_TIME, CONTRACTOR, INTERN, CONSULTANT, VOLUNTEER, CASUAL, SEASONAL, FREELANCE, OTHER | | | | address | HrisEmployee_address | | | | language_locale | string | | | | currency | string | | | | timezone | string | | | | image_url | string | | This link expires after 1 hour. | | company_id | string | | (reference to HrisCompany) | | pronouns | string | | | | employee_roles | HrisEmployee_employee_roles | | | | compensation | HrisCompensation[] | | | | salutation | string | | Mr. Mrs. Ms. | | bio | string | | | | ssn_sin | string | | | | groups | HrisGroup[] | | Which groups/teams/units that this employee/user belongs to. May not have all of the Group fields present, but should have id, name, or email. | | locations | HrisLocation[] | | | | metadata | HrisMetadata[] | | | | storage_quota_allocated | number | | | | storage_quota_used | number | | | | storage_quota_available | number | | | | relationships | HrisEmployeerelationship[] | | the employee's personal relationships (eg. emergency contacts, spouse, dependants, ...) | | has_mfa | boolean | | does the user/employee have multi-factor authentication enabled | | timeoff_days_total | number | | Total time off allowance for the current leave period, in days (when reported by the provider) | | timeoff_days_used | number | | Time off days used or booked in the current leave period (when reported by the provider) | | flsa_status | string enum: EXEMPT, NON_EXEMPT, SALARIED_NONEXEMPT, OWNER | | US Fair Labor Standards Act classification | | pay_frequency | string enum: ONE_TIME, DAY, QUARTER, YEAR, HOUR, MONTH, WEEK, BIWEEKLY, SEMIMONTHLY | | how often the employee is paid (eg. BIWEEKLY) | | raw | object | | | ### HrisGroup | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the group (e.g. a team or department) | | description | string | | | | parent_id | string | | | | type | string enum: TEAM, GROUP, DEPARTMENT, DIVISION, BUSINESS_UNIT, BRANCH, SUB_DEPARTMENT, PAY_GROUP | | | | user_ids | HrisGroup_user_ids | | (reference to HrisEmployee) | | manager_ids | HrisGroup_manager_ids | | | | is_active | boolean | | | | company_id | string | | (reference to HrisCompany) | | pay_frequency | string enum: ONE_TIME, DAY, QUARTER, YEAR, HOUR, MONTH, WEEK, BIWEEKLY, SEMIMONTHLY | | pay cycle when type is PAY_GROUP | | next_paid_at | string (date-time) | | next pay date when type is PAY_GROUP (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisJob | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | the job or position title | | description | string | | | | code | string | | the integration's job code or position number, when it differs from id | | is_active | boolean | | | | employment_type | string enum: FULL_TIME, PART_TIME, CONTRACTOR, INTERN, CONSULTANT, VOLUNTEER, CASUAL, SEASONAL, FREELANCE, OTHER | | | | company_id | string | | (reference to HrisCompany) | | groups | HrisGroup[] | | the groups (departments, teams, organizations) this job belongs to. May not have all of the Group fields present, but should have id and name | | locations | HrisLocation[] | | where this job is worked. May not have all of the Location fields present, but should have id and name | | parent_id | string | | the hris_job this job reports to (position hierarchy) | | users | HrisEmployee[] | | the employees currently holding this job. May not have all of the Employee fields present, but should have id and name | | compensation | HrisCompensation[] | | pay attached to the job itself (eg. an hourly rate) | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisLocation | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the location | | description | string | | | | address | HrisLocation_address | | | | parent_id | string | | | | external_identifier | string | | | | telephones | HrisTelephone[] | | | | timezone | string | | | | currency | string | | | | language_locale | string | | | | is_active | boolean | | | | is_hq | boolean | | if this location is the headquarters of the company | | company_id | string | | (reference to HrisCompany) | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisPaycode | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | company_id | string | | (reference to HrisCompany) | | name | string | | | | code | string | | the payroll system's code for this item | | description | string | | | | type | string enum: EARNING, DEDUCTION, TAX, CONTRIBUTION, REIMBURSEMENT | | | | detail_type | string enum: EARNING_SALARY, EARNING_OVERTIME, EARNING_TIP, EARNING_BONUS, EARNING_COMMISSION, EARNING_ADJUSTMENT, EARNING, PRETAX_DEDUCTION, PRETAX_DEDUCTION_HEALTH_INSURANCE, PRETAX_DEDUCTION_RETIREMENT, PRETAX_DEDUCTION_HRA, TAX_FEDERAL, TAX_REGION, TAX_LOCAL, POSTTAX_BENEFIT, POSTTAX_GARNISHMENT, REIMBURSEMENT, EARNING_REGULAR, EARNING_DOUBLE_OVERTIME, EARNING_PTO, EARNING_SICK, EARNING_HOLIDAY, EARNING_SEVERANCE, EARNING_REIMBURSEMENT, TAX_SOCIAL_SECURITY, TAX_MEDICARE, TAX_UNEMPLOYMENT, TAX_EMPLOYER, PRETAX_DEDUCTION_HSA, PRETAX_DEDUCTION_FSA, PRETAX_DEDUCTION_DEPENDENT_CARE, PRETAX_DEDUCTION_COMMUTER, PRETAX_DEDUCTION_DENTAL, PRETAX_DEDUCTION_VISION, POSTTAX_DEDUCTION, POSTTAX_RETIREMENT_ROTH, POSTTAX_LOAN_REPAYMENT, EMPLOYER_CONTRIBUTION, PENSION, EARNING_CASH_IN_LIEU | | the normalized payslip-line type this code produces | | is_pre_tax | boolean | | | | is_employer | boolean | | | | is_active | boolean | | | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisPayroll | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | company_id | string | | (reference to HrisCompany) | | group_id | string | | the pay group (hris_group of type PAY_GROUP) (reference to HrisGroup) | | status | string enum: DRAFT, PENDING, APPROVED, PROCESSING, PAID, FAILED, CANCELLED | | | | type | string enum: REGULAR, OFF_CYCLE, BONUS, CORRECTION, TERMINATION, CONTRACTOR | | | | start_at | string (date-time) | | pay period start (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | pay period end (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | paid_at | string (date-time) | | pay/check date (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | approved_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | currency | string | | | | gross_amount | number | | | | net_amount | number | | | | employee_tax_amount | number | | | | employer_tax_amount | number | | | | deduction_amount | number | | total employee deductions | | employer_contribution_amount | number | | total employer benefit contributions | | total_cost_amount | number | | total company cost/debit for this payroll | | user_ids | HrisPayroll_user_ids | | employees paid in this payroll (reference to HrisEmployee) | | payslip_ids | HrisPayroll_payslip_ids | | | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisPayslip | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | | the employee (reference to HrisEmployee) | | company_id | string | | (reference to HrisCompany) | | payment_reference | string | | | | payment_type | string enum: DIRECT, CHEQUE, CASH | | | | paid_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | currency | string | | | | gross_amount | number | | | | net_amount | number | | | | details | HrisPayslipDetail[] | | | | deduction | HrisPayslip_deduction | | The ID (and optionally name) of the employee deduction (if this detail represents a deduction) | | payroll_id | string | | the hris_payroll (pay run) this payslip belongs to | | group_id | string | | the pay group (hris_group of type PAY_GROUP) (reference to HrisGroup) | | type | string enum: REGULAR, OFF_CYCLE, BONUS, CORRECTION, TERMINATION, CONTRACTOR | | | | hours | number | | total hours paid on this payslip | | employee_tax_amount | number | | total taxes withheld from the employee | | employer_tax_amount | number | | total employer-paid taxes | | deduction_amount | number | | total employee deductions (pre and post-tax) | | employer_contribution_amount | number | | total employer benefit contributions | | gross_ytd_amount | number | | year-to-date gross pay as of this payslip | | net_ytd_amount | number | | year-to-date net pay as of this payslip | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisTaxonomy | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | type | string enum: SKILL, KNOWLEDGE, COMPETENCE, ABILITY, CERTIFICATION, ROLE, HOLIDAY, LEAVE | | the kind of taxonomy item: skill, knowledge, competence, ability, certification or role/occupation | | name | string | | the preferred label of the skill/role | | description | string | | | | domain | string | | 1st-level grouping (broadest) | | category | string | | 2nd-level grouping | | subcategory | string | | 3rd-level grouping | | parent_id | string | | id of the parent taxonomy item, for providers exposing arbitrary-depth trees | | alternative_names | HrisTaxonomy_alternative_names | | synonyms / alternative labels | | url | string | | external reference URL for this item | | role_ids | HrisTaxonomy_role_ids | | ids of hris_taxonomy items of type ROLE that this skill relates to | | is_active | boolean | | | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisTimeoff | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | Yes | employee ID (reference to HrisEmployee) | | company_id | string | | (reference to HrisCompany) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | is_paid | boolean | | | | status | string enum: APPROVED, PENDING, DENIED, CANCELLED | | | | approver_user_id | string | | (reference to HrisEmployee) | | approved_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | comments | string | | | | reason | string | | | | original_type | string | | integration leave-type label before normalization | | type | string enum: VACATION, SICK, HOLIDAY, BEREAVEMENT, PARENTAL, UNPAID, IN_LIEU, OTHER | | | | duration | number | | | | duration_type | string enum: HOUR, DAY | | | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisTimeshift | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | employee_user_id | string | Yes | (reference to HrisEmployee) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | location_id | string | | (reference to HrisLocation) | | company_id | string | | (reference to HrisCompany) | | group_id | string | | department, team, etc. (reference to HrisGroup) | | compensation | HrisCompensation[] | | | | approver_user_id | string | | (reference to HrisEmployee) | | approved_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | hours | number | | | | is_approved | boolean | | | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisAttendanceBreak | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | is_paid | boolean | | | | duration_minutes | number | | | | name | string | | break type label, e.g. "Lunch" | ### HrisAttendance_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### HrisCompany_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### HrisCompensation | Field | Type | Required | Description | |---|---|---|---| | type | string enum: SALARY, BONUS, STOCK_OPTIONS, EQUITY, OTHER, HOURLY, COMMISSION | | | | amount | number | | | | currency | string | | | | frequency | string enum: ONE_TIME, DAY, QUARTER, YEAR, HOUR, MONTH, WEEK, BIWEEKLY, SEMIMONTHLY | | | | group_id | string | | (reference to HrisGroup) | | notes | string | | | ### HrisDevice_admin_user_ids ### HrisDevice_user_ids users who have this device ### HrisEmail | Field | Type | Required | Description | |---|---|---|---| | email | string | Yes | | | type | string enum: WORK, HOME, OTHER | | | ### HrisEmployee_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### HrisEmployee_employee_roles ### HrisEmployeerelationship | Field | Type | Required | Description | |---|---|---|---| | name | string | | | | first_name | string | | | | last_name | string | | | | emails | HrisEmail[] | | | | telephones | HrisTelephone[] | | | | address | HrisEmployeerelationship_address | | | | type | string enum: EMERGENCY, SPOUSE, CHILD, PARENT, SIBLING, FRIEND, OTHER | | | | is_dependent | boolean | | | | is_beneficiary | boolean | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### HrisEmployeerelationship_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### HrisGroup_manager_ids ### HrisGroup_user_ids ### HrisLocation_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### HrisMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The ID of the custom field definition (from the metadata/metadata endpoint). | | value | object | | The field's value, typed by `format`: DATE as ISO-8601 (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM:SSZ` for date-times); NUMBER, PRICE and CURRENCY as a number; BOOLEAN and YES_NO as true/false; MULTIPLE_SELECT as an array of option values; otherwise a string. | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | The data type of the custom field, which determines how `value` is formatted. | | extra_data | object | | | | slug | string | | The integration's API name (key) of an existing custom field. List valid slugs for this object with the metadata/metadata endpoint; unknown slugs are rejected by the integration. | ### HrisPayroll_payslip_ids ### HrisPayroll_user_ids employees paid in this payroll ### HrisPayslipDetail | Field | Type | Required | Description | |---|---|---|---| | type | string enum: EARNING_SALARY, EARNING_OVERTIME, EARNING_TIP, EARNING_BONUS, EARNING_COMMISSION, EARNING_ADJUSTMENT, EARNING, PRETAX_DEDUCTION, PRETAX_DEDUCTION_HEALTH_INSURANCE, PRETAX_DEDUCTION_RETIREMENT, PRETAX_DEDUCTION_HRA, TAX_FEDERAL, TAX_REGION, TAX_LOCAL, POSTTAX_BENEFIT, POSTTAX_GARNISHMENT, REIMBURSEMENT, EARNING_REGULAR, EARNING_DOUBLE_OVERTIME, EARNING_PTO, EARNING_SICK, EARNING_HOLIDAY, EARNING_SEVERANCE, EARNING_REIMBURSEMENT, TAX_SOCIAL_SECURITY, TAX_MEDICARE, TAX_UNEMPLOYMENT, TAX_EMPLOYER, PRETAX_DEDUCTION_HSA, PRETAX_DEDUCTION_FSA, PRETAX_DEDUCTION_DEPENDENT_CARE, PRETAX_DEDUCTION_COMMUTER, PRETAX_DEDUCTION_DENTAL, PRETAX_DEDUCTION_VISION, POSTTAX_DEDUCTION, POSTTAX_RETIREMENT_ROTH, POSTTAX_LOAN_REPAYMENT, EMPLOYER_CONTRIBUTION, PENSION, EARNING_CASH_IN_LIEU | | | | name | string | | | | description | string | | | | amount | number | Yes | | | employee_amount | number | | | | company_amount | number | | | | hours | number | | | | rate_amount | number | | pay rate for hourly/unit-based lines | | ytd_amount | number | | year-to-date amount for this line | | is_pre_tax | boolean | | | | is_employer | boolean | | true when this line is paid by the employer (employer tax or contribution) | | paycode_id | string | | the hris_paycode for this line | | benefit_id | string | | | | deduction_id | string | | | | original_type | string | | integration pay-code label before normalization | ### HrisPayslip_deduction The ID (and optionally name) of the employee deduction (if this detail represents a deduction) | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | | employee ID (reference to HrisEmployee) | | company_id | string | | (reference to HrisCompany) | | benefit_id | string | | | | amount | number | | percentage or absolute amount (employee's portion) | | type | string enum: FIXED, PERCENTAGE | | | | coverage_level | string enum: EMPLOYEE_ONLY, EMPLOYEE_SPOUSE, EMPLOYEE_CHILD, EMPLOYEE_CHILDREN, EMPLOYEE_FAMILY, FAMILY, OTHER | | Level selected by employee (e.g. "FAMILY", "EMPLOYEE_ONLY", or other) | | frequency | string enum: ONE_TIME, DAY, QUARTER, YEAR, HOUR, MONTH, WEEK, BIWEEKLY, SEMIMONTHLY | | Frequency for this deduction (should always be set, matches IHrisBenefit.frequency) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | is_active | boolean | | | | notes | string | | | | employer_amount | number | | percentage or absolute amount (employer's portion/match) | | employer_type | string enum: PERCENTAGE, FIXED | | | | max_amount | number | | annual maximum employee amount (always money) | | paycode_id | string | | the hris_paycode (deduction code) this deduction uses | | garnishment_type | string enum: CHILD_SUPPORT, TAX_LEVY, STUDENT_LOAN, CREDITOR, BANKRUPTCY, OTHER | | set when this deduction is a court-ordered garnishment | | metadata | HrisMetadata[] | | | | raw | object | | | ### HrisTaxonomy_alternative_names synonyms / alternative labels ### HrisTaxonomy_role_ids ids of hris_taxonomy items of type ROLE that this skill relates to ### HrisTelephone | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | | | type | string enum: WORK, HOME, OTHER, FAX, MOBILE | | | --- # Kms API – Endpoints & Data Models ## Kms API data models URL: https://docs.unified.to/kms/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /kms/{connection_id}/space | Create a space | | GET | /kms/{connection_id}/space | List all spaces | | GET | /kms/{connection_id}/space/{id} | Retrieve a space | | PUT | /kms/{connection_id}/space/{id} | Update a space | | DELETE | /kms/{connection_id}/space/{id} | Remove a space | | POST | /kms/{connection_id}/page | Create a page | | GET | /kms/{connection_id}/page | List all pages | | GET | /kms/{connection_id}/page/{id} | Retrieve a page | | PUT | /kms/{connection_id}/page/{id} | Update a page | | DELETE | /kms/{connection_id}/page/{id} | Remove a page | | POST | /kms/{connection_id}/comment | Create a comment | | GET | /kms/{connection_id}/comment | List all comments | | GET | /kms/{connection_id}/comment/{id} | Retrieve a comment | | PUT | /kms/{connection_id}/comment/{id} | Update a comment | | DELETE | /kms/{connection_id}/comment/{id} | Remove a comment | #### Data Models ### KmsComment | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | type | string enum: PAGE_INLINE, PAGE | | | | content_type | string enum: HTML, MARKDOWN, TEXT, OTHER | | | | content | string | | | | user_id | string | | (reference to HrisEmployee) | | page_id | string | | (reference to KmsPage) | | parent_id | string | | | | metadata | KmsPageMetadata[] | | | | raw | object | | | ### KmsPage | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | title | string | | The page's title | | type | string enum: HTML, MARKDOWN, TEXT, OTHER | Yes | | | space_id | string | | (reference to KmsSpace) | | parent_id | string | | | | is_active | boolean | | | | user_id | string | | (reference to HrisEmployee) | | download_url | string | | | | metadata | KmsPageMetadata[] | | | | has_children | boolean | | | | web_url | string | | | | raw | object | | | ### KmsSpace | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | parent_id | string | | | | is_active | boolean | | | | user_id | string | | (reference to HrisEmployee) | | parent_page_id | string | | (reference to KmsPage) | | metadata | KmsPageMetadata[] | | | | raw | object | | | ### KmsPageMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The ID of the custom field definition (from the metadata/metadata endpoint). | | value | object | | The field's value, typed by `format`: DATE as ISO-8601 (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM:SSZ` for date-times); NUMBER, PRICE and CURRENCY as a number; BOOLEAN and YES_NO as true/false; MULTIPLE_SELECT as an array of option values; otherwise a string. | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL, PERCENT, EMAIL, PHONE, REFERENCE, TIME | | The data type of the custom field, which determines how `value` is formatted. | | extra_data | object | | | | slug | string | | The integration's API name (key) of an existing custom field. List valid slugs for this object with the metadata/metadata endpoint; unknown slugs are rejected by the integration. | --- # Legal API – Endpoints & Data Models ## Legal API data models URL: https://docs.unified.to/legal/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /legal/{connection_id}/matter | Create a matter | | GET | /legal/{connection_id}/matter | List all matters | | GET | /legal/{connection_id}/matter/{id} | Retrieve a matter | | PUT | /legal/{connection_id}/matter/{id} | Update a matter | | DELETE | /legal/{connection_id}/matter/{id} | Remove a matter | | POST | /legal/{connection_id}/practicearea | Create a practicearea | | GET | /legal/{connection_id}/practicearea | List all practiceareas | | GET | /legal/{connection_id}/practicearea/{id} | Retrieve a practicearea | | PUT | /legal/{connection_id}/practicearea/{id} | Update a practicearea | | DELETE | /legal/{connection_id}/practicearea/{id} | Remove a practicearea | | POST | /legal/{connection_id}/timeentry | Create a timeentry | | GET | /legal/{connection_id}/timeentry | List all timeentries | | GET | /legal/{connection_id}/timeentry/{id} | Retrieve a timeentry | | PUT | /legal/{connection_id}/timeentry/{id} | Update a timeentry | | DELETE | /legal/{connection_id}/timeentry/{id} | Remove a timeentry | | POST | /legal/{connection_id}/expense | Create an expense | | GET | /legal/{connection_id}/expense | List all expenses | | GET | /legal/{connection_id}/expense/{id} | Retrieve an expense | | PUT | /legal/{connection_id}/expense/{id} | Update an expense | | DELETE | /legal/{connection_id}/expense/{id} | Remove an expense | #### Data Models ### LegalExpense | Field | Type | Required | Description | |---|---|---|---| | id | string | | unique identifier for the expense | | created_at | string (date-time) | | when the expense was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | when the expense was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | matter_id | string | | pointer to the Legal Matter this expense is recorded against | | user_id | string | | HR Employee who incurred or recorded the expense (reference to HrisEmployee) | | description | string | | free-text description of the expense | | incurred_at | string (date-time) | | date the expense was incurred (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | quantity | number | | number of units | | price_amount | number | | unit price | | total_amount | number | | total amount of the expense | | currency | string | | ISO 4217 currency code | | type | string enum: HARD_COST, SOFT_COST, OTHER | | hard cost, soft cost or other | | category | string | | the vendor's expense category name | | is_billable | boolean | | whether the expense is billable | | is_billed | boolean | | whether the expense has been billed | | invoice_id | string | | pointer to the Accounting Invoice this expense was billed on (reference to AccountingInvoice) | | expense_code | string | | UTBMS/LEDES expense code | | metadata | LegalMetadata[] | | custom field values | | raw | object | | raw data from the integration | ### LegalMatter | Field | Type | Required | Description | |---|---|---|---| | id | string | | unique identifier for the matter | | created_at | string (date-time) | | when the matter was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | when the matter was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | the matter name | | number | string | | the firm's display/matter number | | description | string | | free-text description of the matter | | status | string enum: PENDING, OPEN, CLOSED, OTHER | | the matter status | | billing_method | string enum: HOURLY, FLAT_FEE, CONTINGENCY, OTHER | | how the matter is billed | | practicearea_id | string | | pointer to the Legal Practicearea object | | stage | string | | the matter stage name | | contact_ids | LegalMatter_contact_ids | | CRM Contacts that are clients on this matter | | company_ids | LegalMatter_company_ids | | CRM Companies that are clients on this matter | | parties | LegalParty[] | | all people and organizations related to this matter, with their role | | user_id | string | | HR Employee who is the responsible attorney (reference to HrisEmployee) | | originating_user_id | string | | HR Employee who is the originating attorney (reference to HrisEmployee) | | user_ids | LegalMatter_user_ids | | other HR Employees assigned to this matter (reference to HrisEmployee) | | client_reference | string | | the client's own reference number for this matter | | currency | string | | ISO 4217 currency code | | is_billable | boolean | | whether the matter is billable | | opened_at | string (date-time) | | when the matter was opened (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | closed_at | string (date-time) | | when the matter was closed (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | pending_at | string (date-time) | | when the matter became pending (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | statute_of_limitations_at | string (date-time) | | statute of limitations deadline for the matter (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | LegalMetadata[] | | custom field values | | raw | object | | raw data from the integration | ### LegalPracticearea | Field | Type | Required | Description | |---|---|---|---| | id | string | | unique identifier for the practice area | | created_at | string (date-time) | | when the practice area was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | when the practice area was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | the practice area name | | type | string enum: ADMINISTRATIVE, ADMIRALTY_AND_MARITIME, ANTI_TRUST_AND_COMPETITION, APPELLATE, BANKING_AND_FINANCE, BANKRUPTCY, BUSINESS_FORMATION_AND_COMPLIANCE, CIVIL_LITIGATION, CIVIL_RIGHTS_AND_CONSTITUTIONAL, COLLECTIONS_AND_DEBT, COMMERCIAL_AND_SALE_OF_GOODS, COMMERCIAL_LITIGATION, CONSTRUCTION, CONSUMER_PROTECTION, CONTRACTS, CORPORATE_LITIGATION, CRIMINAL, DISABILITY_AND_SOCIAL_SECURITY, EDUCATION, ELDER, EMPLOYMENT_AND_LABOR, ENERGY_AND_ENVIRONMENTAL, ETHICS, FAMILY, FOOD_AND_DRUG, GENERAL_PRACTICE, GOVERNMENT, HEALTHCARE, IMMIGRATION, IN_HOUSE_COUNSEL, INSURANCE, INTELLECTUAL_PROPERTY, INTERNATIONAL, JUVENILE, LEGAL_AID, MEDIATION_AND_ARBITRATION, MEDICAL_MALPRACTICE, MILITARY, MULTI_PRACTICE, NON_PROFIT_AND_PRO_BONO, OTHER, PERSONAL_INJURY, PRIVACY_AND_INFORMATION_SECURITY, PRIVATE_CLIENT, PRODUCT_LIABILITY, REAL_ESTATE, SCIENCE_AND_TECHNOLOGY, SECURITIES_AND_MERGERS_AND_ACQUISITIONS, SMALL_CLAIMS, SPORTS_AND_ENTERTAINMENT_AND_GAMING, TAX, TELECOMMUNICATIONS, TRAFFIC_OFFENSES, TRANSPORTATION, TRIBAL, TRUSTS, WILLS_AND_ESTATES, WORKERS_COMPENSATION | | the normalized practice area type | | code | string | | the practice area code | | is_active | boolean | | whether the practice area is active | | metadata | LegalMetadata[] | | custom field values | | raw | object | | raw data from the integration | ### LegalTimeentry | Field | Type | Required | Description | |---|---|---|---| | id | string | | unique identifier for the time entry | | created_at | string (date-time) | | when the time entry was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | when the time entry was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | matter_id | string | | pointer to the Legal Matter this time is recorded against | | user_id | string | | HR Employee who is the timekeeper (reference to HrisEmployee) | | description | string | | description of the work performed | | worked_at | string (date-time) | | date the work was performed (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | minutes | number | | duration worked, in minutes | | billable_minutes | number | | billable duration after rounding, in minutes | | rate_amount | number | | hourly rate, or the flat amount for flat-rate entries | | total_amount | number | | total amount of the time entry | | currency | string | | ISO 4217 currency code | | is_billable | boolean | | whether the time entry is billable | | is_billed | boolean | | whether the time entry has been billed | | invoice_id | string | | pointer to the Accounting Invoice this time was billed on (reference to AccountingInvoice) | | activity_code | string | | UTBMS/LEDES activity code | | task_code | string | | UTBMS/LEDES task code | | metadata | LegalMetadata[] | | custom field values | | raw | object | | raw data from the integration | ### LegalMatter_company_ids CRM Companies that are clients on this matter ### LegalMatter_contact_ids CRM Contacts that are clients on this matter ### LegalMatter_user_ids other HR Employees assigned to this matter ### LegalMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | the custom field identifier | | slug | string | | the custom field key | | value | object | | the custom field value | | namespace | string | | the scope or origin of the custom field | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | the data type of the value | | extra_data | object | | additional data about the custom field | ### LegalParty | Field | Type | Required | Description | |---|---|---|---| | contact_id | string | | pointer to a CRM Contact | | company_id | string | | pointer to a CRM Company | | role | string enum: CLIENT, OPPOSING_PARTY, OPPOSING_COUNSEL, CO_COUNSEL, WITNESS, EXPERT, JUDGE, REFERRER, OTHER | | the party role on the matter | | description | string | | the vendor's free-text relationship description | --- # Lms API – Endpoints & Data Models ## Lms API data models URL: https://docs.unified.to/lms/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /lms/{connection_id}/course | Create a course | | GET | /lms/{connection_id}/course | List all courses | | GET | /lms/{connection_id}/course/{id} | Retrieve a course | | PUT | /lms/{connection_id}/course/{id} | Update a course | | DELETE | /lms/{connection_id}/course/{id} | Remove a course | | POST | /lms/{connection_id}/class | Create a class | | GET | /lms/{connection_id}/class | List all classes | | GET | /lms/{connection_id}/class/{id} | Retrieve a class | | PUT | /lms/{connection_id}/class/{id} | Update a class | | DELETE | /lms/{connection_id}/class/{id} | Remove a class | | POST | /lms/{connection_id}/student | Create a student | | GET | /lms/{connection_id}/student | List all students | | GET | /lms/{connection_id}/student/{id} | Retrieve a student | | PUT | /lms/{connection_id}/student/{id} | Update a student | | DELETE | /lms/{connection_id}/student/{id} | Remove a student | | POST | /lms/{connection_id}/instructor | Create an instructor | | GET | /lms/{connection_id}/instructor | List all instructors | | GET | /lms/{connection_id}/instructor/{id} | Retrieve an instructor | | PUT | /lms/{connection_id}/instructor/{id} | Update an instructor | | DELETE | /lms/{connection_id}/instructor/{id} | Remove an instructor | | POST | /lms/{connection_id}/content | Create a content | | GET | /lms/{connection_id}/content | List all contents | | GET | /lms/{connection_id}/content/{id} | Retrieve a content | | PUT | /lms/{connection_id}/content/{id} | Update a content | | DELETE | /lms/{connection_id}/content/{id} | Remove a content | | POST | /lms/{connection_id}/collection | Create a collection | | GET | /lms/{connection_id}/collection | List all collections | | GET | /lms/{connection_id}/collection/{id} | Retrieve a collection | | PUT | /lms/{connection_id}/collection/{id} | Update a collection | | DELETE | /lms/{connection_id}/collection/{id} | Remove a collection | | POST | /lms/{connection_id}/activity | Create an activity | | GET | /lms/{connection_id}/activity | List all activities | | GET | /lms/{connection_id}/activity/{id} | Retrieve an activity | | PUT | /lms/{connection_id}/activity/{id} | Update an activity | | DELETE | /lms/{connection_id}/activity/{id} | Remove an activity | #### Data Models ### LmsActivity | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | content_id | string | | | | course_id | string | | For providers that track at course level (e.g., Learnupon enrollments) (reference to Course) | | student_id | string | | (reference to LmsStudent) | | duration_minutes | number | | | | assigned_grade | string | | | | is_completed | boolean | | | | progress_percentage | number | | | | started_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | completed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | LmsMetadata[] | | | | raw | object | | | ### LmsClass | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | media | LmsMedia[] | | | | course_id | string | | (reference to Course) | | languages | LmsClass_languages | | | | instructors | LmsReference[] | | | | students | LmsReference[] | | | | metadata | LmsMetadata[] | | | | raw | object | | | ### LmsCollection | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | media | LmsMedia[] | | | | is_active | boolean | | | | parent_id | string | | | | metadata | LmsMetadata[] | | | | raw | object | | | ### LmsContent | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | external_reference | string | | | | course_ids | LmsContent_course_ids | | | | name | string | | | | description | string | | | | languages | LmsContent_languages | | | | media | LmsMedia[] | | | | is_active | boolean | | | | duration_minutes | number | | | | categories | LmsContent_categories | | | | subjects | LmsSubject[] | | Topic taxonomy as {name, rank} pairs carrying the full ancestor chain (rank = depth, 0 = top level) | | skills | LmsContent_skills | | | | tags | LmsContent_tags | | | | difficulty | string | | Difficulty or level of the content (e.g. Beginner, Intermediate, Advanced) | | sort_order | number | | | | provider_name | string | | | | short_description | string | | | | localizations | LmsContentLocalization[] | | | | collection_ids | LmsContent_collection_ids | | (reference to CommerceCollection) | | instructors | LmsReference[] | | | | published_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | LmsMetadata[] | | | | raw | object | | | ### LmsCourse | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | is_private | boolean | | | | is_active | boolean | | | | price_amount | number | | How much the course costs | | languages | LmsCourse_languages | | | | categories | LmsCourse_categories | | | | currency | string | | The currency code (ISO) of the course price | | media | LmsMedia[] | | | | instructors | LmsReference[] | | | | students | LmsReference[] | | | | content_ids | LmsCourse_content_ids | | | | skills | LmsCourse_skills | | | | duration_minutes | number | | | | provider_name | string | | | | published_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | time_estimate_minutes | number | | | | metadata | LmsMetadata[] | | | | raw | object | | | ### LmsInstructor | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | first_name | string | | | | last_name | string | | | | emails | LmsEmail[] | | | | title | string | | | | telephones | LmsTelephone[] | | | | image_url | string | | | | metadata | LmsMetadata[] | | | | raw | object | | | ### LmsStudent | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | first_name | string | | | | last_name | string | | | | emails | LmsEmail[] | | | | telephones | LmsTelephone[] | | | | address | LmsStudent_address | | | | image_url | string | | | | metadata | LmsMetadata[] | | | | raw | object | | | ### LmsClass_languages ### LmsContentLocalization | Field | Type | Required | Description | |---|---|---|---| | name | string | | | | description | string | | | | language | string | | | ### LmsContent_categories ### LmsContent_collection_ids ### LmsContent_course_ids ### LmsContent_languages ### LmsContent_skills ### LmsContent_tags ### LmsCourse_categories ### LmsCourse_content_ids ### LmsCourse_languages ### LmsCourse_skills ### LmsEmail ### LmsMedia | Field | Type | Required | Description | |---|---|---|---| | url | string | | | | name | string | | | | description | string | | | | type | string enum: IMAGE, HEADSHOT, VIDEO, WEB, DOCUMENT, TEXT, HTML, OTHER | | | | thumbnail_url | string | | | | content | string | | Inline text/HTML/markdown content (use when url is empty or for embedded content) | | languages | LmsMedia_languages | | ISO 2-digit language codes | ### LmsMedia_languages ISO 2-digit language codes ### LmsMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### LmsReference | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | ### LmsStudent_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | ### LmsSubject | Field | Type | Required | Description | |---|---|---|---| | name | string | | | | rank | number | | Depth of this subject in the provider's topic hierarchy (0 = top level) | ### LmsTelephone | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | | | type | string enum: WORK, HOME, OTHER, FAX, MOBILE | | | --- # Martech API – Endpoints & Data Models ## Martech API data models URL: https://docs.unified.to/martech/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /martech/{connection_id}/list | Create a list | | GET | /martech/{connection_id}/list | List all lists | | GET | /martech/{connection_id}/list/{id} | Retrieve a list | | PUT | /martech/{connection_id}/list/{id} | Update a list | | DELETE | /martech/{connection_id}/list/{id} | Remove a list | | POST | /martech/{connection_id}/member | Create a member | | GET | /martech/{connection_id}/member | List all members | | GET | /martech/{connection_id}/member/{id} | Retrieve a member | | PUT | /martech/{connection_id}/member/{id} | Update a member | | DELETE | /martech/{connection_id}/member/{id} | Remove a member | | POST | /martech/{connection_id}/campaign | Create a campaign | | GET | /martech/{connection_id}/campaign | List all campaigns | | GET | /martech/{connection_id}/campaign/{id} | Retrieve a campaign | | PUT | /martech/{connection_id}/campaign/{id} | Update a campaign | | DELETE | /martech/{connection_id}/campaign/{id} | Remove a campaign | | GET | /martech/{connection_id}/report | List all reports | #### Data Models ### MarketingCampaign | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the campaign | | created_at | string (date-time) | | When the campaign was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the campaign was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Campaign nametitle | | type | string | | Campaign type (e.g., regular, automation, ab_test) | | status | string enum: DRAFT, SCHEDULED, SENDING, SENT, CANCELLED, PAUSED, ARCHIVED, OTHER | | Campaign status | | list_ids | MarketingCampaign_list_ids | | Associated listaudience IDs | | subject_line | string | | Email subject line | | preview_text | string | | Email preview text | | from_name | string | | Sender name | | from_email | string | | Sender email address | | reply_to_email | string | | Reply-to email address | | send_at | string (date-time) | | Scheduled send time (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | MarketingMetadata[] | | | | raw | object | | Raw data from integration | ### MarketingList Mailing List | Field | Type | Required | Description | |---|---|---|---| | id | string | | Identifier for this list | | created_at | string (date-time) | | Date that this list was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The list's name | | description | string | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | | The user who created this list (reference to HrisEmployee) | | is_active | boolean | | | | address | MarketingList_address | | | | subject | string | | | | sender_name | string | | | | sender_email | string | | | | language | string | | | | sender_company | string | | | | sender_phone | string | | | | metadata | MarketingMetadata[] | | | | raw | object | | The raw data returned by the integration for this list | ### MarketingMember A member represents a person | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this member object | | created_at | string (date-time) | | The date that this member object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this member object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the member | | first_name | string | | | | last_name | string | | | | company | string | | The name of the company/organization the member belongs to | | emails | MarketingEmail[] | | An array of email addresses for this member | | list_ids | MarketingMember_list_ids | | An array of list IDs associated with this member | | tags | MarketingMember_tags | | An array of tags associated with this member | | status | string enum: SUBSCRIBED, UNSUBSCRIBED, CLEANED, PENDING, TRANSACTIONAL | | | | metadata | MarketingMetadata[] | | | | raw | object | | The raw data returned by the integration for this member | ### MarketingCampaign_list_ids Associated listaudience IDs ### MarketingEmail | Field | Type | Required | Description | |---|---|---|---| | email | string | Yes | | | type | string enum: WORK, HOME, OTHER | | | ### MarketingList_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | | ### MarketingMember_list_ids An array of list IDs associated with this member ### MarketingMember_tags An array of tags associated with this member ### MarketingMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | --- # Messaging API – Endpoints & Data Models ## Messaging API data models URL: https://docs.unified.to/messaging/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /messaging/{connection_id}/channel | Create a channel | | GET | /messaging/{connection_id}/channel | List all channels | | GET | /messaging/{connection_id}/channel/{id} | Retrieve a channel | | PUT | /messaging/{connection_id}/channel/{id} | Update a channel | | DELETE | /messaging/{connection_id}/channel/{id} | Remove a channel | | POST | /messaging/{connection_id}/message | Create a message | | GET | /messaging/{connection_id}/message | List all messages | | GET | /messaging/{connection_id}/message/{id} | Retrieve a message | | PUT | /messaging/{connection_id}/message/{id} | Update a message | | DELETE | /messaging/{connection_id}/message/{id} | Remove a message | | PUT | /messaging/{connection_id}/event/{id} | Update an event | #### Data Models ### MessagingChannel | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | parent_id | string | | | | has_subchannels | boolean | | | | members | MessagingMember[] | | | | is_active | boolean | | | | is_private | boolean | | | | web_url | string | | | | metadata | MessagingMetadata[] | | | | raw | object | | | ### MessagingEvent | Field | Type | Required | Description | |---|---|---|---| | id | string | | not used | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | type | string enum: MESSAGE_RECEIVED, REACTION_ADDED, REACTION_REMOVED, BUTTON_CLICK, APP_MENTION, CHANNEL_JOINED, CHANNEL_LEFT, CHANNEL_CREATED, CHANNEL_DELETED, CHANNEL_RENAMED, USER_CREATED, USER_DELETED, USER_UPDATED | | | | channel | MessagingEvent_channel | | | | message | MessagingEvent_message | | | | button | MessagingEvent_button | | | | user | MessagingEvent_user | | | | metadata | MessagingMetadata[] | | | | raw | object | | | | is_replacing_original | boolean | | | ### MessagingMessage | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | channels | MessagingReference[] | | Represents all channels to which the message is sent. Identifies the channels where the message is posted. To send to the "Draft" channel/folder, use a channel ID "DRAFT". | | parent_id | string | | Represents the ID of the immediate predecessor message in the thread. Identifies a specific message to which the current message directly replies. | | message_thread_identifier | string | | the opaque identifier for the first message in a thread | | author_member | MessagingMessage_author_member | | for email systems, this field represents the From value | | destination_members | MessagingMember[] | | for email systems, this field represents the To value | | hidden_members | MessagingMember[] | | for email systems, this field represents the BCC value | | mentioned_members | MessagingMember[] | | for email systems, this field represents the CC value | | reactions | MessagingReaction[] | | | | subject | string | | | | message | string | | The message body as plain text | | message_html | string | | | | message_markdown | string | | | | attachments | MessagingAttachment[] | | | | web_url | string | | | | reference | string | | eg. RFC822 MessageID | | has_children | boolean | | | | is_unread | boolean | | | | buttons | MessagingButton[] | | | | metadata | MessagingMetadata[] | | | | raw | object | | | ### MessagingAttachment | Field | Type | Required | Description | |---|---|---|---| | filename | string | | | | size | number | | | | content_identifier | string | | the inline attachment's content_id | | content_type | string | | | | download_url | string | | | | message_id | string | | | ### MessagingButton | Field | Type | Required | Description | |---|---|---|---| | id | string | Yes | | | text | string | | | | icon | string | | | ### MessagingEvent_button | Field | Type | Required | Description | |---|---|---|---| | id | string | Yes | | | text | string | | | | icon | string | | | ### MessagingEvent_channel | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | parent_id | string | | | | has_subchannels | boolean | | | | members | MessagingMember[] | | | | is_active | boolean | | | | is_private | boolean | | | | web_url | string | | | | metadata | MessagingMetadata[] | | | | raw | object | | | ### MessagingEvent_message | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | channels | MessagingReference[] | | Represents all channels to which the message is sent. Identifies the channels where the message is posted. To send to the "Draft" channel/folder, use a channel ID "DRAFT". | | parent_id | string | | Represents the ID of the immediate predecessor message in the thread. Identifies a specific message to which the current message directly replies. | | message_thread_identifier | string | | the opaque identifier for the first message in a thread | | author_member | MessagingEvent_message_author_member | | for email systems, this field represents the From value | | destination_members | MessagingMember[] | | for email systems, this field represents the To value | | hidden_members | MessagingMember[] | | for email systems, this field represents the BCC value | | mentioned_members | MessagingMember[] | | for email systems, this field represents the CC value | | reactions | MessagingReaction[] | | | | subject | string | | | | message | string | | The message body as plain text | | message_html | string | | | | message_markdown | string | | | | attachments | MessagingAttachment[] | | | | web_url | string | | | | reference | string | | eg. RFC822 MessageID | | has_children | boolean | | | | is_unread | boolean | | | | buttons | MessagingButton[] | | | | metadata | MessagingMetadata[] | | | | raw | object | | | ### MessagingEvent_message_author_member for email systems, this field represents the From value | Field | Type | Required | Description | |---|---|---|---| | user_id | string | | (reference to HrisEmployee) | | email | string | | | | name | string | | | | image_url | string | | | ### MessagingEvent_user | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | ### MessagingMember | Field | Type | Required | Description | |---|---|---|---| | user_id | string | | (reference to HrisEmployee) | | email | string | | | | name | string | | | | image_url | string | | | ### MessagingMessage_author_member for email systems, this field represents the From value | Field | Type | Required | Description | |---|---|---|---| | user_id | string | | (reference to HrisEmployee) | | email | string | | | | name | string | | | | image_url | string | | | ### MessagingMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### MessagingReaction | Field | Type | Required | Description | |---|---|---|---| | reaction | string | Yes | | | member | MessagingReaction_member | Yes | | ### MessagingReaction_member | Field | Type | Required | Description | |---|---|---|---| | user_id | string | | (reference to HrisEmployee) | | email | string | | | | name | string | | | | image_url | string | | | ### MessagingReference | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | | --- # Metadata API – Endpoints & Data Models ## Metadata API data models URL: https://docs.unified.to/metadata/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /metadata/{connection_id}/metadata | Create a metadata | | GET | /metadata/{connection_id}/metadata | List all metadatas | | GET | /metadata/{connection_id}/metadata/{id} | Retrieve a metadata | | PUT | /metadata/{connection_id}/metadata/{id} | Update a metadata | | DELETE | /metadata/{connection_id}/metadata/{id} | Remove a metadata | #### Data Models ### MetadataMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | Yes | The display name of the custom field | | slug | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | The data type of the custom field's values | | original_format | string | | | | options | MetadataMetadata_options | | | | object_type | string | Yes | This is the unified object type that this metadata is associated with. eg. crm_contact or commerce_item | | objects | MetadataMetadata_objects | | | | is_required | boolean | | When true, this metadata slug is required for create for this integration. | | raw | object | | | ### MetadataMetadata_objects ### MetadataMetadata_options --- # Passthrough API – Endpoints & Data Models ## Passthrough API data models URL: https://docs.unified.to/passthrough/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /passthrough/{connection_id}/{path} | Passthrough POST | | PUT | /passthrough/{connection_id}/{path} | Passthrough PUT | | GET | /passthrough/{connection_id}/{path} | Passthrough GET | | DELETE | /passthrough/{connection_id}/{path} | Passthrough DELETE | #### Data Models ### Payload integration-specific payload --- # Payment API – Endpoints & Data Models ## Payment API data models URL: https://docs.unified.to/payment/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /payment/{connection_id}/payment | Create a payment | | GET | /payment/{connection_id}/payment | List all payments | | GET | /payment/{connection_id}/payment/{id} | Retrieve a payment | | PUT | /payment/{connection_id}/payment/{id} | Update a payment | | DELETE | /payment/{connection_id}/payment/{id} | Remove a payment | | POST | /payment/{connection_id}/link | Create a link | | GET | /payment/{connection_id}/link | List all links | | GET | /payment/{connection_id}/link/{id} | Retrieve a link | | PUT | /payment/{connection_id}/link/{id} | Update a link | | DELETE | /payment/{connection_id}/link/{id} | Remove a link | | GET | /payment/{connection_id}/refund/{id} | Retrieve a refund | | GET | /payment/{connection_id}/refund | List all refunds | | GET | /payment/{connection_id}/payout/{id} | Retrieve a payout | | GET | /payment/{connection_id}/payout | List all payouts | | POST | /payment/{connection_id}/subscription | Create a subscription | | GET | /payment/{connection_id}/subscription | List all subscriptions | | GET | /payment/{connection_id}/subscription/{id} | Retrieve a subscription | | PUT | /payment/{connection_id}/subscription/{id} | Update a subscription | | DELETE | /payment/{connection_id}/subscription/{id} | Remove a subscription | #### Data Models ### PaymentLink | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | is_active | boolean | | | | lineitems | PaymentLineitem[] | | | | currency | string | | | | amount | number | | | | payment_id | string | | (reference to PaymentPayment) | | contact_id | string | | ID of the AccountingContact (reference to AccountingContact) | | url | string | | the payment link | | description | string | | | | is_chargeable_now | boolean | | | | success_url | string | | | | metadata | PaymentMetadata[] | | | | raw | object | | | ### PaymentPayment | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | The amount paid, in the payment's currency | | contact_id | string | | (reference to AccountingContact) | | payment_method | string | | | | type | string enum: INVOICE, BILL | | | | currency | string | | | | notes | string | | | | invoice_id | string | | (reference to AccountingInvoice) | | bill_id | string | | references AccountingBill | | link_id | string | | | | account_id | string | | (reference to AccountingAccount) | | reference | string | | | | organization_id | string | | reference to an AccountingOrganization | | allocations | PaymentAllocation[] | | What this payment was applied to (invoices, bills, credit memos, etc.). Replaces separate invoice/bill payment endpoints. | | status | string enum: SUCCEEDED, PENDING, AUTHORIZED, FAILED, CANCELED | | | | salesorder_id | string | | reference to an AccountingSalesorder, e.g. the POS order this payment settled | | tender_type | string enum: CARD, CASH, GIFT_CARD, BANK_TRANSFER, WALLET, CHECK, STORE_CREDIT, BUY_NOW_PAY_LATER, EXTERNAL, OTHER | | normalized tender category; the provider's original label stays in payment_method | | tip_amount | number | | | | fee_amount | number | | processing/processor fee | | location_id | string | | store/site where the payment was taken (commerce_location) | | device_id | string | | POS terminal/register/device (hr device) | | card_brand | string | | | | card_last4 | string | | | | paid_at | string (date-time) | | Date and time the payment was made (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | exchange_rate | number | | Exchange rate to the base/home currency at the payment date | | category_ids | PaymentPayment_category_ids | | dimension refs -> AccountingCategory (reference to AccountingCategory) | | net_amount | number | | total_amount minus fee_amount | | metadata | PaymentMetadata[] | | | | raw | object | | | ### PaymentPayout | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | | | currency | string | | | | notes | string | | | | status | string enum: SUCCEEDED, PENDING, FAILED, CANCELED | | | | arrival_at | string (date-time) | | Date the funds land in the destination account (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | fee_amount | number | | Processor fee deducted from the payout | | net_amount | number | | total_amount minus fee_amount | | account_id | string | | ref -> AccountingAccount; the destination account (reference to AccountingAccount) | | lineitems | PaymentPayoutLineitem[] | | The transactions included in this payout | | metadata | PaymentMetadata[] | | | | raw | object | | | ### PaymentRefund | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | | | payment_id | string | | (reference to PaymentPayment) | | currency | string | | | | notes | string | | | | status | string enum: SUCCEEDED, PENDING, FAILED, CANCELED | | | | reference | string | | | | refunded_at | string (date-time) | | Date and time the refund occurred (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | reason | string enum: DUPLICATE, FRAUDULENT, REQUESTED_BY_CUSTOMER, OTHER | | Why the refund was issued | | tender_type | string enum: CARD, CASH, GIFT_CARD, BANK_TRANSFER, WALLET, CHECK, STORE_CREDIT, BUY_NOW_PAY_LATER, EXTERNAL, OTHER | | Normalized tender category the refund was returned to; mirrors PaymentPayment.tender_type | | metadata | PaymentMetadata[] | | | | raw | object | | | ### PaymentSubscription | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | description | string | | | | contact_id | string | | a reference to an Accounting Contact (reference to AccountingContact) | | invoice_id | string | | a reference to an Accounting Invoice (reference to AccountingInvoice) | | current_period_end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | current_period_start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | canceled_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | currency | string | | | | total_amount | number | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | status | string enum: ACTIVE, INACTIVE, CANCELED, PAUSED | | | | month | number | | the month on which to charge a customer (1-12). Only applies when the interval_unit is YEAR. | | interval_unit | string enum: YEAR, MONTH, WEEK, DAY | | | | day_of_month | number | | The day of the month to charge customers on. 1-28 or -1 to indicate the last day of the month. Only applies when the interval_unit is MONTH. | | day_of_week | number | | The day of the week to charge customers on. 1-7 or -1 to indicate the last day of the week. Only applies when the interval_unit is WEEK. | | interval | number | | The number of intervals between charges within the interval_unit. Defaults to 1. | | lineitems | PaymentLineitem[] | | | | metadata | PaymentMetadata[] | | | | raw | object | | | ### PaymentAllocation | Field | Type | Required | Description | |---|---|---|---| | object_type | string enum: INVOICE, BILL, CREDITMEMO, VENDORCREDIT, SALESORDER, PURCHASEORDER | | The type of object this payment was applied to | | object_id | string | | The id of the object this payment was applied to | | amount | number | | The amount of the payment applied to this object | | currency | string | | ISO 4217 currency code of the applied amount | | exchange_rate | number | | The exchange rate at the time the payment was applied, for multi-currency | | allocated_at | string (date-time) | | Date and time when the payment was applied, in ISO 8601 format and UTC (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### PaymentLineitem | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | refunded_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | total_amount | number | | | | refund_amount | number | | new field | | discount_amount | number | | | | tax_amount | number | | | | item_id | string | | (reference to CommerceItem) | | unit_amount | number | | | | unit_quantity | number | | | | item_sku | string | | | | item_name | string | | | | item_description | string | | | | notes | string | | | | taxrate_id | string | | | | account_id | string | | (reference to AccountingAccount) | ### PaymentMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### PaymentPayment_category_ids dimension refs -> AccountingCategory ### PaymentPayoutLineitem | Field | Type | Required | Description | |---|---|---|---| | object_type | string enum: PAYMENT, REFUND, FEE, ADJUSTMENT, TRANSFER, OTHER | | What this payout line settles | | object_id | string | | id of the settled object -- a PaymentPayment or PaymentRefund for PAYMENT / REFUND lines | | amount | number | | Gross amount of this line | | currency | string | | ISO 4217 currency code of the line amount | | fee_amount | number | | Processor fee attributed to this line | | net_amount | number | | amount minus fee_amount | --- # Performance API – Endpoints & Data Models ## Performance API data models URL: https://docs.unified.to/performance/overview #### Endpoints | Method | Path | Description | |---|---|---| | GET | /performance/{connection_id}/cycle/{id} | Retrieve a cycle | | GET | /performance/{connection_id}/cycle | List all cycles | | GET | /performance/{connection_id}/review/{id} | Retrieve a review | | GET | /performance/{connection_id}/review | List all reviews | | POST | /performance/{connection_id}/goal | Create a goal | | GET | /performance/{connection_id}/goal | List all goals | | GET | /performance/{connection_id}/goal/{id} | Retrieve a goal | | PUT | /performance/{connection_id}/goal/{id} | Update a goal | | DELETE | /performance/{connection_id}/goal/{id} | Remove a goal | | POST | /performance/{connection_id}/feedback | Create a feedback | | GET | /performance/{connection_id}/feedback | List all feedbacks | | GET | /performance/{connection_id}/feedback/{id} | Retrieve a feedback | #### Data Models ### PerformanceCycle | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | type | string enum: REVIEW, GOAL, OTHER | | whether this is a review cycle or a goal cycle/timeframe | | status | string enum: DRAFT, SCHEDULED, ACTIVE, CLOSED, OTHER | | | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | PerformanceMetadata[] | | | | raw | object | | | ### PerformanceFeedback | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | Yes | the HR Employee that this feedback is about (reference to HrisEmployee) | | author_user_id | string | | the HR Employee that gave this feedback (reference to HrisEmployee) | | type | string enum: PRAISE, FEEDBACK, REQUESTED, OTHER | | praise/recognition, constructive feedback, or a response to a feedback request | | message | string | | | | tags | PerformanceFeedback_tags | | badges or company values attached to this feedback | | is_visible | boolean | | true when publicly visible (e.g. praise wall); false when private or manager-only | | metadata | PerformanceMetadata[] | | | | raw | object | | | ### PerformanceGoal | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | user_ids | PerformanceGoal_user_ids | | HR Employees that own or share this goal; the first element is the primary owner (reference to HrisEmployee) | | company_id | string | | | | parent_id | string | | id of the parent goal, for goal alignment / OKR trees | | cycle_id | string | | pointer to the Performance Cycle object (goal cycle or timeframe) | | type | string enum: INDIVIDUAL, TEAM, DEPARTMENT, COMPANY, OTHER | | | | status | string enum: NOT_STARTED, IN_PROGRESS, COMPLETED, CLOSED, OTHER | | | | progress | number | | progress percentage from 0 to 100 | | weight | number | | the weight of this goal when the provider weighs goals in reviews | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | completed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | milestones | PerformanceGoalMilestone[] | | milestones or key results for this goal | | metadata | PerformanceMetadata[] | | | | raw | object | | | ### PerformanceReview | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | Yes | the HR Employee being reviewed (reference to HrisEmployee) | | reviewer_user_id | string | | the HR Employee that wrote this review (reference to HrisEmployee) | | cycle_id | string | | pointer to the Performance Cycle object | | template_identifier | string | | id of the review template/form this review is based on | | type | string enum: SELF, MANAGER, PEER, UPWARD, OTHER | | | | status | string enum: DRAFT, IN_PROGRESS, SUBMITTED, COMPLETED, SHARED, ACKNOWLEDGED, OTHER | | | | rating | number | | the overall rating/score of this review | | rating_maximum | number | | the maximum possible rating/score on the provider's scale | | rating_description | string | | label of the rating on the provider's scale (e.g. "Exceeds expectations") | | comment | string | | the overall/summary comment of this review | | questions | PerformanceReviewQuestion[] | | | | is_acknowledged | boolean | | whether the reviewee acknowledged this review | | submitted_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | completed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | PerformanceMetadata[] | | | | raw | object | | | ### PerformanceFeedback_tags badges or company values attached to this feedback ### PerformanceGoalMilestone | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | Yes | | | description | string | | | | target_value | number | | | | current_value | number | | | | unit | string | | unit of the target/current values (e.g. %, USD, count) | | weight | number | | | | is_completed | boolean | | | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### PerformanceGoal_user_ids HR Employees that own or share this goal; the first element is the primary owner ### PerformanceMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### PerformanceReviewQuestion | Field | Type | Required | Description | |---|---|---|---| | question | string | Yes | | | description | string | | | | type | string enum: TEXT, SINGLE_SELECT, MULTI_SELECT, RATING, OTHER | | | | answers | PerformanceReviewQuestion_answers | | all answer values; single-answer questions have one element | ### PerformanceReviewQuestion_answers all answer values; single-answer questions have one element --- # Repo API – Endpoints & Data Models ## Repo API data models URL: https://docs.unified.to/repo/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /repo/{connection_id}/organization | Create an organization | | GET | /repo/{connection_id}/organization | List all organizations | | GET | /repo/{connection_id}/organization/{id} | Retrieve an organization | | PUT | /repo/{connection_id}/organization/{id} | Update an organization | | DELETE | /repo/{connection_id}/organization/{id} | Remove an organization | | POST | /repo/{connection_id}/repository | Create a repository | | GET | /repo/{connection_id}/repository | List all repositories | | GET | /repo/{connection_id}/repository/{id} | Retrieve a repository | | PUT | /repo/{connection_id}/repository/{id} | Update a repository | | DELETE | /repo/{connection_id}/repository/{id} | Remove a repository | | POST | /repo/{connection_id}/branch | Create a branch | | GET | /repo/{connection_id}/branch | List all branches | | GET | /repo/{connection_id}/branch/{id} | Retrieve a branch | | PUT | /repo/{connection_id}/branch/{id} | Update a branch | | DELETE | /repo/{connection_id}/branch/{id} | Remove a branch | | POST | /repo/{connection_id}/commit | Create a commit | | GET | /repo/{connection_id}/commit | List all commits | | GET | /repo/{connection_id}/commit/{id} | Retrieve a commit | | PUT | /repo/{connection_id}/commit/{id} | Update a commit | | DELETE | /repo/{connection_id}/commit/{id} | Remove a commit | | POST | /repo/{connection_id}/pullrequest | Create a pullrequest | | GET | /repo/{connection_id}/pullrequest | List all pullrequests | | GET | /repo/{connection_id}/pullrequest/{id} | Retrieve a pullrequest | | PUT | /repo/{connection_id}/pullrequest/{id} | Update a pullrequest | | DELETE | /repo/{connection_id}/pullrequest/{id} | Remove a pullrequest | #### Data Models ### RepoBranch | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | Yes | | | repo_id | string | Yes | (reference to RepoRepository) | | metadata | RepoMetadata[] | | | | raw | object | | | ### RepoCommit | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | | (reference to HrisEmployee) | | repo_id | string | Yes | (reference to RepoRepository) | | message | string | | | | branch_id | string | | (reference to RepoBranch) | | pullrequest_ids | RepoCommit_pullrequest_ids | | | | lines_added | number | | | | lines_deleted | number | | | | lines_changed | number | | | | metadata | RepoMetadata[] | | | | raw | object | | | ### RepoOrganization | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | avatar_url | string | | | | web_url | string | | | | user_ids | RepoOrganization_user_ids | | id values of the users/employees associated with this organization (reference to HrisEmployee) | | metadata | RepoMetadata[] | | | | raw | object | | | ### RepoPullrequest | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_ids | RepoPullrequest_user_ids | | (reference to HrisEmployee) | | repo_id | string | | (reference to RepoRepository) | | title | string | | | | notes | string | | | | target_branch_id | string | | | | source_branch_id | string | | | | status | string enum: PENDING, APPROVED, REJECTED | | | | labels | RepoPullrequest_labels | | | | closed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | commit_ids | RepoPullrequest_commit_ids | | (reference to RepoCommit) | | metadata | RepoMetadata[] | | | | raw | object | | | ### RepoRepository | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | Yes | | | description | string | | | | owner | string | | | | is_private | boolean | | | | web_url | string | | | | org_id | string | | (reference to RepoOrganization) | | metadata | RepoMetadata[] | | | | raw | object | | | ### RepoCommit_pullrequest_ids ### RepoMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### RepoOrganization_user_ids id values of the users/employees associated with this organization ### RepoPullrequest_commit_ids ### RepoPullrequest_labels ### RepoPullrequest_user_ids --- # Scim API – Endpoints & Data Models ## Scim API data models URL: https://docs.unified.to/scim/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /scim/{connection_id}/users | Create user | | GET | /scim/{connection_id}/users | List users | | GET | /scim/{connection_id}/users/{id} | Get user | | PUT | /scim/{connection_id}/users/{id} | Update user | | DELETE | /scim/{connection_id}/users/{id} | Delete user | | POST | /scim/{connection_id}/groups | Create group | | GET | /scim/{connection_id}/groups | List groups | | GET | /scim/{connection_id}/groups/{id} | Get group | | PUT | /scim/{connection_id}/groups/{id} | Update group | | DELETE | /scim/{connection_id}/groups/{id} | Delete group | #### Data Models ### ScimGroup | Field | Type | Required | Description | |---|---|---|---| | id | string | | The group's unique id | | externalId | string | | A groups id in an external system | | displayName | string | Yes | The group's display name | | groupType | string | | Organization [NOT STANDARD] | | members | ScimGroupMember[] | | An array of members | | meta | ScimGroup_meta | | | | schemas | ScimGroup_schemas | | Array of schema URIs | ### ScimUser | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | schemas | ScimUser_schemas | | | | externalId | string | | | | userType | string | | 'Employee','Super Admin', 'General Admin', 'Account Manager', 'User Manager', 'Workflow Manager', 'Experience Manager', 'Author','Reporter','Contractor,'Intern','Temp','External','Unknown'] | | userName | string | | | | meta | ScimUser_meta | | | | name | ScimUser_name | | | | displayName | string | | | | nickName | string | | | | profileUrl | string | | | | title | string | | | | preferredLanguage | string | | | | locale | string | | | | timezone | string | | | | active | boolean | | | | password | string | | | | phoneNumbers | ScimPhoneNumber[] | | | | emails | ScimEmail[] | | | | ims | ScimIms[] | | | | photos | ScimPhoto[] | | | | addresses | ScimAddress[] | | | | groups | ScimUserGroups[] | | | | entitlements | ScimEntitlement[] | | | | roles | ScimRole[] | | Student, Faculty, ... | | x509Certificates | ScimRole[] | | | | urn:ietf:params:scim:schemas:extension:enterprise:2.0:User | ScimUser_urn_ietf_params_scim_schemas_extension_enterprise_2_0_User | | an organization. | | urn:ietf:params:scim:schemas:extension:lattice:attributes:1.0:User | ScimUser_urn_ietf_params_scim_schemas_extension_lattice_attributes_1_0_User | | | | urn:ietf:params:scim:schemas:extension:peakon:2.0:User | ScimUser_urn_ietf_params_scim_schemas_extension_peakon_2_0_User | | | ### ScimAddress | Field | Type | Required | Description | |---|---|---|---| | formatted | string | | | | streetAddress | string | | could be multi-line | | locality | string | | city | | region | string | | stateprovince | | postalCode | string | | | | country | string | | | | type | string enum: work, home, other | | | ### ScimEmail | Field | Type | Required | Description | |---|---|---|---| | type | string enum: work, home, other | Yes | | | value | string | | | | primary | boolean | | | | display | string | | | ### ScimEntitlement | Field | Type | Required | Description | |---|---|---|---| | value | string | Yes | | | display | string | | | | type | string | | | | primary | boolean | | | ### ScimGroupMember | Field | Type | Required | Description | |---|---|---|---| | value | string | Yes | ID of user | | $ref | string | | url to user | | display | string | | user name | | type | string enum: User, Group | | NOT STANDARD | | operation | string enum: add, delete | | ONLY USED ON UPDATES | ### ScimGroup_meta | Field | Type | Required | Description | |---|---|---|---| | resourceType | string enum: User, Group | | | | created | string | | | | lastModified | string | | | | location | string | | | | version | string | | | ### ScimGroup_schemas Array of schema URIs ### ScimIms | Field | Type | Required | Description | |---|---|---|---| | type | string enum: aim, qtalk, icq, xmpp, msn, skype, qq, yahoo | | | | value | string | | | | display | string | | | | primary | boolean | | | ### ScimManager "id" attribute of another User. | Field | Type | Required | Description | |---|---|---|---| | value | string | | The "id" of the SCIM resource representing the user's manager. RECOMMENDED. | | managerId | string | | alias for value? | | $ref | string | | The URI of the SCIM resource representing the User's manager. RECOMMENDED. | | displayName | string | | The displayName of the user's manager. This attribute is OPTIONAL, and mutability is "readOnly". | | type | string enum: direct, indirect | | | ### ScimPhoneNumber | Field | Type | Required | Description | |---|---|---|---| | type | string enum: work, home, other, mobile, fax, pager | | | | value | string | | | | display | string | | | | primary | boolean | | | ### ScimPhoto | Field | Type | Required | Description | |---|---|---|---| | value | string | | URL | | display | string | | | | type | string enum: photo, thumbnail | | | | primary | boolean | | | ### ScimRole | Field | Type | Required | Description | |---|---|---|---| | value | string | Yes | value of the role | | display | string | | | | type | string | | | | primary | boolean | | | ### ScimUserGroups | Field | Type | Required | Description | |---|---|---|---| | value | string | Yes | ID | | $ref | string | | | | display | string | | | | type | string enum: direct, indirect | | | ### ScimUser_meta | Field | Type | Required | Description | |---|---|---|---| | resourceType | string enum: User, Group | | | | created | string | | | | lastModified | string | | | | location | string | | | | version | string | | | ### ScimUser_name | Field | Type | Required | Description | |---|---|---|---| | formatted | string | | | | familyName | string | | | | givenName | string | | | | middleName | string | | | | honorificPrefix | string | | | | honorificSuffix | string | | | ### ScimUser_schemas ### ScimUser_urn_ietf_params_scim_schemas_extension_enterprise_2_0_User an organization. | Field | Type | Required | Description | |---|---|---|---| | employeeNumber | string | | | | costCenter | string | | Identifies the name of a cost center. | | organization | string | | Identifies the name of an organization. | | division | string | | Identifies the name of a division. | | department | string | | Identifies the name of a department. | | manager | ScimUser_urn_ietf_params_scim_schemas_extension_enterprise_2_0_User_manager | | "id" attribute of another User. | | additionalManagers | ScimManager[] | | | | level | string | | | | startDate | string (date-time) | | 2011-03-25 | | endDate | string (date-time) | | 2022-02-25 | | birthday | string (date-time) | | 1985-07-20 | | gender | string enum: male, female | | | | location | string | | Berlin | | currency | string | | User currency | ### ScimUser_urn_ietf_params_scim_schemas_extension_enterprise_2_0_User_manager "id" attribute of another User. | Field | Type | Required | Description | |---|---|---|---| | value | string | | The "id" of the SCIM resource representing the user's manager. RECOMMENDED. | | managerId | string | | alias for value? | | $ref | string | | The URI of the SCIM resource representing the User's manager. RECOMMENDED. | | displayName | string | | The displayName of the user's manager. This attribute is OPTIONAL, and mutability is "readOnly". | | type | string enum: direct, indirect | | | ### ScimUser_urn_ietf_params_scim_schemas_extension_lattice_attributes_1_0_User | Field | Type | Required | Description | |---|---|---|---| | startDate | string (date-time) | | "2022-03-08", | | birthDate | string (date-time) | | "1987-10-06", | | gender | string enum: male, female | | | | sexualOrientation | string enum: Queer, Heterosexual, Straight | | | | ethnicity | string enum: Caucasian, East Asian, Middle Eastern, Black, Biracial (South Asian & Caucasian), Filipino, South Asian, Indian, White, Asian | | | | People Manager (Reviews) | string | | 'People Manager', | | Salary Information | string | | '130000', | | Remote Work - Location | string | | 'Ontario', | | Job Level | string | | 'M3', | | Sub Departments | string | | | ### ScimUser_urn_ietf_params_scim_schemas_extension_peakon_2_0_User | Field | Type | Required | Description | |---|---|---|---| | Gender | string enum: Female, Male | | | | Team | string | | | | Manager | string | | | | Date of Birth | string (date-time) | | | --- # Shipping API – Endpoints & Data Models ## Shipping API data models URL: https://docs.unified.to/shipping/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /shipping/{connection_id}/shipment | Create a shipment | | GET | /shipping/{connection_id}/shipment | List all shipments | | GET | /shipping/{connection_id}/shipment/{id} | Retrieve a shipment | | PUT | /shipping/{connection_id}/shipment/{id} | Update a shipment | | DELETE | /shipping/{connection_id}/shipment/{id} | Remove a shipment | | POST | /shipping/{connection_id}/label | Create a label | | GET | /shipping/{connection_id}/label | List all labels | | GET | /shipping/{connection_id}/label/{id} | Retrieve a label | | PUT | /shipping/{connection_id}/label/{id} | Update a label | | DELETE | /shipping/{connection_id}/label/{id} | Remove a label | | GET | /shipping/{connection_id}/tracking/{id} | Retrieve a tracking | | GET | /shipping/{connection_id}/tracking | List all trackings | | POST | /shipping/{connection_id}/rate | Create a rate | | GET | /shipping/{connection_id}/carrier/{id} | Retrieve a carrier | | GET | /shipping/{connection_id}/carrier | List all carriers | #### Data Models ### ShippingCarrier | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this carrier object | | created_at | string (date-time) | | The date that this carrier object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this carrier object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the carrier | | code | string | | Carrier code (e.g., "usps", "fedex", "ups") | | is_active | boolean | | Whether the carrier is currently active | | logo_url | string | | Carrier logo URL | | website_url | string | | Carrier website | | metadata | ShippingMetadata[] | | | | raw | object | | The raw data returned by the integration for this carrier | ### ShippingLabel | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this label object | | created_at | string (date-time) | | The date that this label object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this label object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | shipment_id | string | | Reference to the shipment | | tracking_number | string | | Tracking number for the shipment | | label_url | string | | URL to download the label | | label_format | string enum: PDF, PNG, ZPL, EPL2, PDF_4X6, PDF_4X8, PNG_4X6, PNG_4X8 | | Format of the label (PDF, PNG, ZPL, EPL2) | | status | string enum: PENDING, PROCESSING, IN_TRANSIT, DELIVERED, EXCEPTION, CANCELLED, LABEL_CREATED, PICKED_UP, OUT_FOR_DELIVERY, DELIVERY_ATTEMPTED, RETURNED_TO_SENDER, HELD_AT_LOCATION, CUSTOMS_CLEARANCE, EXCEPTION_RESOLVED | | Status of the label | | is_voided | boolean | | Whether label has been voided | | label_cost | number | | Cost to purchase label | | label_cost_currency | string | | | | rate_id | string | | Rate used for this label; points to ShippingRate | | service_code | string | | Service code used | | metadata | ShippingMetadata[] | | | | raw | object | | The raw data returned by the integration for this label | ### ShippingRate | Field | Type | Required | Description | |---|---|---|---| | shipment_id | string | | Reference to the shipping object (if rate is for existing shipment) | | currency | string | | | | from_address | ShippingRate_from_address | | Origin address | | to_address | ShippingRate_to_address | | Destination address | | packages | ShippingPackage[] | | Multiple packages (alternative to package) | | ship_by_at | string (date-time) | | Latest date to ship (from order) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | carrier_id | string | | Reference to the carrier | | id | string | | Unique identifier for this rate object | | rates | ShippingRateRate[] | | | | metadata | ShippingMetadata[] | | | | raw | object | | | ### ShippingShipment | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this shipment object | | created_at | string (date-time) | | The date that this shipment object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this shipment object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | order_id | string | | Reference to commerce_order or accounting_order | | organization_id | string | | ref -> accounting Organization this shipment belongs to (multi-company ERPs) | | from_address | ShippingShipment_from_address | | Origin address | | to_address | ShippingShipment_to_address | | Destination address | | packages | ShippingPackage[] | | Array of packages in this shipment | | carrier_id | string | | Reference to the carrier | | service_code | string | | Code for the shipping service used | | status | string enum: PENDING, PROCESSING, IN_TRANSIT, DELIVERED, EXCEPTION, CANCELLED, LABEL_CREATED, PICKED_UP, OUT_FOR_DELIVERY, DELIVERY_ATTEMPTED, RETURNED_TO_SENDER, HELD_AT_LOCATION, CUSTOMS_CLEARANCE, EXCEPTION_RESOLVED | | Current status of the shipment | | rate_id | string | | Optional reference to the selected rate (for traceability) | | label_id | string | | Optional reference to the shipping label | | tracking_id | string | | Optional reference to the tracking information | | shipped_at | string (date-time) | | When shipment was createddispatched (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | rate_amount | number | | The rate amount used (may differ from shipping_cost due to adjustments) | | rate_currency | string | | Currency for rate_amount | | rate_service_name | string | | Service name from the rate (e.g., "Priority Mail") | | rate_estimated_days | number | | Estimated delivery days from the rate | | rate_estimated_delivery_at | string (date-time) | | Estimated delivery date from the rate (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | is_rate_guaranteed | boolean | | Whether delivery is guaranteed (from rate) | | return_address | ShippingShipment_return_address | | Return address (may differ from from_address) | | return_authorization_number | string | | RMA number if return shipment | | warehouse_location_id | string | | Origin warehouselocation ID; points to CommerceLocation | | warehouse_location_name | string | | Origin warehouse location name | | customs | ShippingShipment_customs | | Customs information | | is_international | boolean | | Whether shipment is international | | insurance | ShippingShipment_insurance | | Insurance details | | special_instructions | ShippingShipment_special_instructions | | Array of special instructions | | is_signature_required | boolean | | Signature required on delivery | | is_adult_signature_required | boolean | | Adult signature required | | reference_number | string | | Customer reference number | | is_return | boolean | | Whether this is a return shipment | | original_shipment_id | string | | Reference to original shipment if return; points to ShippingShipment | | return_reason | string | | Reason for return | | return_type | string enum: CUSTOMER, VENDOR, WARRANTY, DEFECTIVE, OTHER | | Type of return | | carrier_name | string | | Human-readable carrier/provider name when there is no carrier record to reference (e.g. commerce-platform fulfillments) | | tracking_url | string | | Carrier tracking URL for this shipment | | lineitems | ShippingShipmentLineitem[] | | Item-level fulfillment lines (what shipped); used by commerce-platform fulfillments | | metadata | ShippingMetadata[] | | | | raw | object | | The raw data returned by the integration for this shipment | ### ShippingTracking | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this tracking object | | created_at | string (date-time) | | The date that this tracking object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this tracking object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | shipment_id | string | | Reference to the shipment | | tracking_number | string | | Tracking number for the shipment | | status | string enum: PENDING, PROCESSING, IN_TRANSIT, DELIVERED, EXCEPTION, CANCELLED, LABEL_CREATED, PICKED_UP, OUT_FOR_DELIVERY, DELIVERY_ATTEMPTED, RETURNED_TO_SENDER, HELD_AT_LOCATION, CUSTOMS_CLEARANCE, EXCEPTION_RESOLVED | | Current status of the shipment | | events | ShippingTrackingEvent[] | | Array of tracking events | | estimated_delivery | string (date-time) | | Estimated delivery date | | actual_delivery_at | string (date-time) | | Actual delivery timestamp (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | status_description | string | | Human-readable status | | carrier_id | string | | Reference to the carrier | | carrier_status_code | string | | Carrier's status code | | carrier_status_description | string | | Carrier's status description | | metadata | ShippingMetadata[] | | | | raw | object | | The raw data returned by the integration for this tracking | ### ShippingCustomsItem | Field | Type | Required | Description | |---|---|---|---| | description | string | | Item description | | quantity | number | | Quantity | | amount | number | | Value per unit | | currency | string | | | | weight | number | | Weight per unit | | weight_unit | string enum: g, kg, oz, lb | | | | harmonized_tariff_code | string | | HS code | | country_of_origin | string | | Country of origin (ISO code) | | sku | string | | SKU | ### ShippingMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### ShippingPackage | Field | Type | Required | Description | |---|---|---|---| | weight | number | | Weight of the package | | weight_unit | string enum: g, kg, oz, lb | | Unit for weight (g, kg, oz, lb) | | length | number | | Length of the package | | width | number | | Width of the package | | height | number | | Height of the package | | size_unit | string enum: cm, inch | | Unit for dimensions (cm, inch) | | description | string | | Description of the package contents | | value | number | | Declared value of the package | | currency | string | | ISO 4217 currency code | | tracking_number | string | | Package-level tracking (for multi-package shipments) | | insured_amount | number | | Insured value for this package | ### ShippingRateRate | Field | Type | Required | Description | |---|---|---|---| | title | string | Yes | | | description | string | | | | amount | number | | | | currency | string | | | | base_amount | number | | Base shipping rate | | tax_amount | number | | | | discount_amount | number | | Applied discount | | surcharges | ShippingRateSurcharge[] | | Additional charges | | rate_source | string | | Source of rate (e.g., "carrier", "negotiated", "account") | | is_negotiated_rate | boolean | | Whether this is a negotiated rate | | estimated_days | number | | Estimated delivery time in days | | delivery_days | number | | Delivery days (integer) | | estimated_delivery_end_at | string (date-time) | | Specific delivery date (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | estimated_delivery_start_at | string (date-time) | | Time window (e.g., "10:00 AM - 2:00 PM") (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | delivery_terms | string | | Delivery duration terms (e.g., "3-5 business days") | | transit_hours | number | | Transit time in hours | | is_guaranteed | boolean | | Whether delivery is guaranteed | | package_type | string | | Package type (e.g., "package", "envelope", "flat") | | is_trackable | boolean | | Whether shipment is trackable | | is_active | boolean | | Whether rate is currently available | ### ShippingRateSurcharge | Field | Type | Required | Description | |---|---|---|---| | code | string | | Surcharge type (e.g., "RESIDENTIAL", "FUEL", "SIGNATURE") | | name | string | | Human-readable name | | amount | number | | Surcharge amount | | description | string | | Description of surcharge | ### ShippingRate_from_address Origin address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | | name | string | | Recipientsender name | | company_name | string | | Company name (for commercial addresses) | | telephone | string | | Phone number (required by FedEx, UPS, DHL) | | email | string | | Email address (for delivery notifications) | | is_residential | boolean | | Alias for address_type === 'RESIDENTIAL' | | is_validated | boolean | | Whether address has been validated | | delivery_instructions | string | | Special delivery notes | ### ShippingRate_to_address Destination address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | | name | string | | Recipientsender name | | company_name | string | | Company name (for commercial addresses) | | telephone | string | | Phone number (required by FedEx, UPS, DHL) | | email | string | | Email address (for delivery notifications) | | is_residential | boolean | | Alias for address_type === 'RESIDENTIAL' | | is_validated | boolean | | Whether address has been validated | | delivery_instructions | string | | Special delivery notes | ### ShippingShipmentLineitem | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this fulfillment line | | item_id | string | | Reference to the CommerceItem being fulfilled (reference to CommerceItem) | | item_variant_id | string | | Reference to the CommerceItemvariant being fulfilled (reference to CommerceCommerceItemvariant) | | order_lineitem_id | string | | The order line being fulfilled | | quantity | number | | Units fulfilled on this line | | item_name | string | | Item name (for display) | | sku | string | | Item SKU | ### ShippingShipment_customs Customs information | Field | Type | Required | Description | |---|---|---|---| | contents_type | string enum: MERCHANDISE, DOCUMENTS, GIFT, RETURNED_GOODS, SAMPLE, OTHER | | | | description | string | | Explanation of contents | | amount | number | | Customs declared value | | currency | string | | | | duties_paid_by | string enum: SENDER, RECIPIENT, THIRD_PARTY | | | | taxes_paid_by | string enum: SENDER, RECIPIENT, THIRD_PARTY | | | | shipper_eori | string | | Exporter EORI number | | recipient_eori | string | | Importer EORI number | | shipper_tax_number | string | | Exporter tax ID | | recipient_tax_number | string | | Importer tax ID | | items | ShippingCustomsItem[] | | Customs items | | restrictions | ShippingShipment_customs_restrictions | | Any restrictions | | non_delivery_option | string enum: RETURN, ABANDON | | | ### ShippingShipment_customs_restrictions Any restrictions ### ShippingShipment_from_address Origin address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | | name | string | | Recipientsender name | | company_name | string | | Company name (for commercial addresses) | | telephone | string | | Phone number (required by FedEx, UPS, DHL) | | email | string | | Email address (for delivery notifications) | | is_residential | boolean | | Alias for address_type === 'RESIDENTIAL' | | is_validated | boolean | | Whether address has been validated | | delivery_instructions | string | | Special delivery notes | ### ShippingShipment_insurance Insurance details | Field | Type | Required | Description | |---|---|---|---| | insured_value | number | | Insured value | | currency | string | | | | insurance_provider | string | | Insurance provider name | | insurance_provider_code | string | | Provider code | | insurance_cost | number | | Cost of insurance | | insurance_cost_currency | string | | | | coverage_type | string enum: STANDARD, PREMIUM, CUSTOM | | | | coverage_amount | number | | Coverage amount (may differ from insured_value) | ### ShippingShipment_return_address Return address (may differ from from_address) | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | | name | string | | Recipientsender name | | company_name | string | | Company name (for commercial addresses) | | telephone | string | | Phone number (required by FedEx, UPS, DHL) | | email | string | | Email address (for delivery notifications) | | is_residential | boolean | | Alias for address_type === 'RESIDENTIAL' | | is_validated | boolean | | Whether address has been validated | | delivery_instructions | string | | Special delivery notes | ### ShippingShipment_special_instructions Array of special instructions ### ShippingShipment_to_address Destination address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | | name | string | | Recipientsender name | | company_name | string | | Company name (for commercial addresses) | | telephone | string | | Phone number (required by FedEx, UPS, DHL) | | email | string | | Email address (for delivery notifications) | | is_residential | boolean | | Alias for address_type === 'RESIDENTIAL' | | is_validated | boolean | | Whether address has been validated | | delivery_instructions | string | | Special delivery notes | ### ShippingTrackingEvent | Field | Type | Required | Description | |---|---|---|---| | created_at | string (date-time) | | The date that this tracking event object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | description | string | | Description of the tracking event | | status | string enum: PENDING, PROCESSING, IN_TRANSIT, DELIVERED, EXCEPTION, CANCELLED, LABEL_CREATED, PICKED_UP, OUT_FOR_DELIVERY, DELIVERY_ATTEMPTED, RETURNED_TO_SENDER, HELD_AT_LOCATION, CUSTOMS_CLEARANCE, EXCEPTION_RESOLVED | | Status at the time of this event | | event_code | string | | Carrier-specific event code | | carrier_status_code | string | | Carrier's status code at this event | | location_address | ShippingTrackingEvent_location_address | | | | location_id | string | | Location ID; points to CommerceLocation (reference to ShippingLocation) | | location_name | string | | Location name | | notes | string | | Additional notes | | signed_by | string | | Who signed for delivery | ### ShippingTrackingEvent_location_address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | region_code | string | | | | postal_code | string | | | | country | string | | | | country_code | string | | ISO 2-digit country code | | name | string | | Recipientsender name | | company_name | string | | Company name (for commercial addresses) | | telephone | string | | Phone number (required by FedEx, UPS, DHL) | | email | string | | Email address (for delivery notifications) | | is_residential | boolean | | Alias for address_type === 'RESIDENTIAL' | | is_validated | boolean | | Whether address has been validated | | delivery_instructions | string | | Special delivery notes | --- # Signing API – Endpoints & Data Models ## Signing API data models URL: https://docs.unified.to/signing/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /signing/{connection_id}/document | Create a document | | GET | /signing/{connection_id}/document | List all documents | | GET | /signing/{connection_id}/document/{id} | Retrieve a document | | PUT | /signing/{connection_id}/document/{id} | Update a document | | DELETE | /signing/{connection_id}/document/{id} | Remove a document | | POST | /signing/{connection_id}/signatory | Create a signatory | | GET | /signing/{connection_id}/signatory | List all signatories | | GET | /signing/{connection_id}/signatory/{id} | Retrieve a signatory | | PUT | /signing/{connection_id}/signatory/{id} | Update a signatory | | DELETE | /signing/{connection_id}/signatory/{id} | Remove a signatory | | GET | /signing/{connection_id}/template/{id} | Retrieve a template | | GET | /signing/{connection_id}/template | List all templates | #### Data Models ### SigningDocument | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this signing document | | created_at | string (date-time) | | The date that this signing document was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this signing document was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Document/envelope title | | description | string | | Message to signers | | status | string enum: DRAFT, SENT, DELIVERED, IN_PROGRESS, COMPLETED, DECLINED, VOIDED, EXPIRED | | Current status of the signing document | | sent_at | string (date-time) | | When sent for signature (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | completed_at | string (date-time) | | When all signatures were collected (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | expires_at | string (date-time) | | Signing deadline (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | template_id | string | | If created from a template; points to SigningTemplate | | download_url | string | | URL to download the signed document | | creator_id | string | | User who created the signing document | | raw | object | | The raw data returned by the integration | | metadata | SigningMetadata[] | | | ### SigningSignatory | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this signatory | | created_at | string (date-time) | | The date that this signatory was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this signatory was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | document_id | string | | Parent signing document; points to SigningDocument | | name | string | | Full name of the signatory | | email | string | | Email address of the signatory | | role | string enum: SIGNER, CC, APPROVER, IN_PERSON_SIGNER, VIEWER | | Role of the signatory | | status | string enum: PENDING, SENT, DELIVERED, SIGNED, DECLINED, ERROR | | Current status of this signatory | | order | number | | Signing order (1, 2, 3...) | | signed_at | string (date-time) | | When this signatory signed (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | decline_reason | string | | Reason for declining (if status is DECLINED) | | raw | object | | The raw data returned by the integration | | metadata | SigningMetadata[] | | | ### SigningTemplate | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this template | | created_at | string (date-time) | | The date that this template was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this template was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Template name | | description | string | | Template description | | is_active | boolean | | Whether the template is active/available | | creator_id | string | | User who created the template | | raw | object | | The raw data returned by the integration | | metadata | SigningMetadata[] | | | ### SigningMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | --- # Social API – Endpoints & Data Models ## Social API data models URL: https://docs.unified.to/social/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /social/{connection_id}/post | Create a post | | GET | /social/{connection_id}/post | List all posts | | GET | /social/{connection_id}/post/{id} | Retrieve a post | | PUT | /social/{connection_id}/post/{id} | Update a post | | DELETE | /social/{connection_id}/post/{id} | Remove a post | | GET | /social/{connection_id}/profile/{id} | Retrieve a profile | | GET | /social/{connection_id}/profile | List all profiles | | GET | /social/{connection_id}/review/{id} | Retrieve a review | | PUT | /social/{connection_id}/review/{id} | Update a review | | GET | /social/{connection_id}/review | List all reviews | | GET | /social/{connection_id}/insight/{id} | Retrieve an insight | | GET | /social/{connection_id}/insight | List all insights | #### Data Models ### SocialInsight | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the insight data point | | created_at | string (date-time) | | When the insight was recorded (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | profile_id | string | | The social profile these stats belong to | | post_id | string | | Optional: the post these stats belong to | | date | string (date-time) | | The day/timestamp of the data point | | period | string enum: DAY, WEEK, MONTH, LIFETIME, TOTAL, OTHER | | The aggregation period of the value | | metric | string enum: FOLLOWERS, FOLLOWING, POSTS, IMPRESSIONS, REACH, ENGAGEMENT, PROFILE_VIEWS, CLICKS, VIDEO_VIEWS, LIKES, COMMENTS, SHARES, SAVES, OTHER | | The metric being reported | | value | number | | The metric value | | metadata | SocialMetadata[] | | | | raw | object | | Raw data from integration | ### SocialPost | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the post | | created_at | string (date-time) | | When the post was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the post was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | profile_id | string | | The social profile (page/handle/listing) that owns this post | | parent_post_id | string | | The post this one replies to or references | | title | string | | Optional post title/headline | | body | string | | The main text content of the post | | status | string enum: DRAFT, SCHEDULED, PUBLISHED, REJECTED, PROCESSING, DELETED, OTHER | | Publication status of the post | | type | string enum: POST, REPLY, REPOST, QUOTE, EVENT, OFFER, ALERT, STORY, OTHER | | The kind of post | | media | SocialMedia[] | | Media assets attached to the post | | url | string | | Public permalink to the post | | language | string | | Language code of the post content | | scheduled_at | string (date-time) | | When the post is scheduled to publish (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | call_to_action | SocialPost_call_to_action | | Call-to-action button on the post | | metrics | SocialPost_metrics | | Engagement metrics for the post | | metadata | SocialMetadata[] | | | | raw | object | | Raw data from integration | ### SocialProfile | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the profile | | created_at | string (date-time) | | When the profile was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the profile was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | Display name of the profile | | username | string | | Handle/username/store code | | parent_id | string | | Parent profile id when profiles are nested (e.g. account → location) | | description | string | | Bio or description of the profile | | url | string | | Public URL of the profile | | website_url | string | | External website linked from the profile | | avatar_url | string | | Profile image URL | | phone | string | | Contact phone number | | location | string | | Free-text location/locale string | | address | SocialProfile_address | | Structured postal address | | category | string | | Primary category of the profile | | is_verified | boolean | | Whether the profile is verified | | is_private | boolean | | Whether the profile is private/protected | | metrics | SocialProfile_metrics | | Audience metrics for the profile | | metadata | SocialMetadata[] | | | | raw | object | | Raw data from integration | ### SocialReview | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the review | | created_at | string (date-time) | | When the review was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the review was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | profile_id | string | | The social profile (page/handle/listing) being reviewed | | rating | number | | Star rating, typically 1-5 | | title | string | | Optional review title/headline | | content | string | | The review text | | author_name | string | | Display name of the reviewer | | author_avatar_url | string | | Profile image URL of the reviewer | | author_location | string | | Free-text location of the reviewer | | reply | SocialReview_reply | | The business's reply to the review | | url | string | | Public permalink to the review | | metadata | SocialMetadata[] | | | | raw | object | | Raw data from integration | ### SocialMedia | Field | Type | Required | Description | |---|---|---|---| | id | string | | Provider media id, when the media is individually addressable | | url | string | Yes | URL of the media asset | | alt | string | | Accessibility caption / alt text | | type | string enum: PHOTO, VIDEO, GIF, OTHER | | Type of media | | height | number | | Media height in pixels | | width | number | | Media width in pixels | | position | number | | Ordering of the media within the post; 1 is the first | ### SocialMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### SocialPost_call_to_action Call-to-action button on the post | Field | Type | Required | Description | |---|---|---|---| | type | string enum: BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP, CALL, OTHER | | The call-to-action button type | | url | string | | The URL the call-to-action points to | ### SocialPost_metrics Engagement metrics for the post | Field | Type | Required | Description | |---|---|---|---| | like_count | number | | Number of likes/reactions | | comment_count | number | | Number of comments/replies | | share_count | number | | Number of shares/reposts | | quote_count | number | | Number of quotes | | view_count | number | | Number of views/impressions | | bookmark_count | number | | Number of bookmarks/saves | ### SocialProfile_address Structured postal address | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | | | postal_code | string | | | | country | string | | | ### SocialProfile_metrics Audience metrics for the profile | Field | Type | Required | Description | |---|---|---|---| | follower_count | number | | Number of followers | | following_count | number | | Number of accounts this profile follows | | post_count | number | | Total number of posts published by this profile | ### SocialReview_reply The business's reply to the review | Field | Type | Required | Description | |---|---|---|---| | content | string | | The reply text from the business | | created_at | string (date-time) | | When the reply was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | When the reply was last updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | --- # Storage API – Endpoints & Data Models ## Storage API data models URL: https://docs.unified.to/storage/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /storage/{connection_id}/file | Create a file | | GET | /storage/{connection_id}/file | List all files | | GET | /storage/{connection_id}/file/{id} | Retrieve a file | | PUT | /storage/{connection_id}/file/{id} | Update a file | | DELETE | /storage/{connection_id}/file/{id} | Remove a file | #### Data Models ### StorageFile | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The file or folder name, including the file's extension | | description | string | | | | parent_id | string | | | | user_id | string | | (reference to HrisEmployee) | | size | number | | | | type | string enum: FILE, FOLDER | | Whether this is a file or a folder | | mime_type | string | | | | permissions | StoragePermission[] | | | | download_url | string | | This link expires after 1 hour. When you need to retrieve the contents of a file, call the "Retrieve a file" endpoint again to get a new download_url. | | hash | string | | | | data | string | | base64 encoded file contents used to create/update only | | version | string | | | | web_url | string | | | | references | StorageReference[] | | The records the file is attached to (e.g. an invoice or a candidate), each by its id and type | | tags | StorageFile_tags | | labels | | metadata | StorageMetadata[] | | | | raw | object | | | ### StorageFile_tags labels ### StorageMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### StoragePermission | Field | Type | Required | Description | |---|---|---|---| | user_id | string | | (reference to HrisEmployee) | | group_id | string | | (reference to HrisGroup) | | roles | StoragePermission_roles | Yes | | | is_hidden | boolean | | | | is_public | boolean | | | ### StoragePermission_roles ### StorageReference | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | type | string | | | | name | string | | | --- # Task API – Endpoints & Data Models ## Task API data models URL: https://docs.unified.to/task/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /task/{connection_id}/project | Create a project | | GET | /task/{connection_id}/project | List all projects | | GET | /task/{connection_id}/project/{id} | Retrieve a project | | PUT | /task/{connection_id}/project/{id} | Update a project | | DELETE | /task/{connection_id}/project/{id} | Remove a project | | POST | /task/{connection_id}/task | Create a task | | GET | /task/{connection_id}/task | List all tasks | | GET | /task/{connection_id}/task/{id} | Retrieve a task | | PUT | /task/{connection_id}/task/{id} | Update a task | | DELETE | /task/{connection_id}/task/{id} | Remove a task | | POST | /task/{connection_id}/comment | Create a comment | | GET | /task/{connection_id}/comment | List all comments | | GET | /task/{connection_id}/comment/{id} | Retrieve a comment | | PUT | /task/{connection_id}/comment/{id} | Update a comment | | DELETE | /task/{connection_id}/comment/{id} | Remove a comment | | GET | /task/{connection_id}/change/{id} | Retrieve a change | | GET | /task/{connection_id}/change | List all changes | #### Data Models ### TaskChange | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | task_id | string | | (reference to TaskTask) | | user_id | string | | (reference to HrisEmployee) | | items | TaskChangeItem[] | | | | metadata | TaskMetadata[] | | | | raw | object | | | ### TaskComment | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | text | string | | The comment's text | | user_id | string | | (reference to HrisEmployee) | | user_name | string | | | | task_id | string | | (reference to TaskTask) | | parent_id | string | | | | has_children | boolean | | | | metadata | TaskMetadata[] | | | | raw | object | | | ### TaskProject | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The project's name | | parent_id | string | | | | user_ids | TaskProject_user_ids | | (reference to HrisEmployee) | | group_ids | TaskProject_group_ids | | (reference to HrisGroup) | | description | string | | | | has_tasks | boolean | | If you can use this project ID to list tasks. Default is true. | | has_children | boolean | | If this project has children folders / lists | | metadata | TaskMetadata[] | | | | raw | object | | | ### TaskTask | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The task's title | | project_id | string | | (reference to TaskProject) | | parent_id | string | | | | completed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | status | string enum: OPENED, IN_PROGRESS, COMPLETED | | | | notes | string | | | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | start_at | string (date-time) | | Scheduled/planned start of the task (distinct from created_at) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | Scheduled/planned end of the task (distinct from due_at and completed_at) (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | priority | string | | | | assigned_user_ids | TaskTask_assigned_user_ids | | (reference to HrisEmployee) | | creator_user_id | string | | The user who created this task (reference to HrisEmployee) | | follower_user_ids | TaskTask_follower_user_ids | | (reference to HrisEmployee) | | group_ids | TaskTask_group_ids | | (reference to HrisGroup) | | tags | TaskTask_tags | | | | url | string | | | | attachment_ids | TaskTask_attachment_ids | | Array of attachment IDs retrieved from StorageFile.Get endpoint (reference to StorageFile) | | metadata | TaskMetadata[] | | | | has_children | boolean | | | | type | string | | The task/issue type name (e.g. Jira issue type). | | time_spent | number | | | | time_spent_unit | string | | | | progress | number | | 0-100 | | story_points | number | | | | raw | object | | | ### TaskChangeItem | Field | Type | Required | Description | |---|---|---|---| | field | string | Yes | unified field in ITaskTask | | from | string | | | | to | string | | | ### TaskMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The ID of the custom field definition (from the metadata/metadata endpoint). | | value | object | | The field's value, typed by `format`: DATE as ISO-8601 (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM:SSZ` for date-times); NUMBER, PRICE and CURRENCY as a number; BOOLEAN and YES_NO as true/false; MULTIPLE_SELECT as an array of option values; otherwise a string. | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | The data type of the custom field, which determines how `value` is formatted. | | extra_data | object | | | | slug | string | | The integration's API name (key) of an existing custom field. List valid slugs for this object with the metadata/metadata endpoint; unknown slugs are rejected by the integration. | ### TaskProject_group_ids ### TaskProject_user_ids ### TaskTask_assigned_user_ids ### TaskTask_attachment_ids Array of attachment IDs retrieved from StorageFile.Get endpoint ### TaskTask_follower_user_ids ### TaskTask_group_ids ### TaskTask_tags --- # Ticketing API – Endpoints & Data Models ## Ticketing API data models URL: https://docs.unified.to/ticketing/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /ticketing/{connection_id}/ticket | Create a ticket | | GET | /ticketing/{connection_id}/ticket | List all tickets | | GET | /ticketing/{connection_id}/ticket/{id} | Retrieve a ticket | | PUT | /ticketing/{connection_id}/ticket/{id} | Update a ticket | | DELETE | /ticketing/{connection_id}/ticket/{id} | Remove a ticket | | POST | /ticketing/{connection_id}/customer | Create a customer | | GET | /ticketing/{connection_id}/customer | List all customers | | GET | /ticketing/{connection_id}/customer/{id} | Retrieve a customer | | PUT | /ticketing/{connection_id}/customer/{id} | Update a customer | | DELETE | /ticketing/{connection_id}/customer/{id} | Remove a customer | | POST | /ticketing/{connection_id}/note | Create a note | | GET | /ticketing/{connection_id}/note | List all notes | | GET | /ticketing/{connection_id}/note/{id} | Retrieve a note | | PUT | /ticketing/{connection_id}/note/{id} | Update a note | | DELETE | /ticketing/{connection_id}/note/{id} | Remove a note | | POST | /ticketing/{connection_id}/category | Create a category | | GET | /ticketing/{connection_id}/category | List all categories | | GET | /ticketing/{connection_id}/category/{id} | Retrieve a category | | PUT | /ticketing/{connection_id}/category/{id} | Update a category | | DELETE | /ticketing/{connection_id}/category/{id} | Remove a category | #### Data Models ### TicketingCategory | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | is_active | boolean | | | | parent_id | string | | | | metadata | TicketingMetadata[] | | | | raw | object | | | ### TicketingCustomer | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | emails | TicketingEmail[] | | | | telephones | TicketingTelephone[] | | | | tags | TicketingCustomer_tags | | | | metadata | TicketingMetadata[] | | | | raw | object | | | ### TicketingNote | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | customer_id | string | | | | description | string | | The note's text | | ticket_id | string | | The ID of the ticket the note is added to (a TicketingTicket ID) | | user_id | string | | (reference to HrisEmployee) | | metadata | TicketingMetadata[] | | | | raw | object | | | ### TicketingTicket | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | customer_id | string | | | | subject | string | | short description of the ticket | | description | string | | Full description of ticket issue | | status | string enum: ACTIVE, CLOSED | | | | closed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | priority | string | | | | category_id | string | | (reference to AccountingCategory) | | source | string | | Channel where ticket was originally submitted | | source_ref | string | | Reference to source-specific object | | tags | TicketingTicket_tags | | | | user_id | string | | (reference to HrisEmployee) | | url | string | | | | attachment_ids | TicketingTicket_attachment_ids | | Array of attachment IDs retrieved from StorageFile.Get endpoint (reference to StorageFile) | | due_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | metadata | TicketingMetadata[] | | | | raw | object | | | ### TicketingCustomer_tags ### TicketingEmail | Field | Type | Required | Description | |---|---|---|---| | email | string | Yes | | | type | string enum: WORK, HOME, OTHER | | | ### TicketingMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | The ID of the custom field definition (from the metadata/metadata endpoint). | | slug | string | | The integration's API name (key) of an existing custom field. List valid slugs for this object with the metadata/metadata endpoint; unknown slugs are rejected by the integration. | | value | object | | The field's value, typed by `format`: DATE as ISO-8601 (`YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM:SSZ` for date-times); NUMBER, PRICE and CURRENCY as a number; BOOLEAN and YES_NO as true/false; MULTIPLE_SELECT as an array of option values; otherwise a string. | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | The data type of the custom field, which determines how `value` is formatted. | | extra_data | object | | | ### TicketingTelephone | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | | | type | string enum: WORK, HOME, OTHER, FAX, MOBILE | | | ### TicketingTicket_attachment_ids Array of attachment IDs retrieved from StorageFile.Get endpoint ### TicketingTicket_tags --- # Uc API – Endpoints & Data Models ## Uc API data models URL: https://docs.unified.to/uc/overview #### Endpoints | Method | Path | Description | |---|---|---| | POST | /uc/{connection_id}/call | Create a call | | GET | /uc/{connection_id}/call | List all calls | | GET | /uc/{connection_id}/call/{id} | Retrieve a call | | PUT | /uc/{connection_id}/call/{id} | Update a call | | POST | /uc/{connection_id}/contact | Create a contact | | GET | /uc/{connection_id}/contact | List all contacts | | GET | /uc/{connection_id}/contact/{id} | Retrieve a contact | | PUT | /uc/{connection_id}/contact/{id} | Update a contact | | DELETE | /uc/{connection_id}/contact/{id} | Remove a contact | | POST | /uc/{connection_id}/comment | Create a comment | | GET | /uc/{connection_id}/comment | List all comments | | GET | /uc/{connection_id}/comment/{id} | Retrieve a comment | | PUT | /uc/{connection_id}/comment/{id} | Update a comment | | DELETE | /uc/{connection_id}/comment/{id} | Remove a comment | | POST | /uc/{connection_id}/recording | Create a recording | | GET | /uc/{connection_id}/recording | List all recordings | | GET | /uc/{connection_id}/recording/{id} | Retrieve a recording | | PUT | /uc/{connection_id}/recording/{id} | Update a recording | | DELETE | /uc/{connection_id}/recording/{id} | Remove a recording | | GET | /uc/{connection_id}/scorecard/{id} | Retrieve a scorecard | | GET | /uc/{connection_id}/scorecard | List all scorecards | | GET | /uc/{connection_id}/scorecardtemplate/{id} | Retrieve a scorecardtemplate | | GET | /uc/{connection_id}/scorecardtemplate | List all scorecardtemplates | #### Data Models ### UcCall | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this call object | | created_at | string (date-time) | | The date that this call object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this call object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | contact_id | string | | The contact ID for call (reference to UcContact) | | telephone | UcCall_telephone | | The telephone number called | | start_at | string (date-time) | | The start time of the call (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | The end time of the call (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | user_id | string | | (reference to HrisEmployee) | | contacts | UcContact[] | | | | is_private | boolean | | | | user_name | string | | | | user_phone | string | | | | type | string enum: INBOUND, OUTBOUND | | | | metadata | UcMetadata[] | | | | raw | object | | The raw data returned by the integration for this call | | conversation_identifier | string | | opaque identifier shared across legs of one interaction | | status | string enum: QUEUED, RINGING, IN_PROGRESS, COMPLETED, BUSY, NO_ANSWER, FAILED, CANCELED, OTHER | | The current or final status of the call | | answer_at | string (date-time) | | When the call was answered (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | duration_seconds | number | | Call length in seconds | | cost_amount | number | | What the integration charged for the call | | currency | string | | ISO 4217 currency of cost_amount | | parent_id | string | | The parent call (leg) that this call belongs to | | instructions | UcCallInstruction[] | | Write-only: what the call does once answered (create/update only; never returned) | ### UcComment | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | content | string | | | | user_id | string | | reference to HrisEmployee (reference to HrisEmployee) | | call_id | string | | | | metadata | UcMetadata[] | | | | raw | object | | | ### UcContact A contact represents a person that optionally is associated with a call | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this contact object | | created_at | string (date-time) | | The date that this contact object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | The last date that this contact object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | The name of the contact | | first_name | string | | | | last_name | string | | | | title | string | | The job title of the contact | | company | string | | The company/organization name of the contact | | emails | UcEmail[] | | An array of email addresses for this contact | | telephones | UcTelephone[] | | An array of telephones for this contact | | metadata | UcMetadata[] | | | | raw | object | | The raw data returned by the integration for this contact | ### UcRecording | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | expires_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | call_id | string | | | | web_url | string | | | | contacts | UcContact[] | | | | user_id | string | | reference to HrisEmployee (reference to HrisEmployee) | | media | UcRecordingMedia[] | | | | user_name | string | | | | user_phone | string | | | | type | string enum: INBOUND, OUTBOUND | | | | conversation_identifier | string | | opaque identifier shared across legs of one interaction | | metadata | UcMetadata[] | | | | raw | object | | | ### UcScorecard | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | call_id | string | | | | scorecardtemplate_id | string | | | | name | string | | name of the scorecard template that was filled in | | user_id | string | | the user being reviewed; reference to HrisEmployee (reference to HrisEmployee) | | reviewer_user_id | string | | the user who filled in the scorecard; reference to HrisEmployee (reference to HrisEmployee) | | reviewed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | score | number | | overall score | | questions | UcScorecardQuestion[] | | | | metadata | UcMetadata[] | | | | raw | object | | | ### UcScorecardtemplate | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | | | | description | string | | | | is_active | boolean | | | | questions | UcScorecardtemplateQuestion[] | | | | metadata | UcMetadata[] | | | | raw | object | | | ### UcCallInstruction | Field | Type | Required | Description | |---|---|---|---| | type | string enum: SAY, PLAY, PAUSE, DIAL, HANGUP | Yes | | | text | string | | SAY: text to speak | | language | string | | SAY: BCP 47 language code (e.g. en-US), passed to the integration as-is | | audio_url | string | | PLAY: public MP3 or WAV URL | | seconds | number | | PAUSE: length of silence | | telephone | UcCallInstruction_telephone | | DIAL: number to dial and bridge | | sip_uri | string | | DIAL: SIP address to dial instead of telephone | | user_phone | string | | DIAL: caller ID on the dialed leg (defaults to the call's user_phone) | | timeout_seconds | number | | DIAL: how long to ring before giving up | ### UcCallInstruction_telephone DIAL: number to dial and bridge | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | | | type | string enum: WORK, HOME, OTHER, FAX, MOBILE | | | ### UcCall_telephone The telephone number called | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | | | type | string enum: WORK, HOME, OTHER, FAX, MOBILE | | | ### UcEmail | Field | Type | Required | Description | |---|---|---|---| | email | string | Yes | | | type | string enum: WORK, HOME, OTHER | | | ### UcMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### UcRecordingMedia | Field | Type | Required | Description | |---|---|---|---| | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | language | string | | two digit ISO code | | transcripts | UcRecordingTranscript[] | | | | transcript_download_url | string | | | | recording_download_url | string | | | ### UcRecordingTranscript | Field | Type | Required | Description | |---|---|---|---| | start_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | end_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | text | string | Yes | | | contact_id | string | | (reference to UcContact) | | user_id | string | | reference to HrisEmployee (reference to HrisEmployee) | ### UcScorecardQuestion | Field | Type | Required | Description | |---|---|---|---| | question_identifier | string | | | | text | string | | | | answer | string | | | | score | number | | | | is_not_applicable | boolean | | | ### UcScorecardtemplateQuestion | Field | Type | Required | Description | |---|---|---|---| | question_identifier | string | | | | text | string | | | | type | string enum: RATING, YES_NO, TEXT, OTHER | | | | options | UcScorecardtemplateQuestion_options | | | ### UcScorecardtemplateQuestion_options ### UcTelephone | Field | Type | Required | Description | |---|---|---|---| | telephone | string | Yes | | | type | string enum: WORK, HOME, OTHER, FAX, MOBILE | | | --- # Unified API – Endpoints & Data Models ## Unified API data models URL: https://docs.unified.to/unified/overview #### Endpoints | Method | Path | Description | |---|---|---| | GET | /unified/notification | List event notifications | | POST | /unified/connection | Create connection | | GET | /unified/connection | List all connections | | GET | /unified/connection/{id} | Retrieve connection | | PUT | /unified/connection/{id} | Update connection | | DELETE | /unified/connection/{id} | Remove connection | | POST | /unified/connection/{id}/reauth | Create a re-authorization token for a connection | | GET | /unified/secretsmanager/instructions | List secrets manager setup instructions | | GET | /unified/secretsmanager/secrets | List secrets in a secrets manager | | POST | /unified/secretsmanager | Create secrets manager | | GET | /unified/secretsmanager | List secrets managers | | GET | /unified/secretsmanager/{id} | Retrieve secrets manager | | DELETE | /unified/secretsmanager/{id} | Remove secrets manager | | GET | /unified/issue/{id} | Retrieve support issue | | GET | /unified/issue | List support issues | | GET | /unified/integration | Returns all integrations | | GET | /unified/integration/workspace/{workspace_id} | Returns all activated integrations in a workspace | | GET | /unified/webhook/{id} | Retrieve webhook by its ID | | PUT | /unified/webhook/{id} | Update webhook subscription | | DELETE | /unified/webhook/{id} | Remove webhook subscription | | GET | /unified/webhook | Returns all registered webhooks | | POST | /unified/webhook | Create webhook subscription | | PUT | /unified/webhook/{id}/trigger | Trigger webhook | | GET | /unified/apicall/{id} | Retrieve specific API Call by its ID | | GET | /unified/apicall | Returns API Calls | | GET | /unified/environment | Returns all environments | | POST | /unified/environment | Create new environments | | DELETE | /unified/environment/{env} | Remove an environment | | GET | /unified/integration/auth/{workspace_id}/{integration_type} | Authorize or re-authorize a connection | #### Data Models ### ApiCall | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this API call (read-only) | | created_at | string (date-time) | | The date that this object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) (read-only) | | connection_id | string | | | | workspace_id | string | | (reference to KmsSpace) (read-only) | | integration_type | string | Yes | The integration type | | external_xref | string | | your customer's user ID | | name | string | Yes | The called name of the API method | | path | string | Yes | The called API method's HTTP verb and route path (PUT /crm/{integration}/deak/{id}) | | size | number | | The size of the response | | status | string | Yes | The resulting HTTP status code (200) | | error | string | | The error description (if status code is >= 400) | | ip_address | string | | | | type | string enum: login, webhook, inbound, mcp | Yes | The type of API Call being logged | | method | string | Yes | | | environment | string | | | | webhook_id | string | | | | is_billable | boolean | | | | user_agent | string | | | | unified_response_time | number | | | | endapi_response_time | number | | | ### Connection A connection represents a specific authentication of an integration. | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for this integration object (read-only) | | created_at | string (date-time) | | The date that this integration object was created (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) (read-only) | | updated_at | string (date-time) | | The last date that this integration object was updated (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) (read-only) | | workspace_id | string | | (reference to KmsSpace) (read-only) | | integration_type | string | Yes | The integration type | | integration_name | string | | | | external_xref | string | | customer's user ID | | permissions | Connection_permissions | Yes | | | categories | Connection_categories | Yes | The Integration categories that this connection supports | | auth | Connection_auth | | An authentication object that represents a specific authorized user's connection to an integration. | | is_paused | boolean | | Whether this integration has exceed the monthly limit of the plan (read-only) | | environment | string | | | | last_healthy_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) (read-only) | | last_unhealthy_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) (read-only) | | last_unhealthy_code | string | | The HTTP status code that caused the last unhealthy status | | secretsmanager_id | string | | the ID of the SecretsManager object | | secretsmanager_key | string | | the key/path/name of the secret within the vault | ### ConnectionReauth | Field | Type | Required | Description | |---|---|---|---| | reauth_token | string | | Pass as reauth_token (together with connection_id) on the "Authorize or re-authorize a connection" URL | | expires_at | string | | The token is single-use and expires at this time (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | ### Environments_List A list of environments in the workspace. ### Issue | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | title | string | Yes | | | status | string enum: COMPLETED, NEW, ROADMAP, IN_PROGRESS, ON_HOLD, VALIDATING, REJECTED | Yes | | | url | string | | | | workspace_id | string | Yes | (reference to KmsSpace) | | type | Issue_type | | | | resolution_time | number | | | | ticket_ref | string | Yes | | | size | number | | 1-5, 1 is lowest | | importance | number | | 1-5, 1 is lowest | | customer_note | string | | | ### SecretsManager | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | type | string enum: aws, azure, gcp, hashicorp, composio, 1password, bitwarden, doppler, akeyless, pipedream, alibabacloud | Yes | | | name | string | Yes | | | workspace_id | string | | (reference to KmsSpace) | | auth | SecretsManager_auth | Yes | secrets-manager specific authentication values | | dcs | SecretsManager_dcs | | data-regions | ### Webhook A webhook is used to POST new, updated, or deleted records to your server. | Field | Type | Required | Description | |---|---|---|---| | id | string | | (read-only) | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) (read-only) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) (read-only) | | workspace_id | string | | (reference to KmsSpace) (read-only) | | connection_id | string | Yes | | | hook_url | string | | The URL of the webhook | | object_type | string enum: accounting_account, accounting_transaction, accounting_journal, accounting_contact, accounting_invoice, accounting_bill, accounting_vendorcredit, accounting_creditmemo, accounting_taxrate, accounting_paymentterm, accounting_organization, accounting_order, accounting_salesorder, accounting_salesreceipt, accounting_purchaseorder, accounting_balancesheet, accounting_profitloss, accounting_trialbalance, accounting_category, accounting_expense, accounting_expensereport, accounting_purchasereceipt, accounting_cashflow, accounting_quote, accounting_agedreceivable, accounting_agedpayable, accounting_project, accounting_bankfeedaccount, accounting_bankfeedtransaction, payment_payment, payment_link, payment_payout, payment_refund, payment_subscription, commerce_item, commerce_collection, commerce_inventory, commerce_location, commerce_review, commerce_saleschannel, commerce_itemvariant, commerce_reservation, commerce_availability, verification_package, verification_request, assessment_package, assessment_order, ats_activity, ats_application, ats_applicationstatus, ats_candidate, ats_document, ats_interview, ats_job, ats_scorecard, ats_company, crm_company, crm_contact, crm_deal, crm_event, crm_lead, crm_pipeline, crm_taxonomy, hris_employee, hris_group, hris_payslip, hris_timeoff, hris_company, hris_location, hris_device, hris_timeshift, hris_attendance, hris_deduction, hris_benefit, hris_bankaccount, hris_document, hris_taxonomy, hris_job, hris_payroll, hris_paycode, hris_earning, martech_list, martech_member, martech_campaign, martech_report, passthrough, ticketing_note, ticketing_ticket, ticketing_customer, ticketing_category, uc_contact, uc_call, uc_comment, uc_recording, uc_scorecard, uc_scorecardtemplate, enrich_person, enrich_company, storage_file, genai_model, genai_prompt, genai_embedding, genai_task, genai_agent, genai_organization, genai_skill, genai_memory, messaging_message, messaging_channel, messaging_event, kms_space, kms_page, kms_comment, task_project, task_task, task_comment, task_change, scim_users, scim_groups, lms_course, lms_class, lms_student, lms_instructor, lms_content, lms_collection, lms_activity, repo_organization, repo_repository, repo_branch, repo_commit, repo_pullrequest, metadata_metadata, calendar_calendar, calendar_event, calendar_busy, calendar_link, calendar_recording, calendar_webinar, ads_organization, ads_ad, ads_campaign, ads_report, ads_group, ads_creative, ads_asset, ads_insertionorder, ads_target, ads_promoted, analytics_property, analytics_event, analytics_session, analytics_visitor, analytics_report, forms_form, forms_submission, shipping_carrier, shipping_rate, shipping_shipment, shipping_label, shipping_tracking, signing_document, signing_signatory, signing_template, clubs_group, clubs_member, clubs_activity, clubs_location, clubs_event, datastore_database, datastore_table, datastore_record, datastore_query, cdp_profile, cdp_segment, cdp_event, cdp_source, cdp_destination, cdp_activation, performance_cycle, performance_review, performance_goal, performance_feedback, social_post, social_profile, social_review, social_insight, legal_matter, legal_practicearea, legal_timeentry, legal_expense, compute_instance, compute_template, compute_gputype, compute_region, compute_volume, compute_snapshot, compute_usage, compute_key, compute_service, compute_deployment, compute_domain, compute_envvar, healthcare_patient, healthcare_practitioner, healthcare_location, healthcare_appointment, healthcare_slot, healthcare_encounter, healthcare_condition, healthcare_allergy, healthcare_medication, healthcare_observation, healthcare_immunization | Yes | The object to return (eg. CRM "contact") | | interval | number | | Virtual webhooks only. How often (in minutes) Unified.to checks for new or updated records. Minimum 1 on paid plans, 60 on free. Ignored for native webhooks. | | checked_at | string (date-time) | | Read-only. The time Unified.to last recorded a check on this webhook. Behaviour varies by webhook implementation, so don't use this field to tell whether a webhook is still running. Use is_healthy, and subscribe to WEBHOOK_UNHEALTHY on the notifications webhook, to monitor health. (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) (read-only) | | integration_type | string | | (read-only) | | environment | string | | (read-only) | | event | string enum: updated, created, deleted | Yes | The event to subscribe to. "deleted" is supported on native webhooks, and on virtual webhooks where the integration supports it. Check the integration's Feature Support tab. | | runs | Webhook_runs | | An array of the most recent virtual webhook runs (read-only) | | fields | string | | | | webhook_type | string enum: virtual, native | | "native" registers with the integration, which sends events as they happen. "virtual" checks for changes on "interval". Optional on create; if omitted, Unified.to picks a supported type for the object. Requesting a type the integration doesn't support returns 400. Set on create only: changes sent on update are ignored without an error. Delete and recreate the webhook to change type. | | meta | object | | (read-only) | | is_healthy | boolean | | Read-only. True while the webhook can operate or recover; a transient failure may still be retrying. False after retries are exhausted or a non-retryable failure such as broken connection auth. Cannot be reset; recreate the webhook. (read-only) | | page_max_limit | number | | | | filters | Webhook_filters | | Supported on virtual webhooks, and on native webhooks for many integrations. Check the integration's Feature Support tab for which filters apply. Filters the integration doesn't support are accepted but have no effect. Some integrations require a filter; if it's missing, create returns an error naming the integration and the filter. | | db_type | string enum: mongodb, mysql, postgres, mssql, mariadb, supabase, snowflake | | | | db_url | string | | | | db_schema | string | | | | db_name_prefix | string | | | | is_paused | boolean | | | | is_beta | boolean | | | ### Connection_auth An authentication object that represents a specific authorized user's connection to an integration. | Field | Type | Required | Description | |---|---|---|---| | token | string | | | | access_token | string | | | | refresh_token | string | | | | expiry_date | string (date-time) | | | | expires_in | number | | | | emails | Connection_auth_emails | | | | name | string | | | | user_id | string | | (reference to HrisEmployee) | | app_id | string | | | | client_id | string | | | | client_secret | string | | | | consumer_key | string | | | | consumer_secret | string | | | | meta | object | | (read-only) | | state | string | | | | other_auth_info | Connection_auth_other_auth_info | | When integration.auth_type = "other", this field contains the authentication credentials in the same order as token_names | | api_url | string | | | | authorize_url | string | | | | token_url | string | | | | refresh_url | string | | | | pem | string | | the PEM X.509 certificate in Base64 ASCII format | | key | string | | the private KEY X.509 certificate in Base64 ASCII format | | refresh_token_expires_in | number | | | | refresh_token_expires_date | string (date-time) | | | | dev_api_key | string | | | | audience | string | | | ### Connection_auth_emails ### Connection_auth_other_auth_info When integration.auth_type = "other", this field contains the authentication credentials in the same order as token_names ### Connection_categories The Integration categories that this connection supports ### Connection_permissions ### Issue_type ### SecretsManager_auth secrets-manager specific authentication values ### SecretsManager_dcs data-regions ### Webhook_filters Supported on virtual webhooks, and on native webhooks for many integrations. Check the integration's Feature Support tab for which filters apply. Filters the integration doesn't support are accepted but have no effect. Some integrations require a filter; if it's missing, create returns an error naming the integration and the filter. ### Webhook_runs An array of the most recent virtual webhook runs --- # Verification API – Endpoints & Data Models ## Verification API data models URL: https://docs.unified.to/verification/overview #### Endpoints | Method | Path | Description | |---|---|---| | GET | /verification/{connection_id}/package/{id} | Retrieve a package | | GET | /verification/{connection_id}/package | List all packages | | POST | /verification/{connection_id}/request | Create a request | | GET | /verification/{connection_id}/request | List all requests | | GET | /verification/{connection_id}/request/{id} | Retrieve a request | | PUT | /verification/{connection_id}/request/{id} | Update a request | | DELETE | /verification/{connection_id}/request/{id} | Remove a request | #### Data Models ### VerificationPackage | Field | Type | Required | Description | |---|---|---|---| | id | string | | Unique identifier for the verification type | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | name | string | Yes | Name of the verification type | | type | string enum: IDENTITY_VERIFICATION, SCREENING, BACKGROUND_CHECK, EMPLOYMENT_VERIFICATION, EDUCATION_VERIFICATION, CREDIT_CHECK, FRAUD_PREVENTION, OTHER | Yes | | | aliases | VerificationPackage_aliases | | | | tags | VerificationPackage_tags | | Category (Verification, Validation, Background Check) | | description | string | | Detailed description | | parameters | VerificationParameter[] | | Questions that need to be answered for this verification | | average_processing_times | VerificationTime[] | | average processing time in minutes | | has_redirect_url | boolean | | | | has_target_url | boolean | | where the provider will redirect the user to after the verification | | needs_ip_address | boolean | | | | cost_amount | number | | Cost-related information | | currency | string | | | | max_score | number | | if this verification returns a score, what is the maximum? | | info_url | string | | where to find additional information | | valid_regions | VerificationPackage_valid_regions | | {country}-{state/province/territory} or just {country} 2-digit ISO codes | | metadata | VerificationMetadata[] | | | | raw | object | | | ### VerificationRequest | Field | Type | Required | Description | |---|---|---|---| | id | string | | (read-only) | | created_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) (read-only) | | updated_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) (read-only) | | package_id | string | | The ID of the verification package (the check) to run (a VerificationPackage ID) | | parameters | VerificationParameterInput[] | | | | target_url | string | | | | candidate_id | string | | points to ATS Candidate | | profile_ip_address | string | | XXX.XXX.XXX.XXX | | profile_name | string | | The full name of the person being verified | | profile_date_of_birth | string | | YYYY-MM-DD | | profile_addresses | VerificationAddress[] | | | | profile_gender | string enum: MALE, FEMALE, INTERSEX, TRANS, NON_BINARY | | | | profile_emails | VerificationRequest_profile_emails | | The email addresses of the person being verified | | profile_telephones | VerificationRequest_profile_telephones | | | | profile_national_identifier | string | | | | response_completed_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | response_expires_at | string (date-time) | | (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | response_issued_at | string (date-time) | | datetime that identify was issued (ISO-8601 / YYYY-MM-DDTHH:MM:SSZ format) | | response_status | string enum: COMPLETED, FAILED, PASSED, PENDING | | Background checks and compliance checks return CLEARFLAGGED or PASS/FAIL or YES/NO | | response_score | number | | Identity verification services provide confidence scores (e.g., 0-100), Credit checks return specific numeric scores (e.g., FICO score 300-850), Fraud prevention tools use risk scores (e.g., 1-999) | | response_redirect_url | string | | Most modern IDV providers (like Onfido, Veriff, Jumio, ID.me). Background Check Providers, such as Checkr, GoodHire, Sterling, and HireRight. Credit bureaus and services often require direct user consent and information. This allows them to properly handle disclosures required by regulations like FCRA. Employment and Education Verification need users to confirm previous employers and schools. | | response_download_urls | VerificationRequest_response_download_urls | | report download | | response_details | VerificationResponseDetail[] | | | | response_source | string | | | | metadata | VerificationMetadata[] | | | | raw | object | | | ### VerificationAddress | Field | Type | Required | Description | |---|---|---|---| | address1 | string | | | | address2 | string | | | | city | string | | | | region | string | | Regional area of the employee's address. For example, in the U.S., the region is the employee's state; in Canada, the region is the employee’s province. | | region_code | string | | Short form for the regional area of the employee's address. For example, in the U.S., the region code is the two-letter abbreviation for the employee’s state; in Canada, the region is the two-letter abbreviation for the employee's province. | | postal_code | string | | | | country | string | | | | country_code | string | | Country code for the country, in ISO 3166 A-2 format | ### VerificationMetadata | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | slug | string | | | | value | object | | | | namespace | string | | | | format | string enum: TEXT, NUMBER, DATE, BOOLEAN, FILE, TEXTAREA, SINGLE_SELECT, MULTIPLE_SELECT, MEASUREMENT, PRICE, YES_NO, CURRENCY, URL | | | | extra_data | object | | | ### VerificationPackage_aliases ### VerificationPackage_tags Category (Verification, Validation, Background Check) ### VerificationPackage_valid_regions {country}-{state/province/territory} or just {country} 2-digit ISO codes ### VerificationParameter | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | Yes | | | public_question | string | | question to ask the human | | type | string enum: TEXT, NUMBER, MULTIPLE_CHOICE, MULTIPLE_SELECT, DATE, FILE | | | | options | VerificationParameter_options | | options for MULTIPLE_CHOICE and MULTIPLE_SELECT | | file_types | VerificationParameter_file_types | | valid file mime types | | valid_regions | VerificationParameter_valid_regions | | {country}-{stateprovince/territory} or just {country} 2-digit ISO codes | | is_required | boolean | | | ### VerificationParameterInput | Field | Type | Required | Description | |---|---|---|---| | id | string | | | | name | string | | name of parameter | | inputs | VerificationParameterInput_inputs | | | ### VerificationParameterInput_inputs ### VerificationParameter_file_types valid file mime types ### VerificationParameter_options options for MULTIPLE_CHOICE and MULTIPLE_SELECT ### VerificationParameter_valid_regions {country}-{stateprovince/territory} or just {country} 2-digit ISO codes ### VerificationRequest_profile_emails The email addresses of the person being verified ### VerificationRequest_profile_telephones ### VerificationRequest_response_download_urls report download ### VerificationResponseDetail | Field | Type | Required | Description | |---|---|---|---| | title | string | | | | text | string | | | | is_private | boolean | | | | is_failed_reason | boolean | | | | parameter_id | string | | in reference to the parameter input | | download_url | string | | | ### VerificationTime | Field | Type | Required | Description | |---|---|---|---| | milliseconds | number | | | | valid_regions | VerificationTime_valid_regions | | {country}-{stateprovince/territory} or just {country} 2-digit ISO codes | ### VerificationTime_valid_regions {country}-{stateprovince/territory} or just {country} 2-digit ISO codes