Skip to main content

OAuth Clients

Let third-party applications access iClosed workspace on behalf of a user without sharing that user's API key

OAuth clients let third-party apps connect to your account securely using the OAuth 2.0 flow.

OAuth access tokens use the same scope model as API keys (for example contacts:read, deals:write). Each API request made with an OAuth access token runs in the context of the workspace the user authorized.

Detail

Value

Protocol

OAuth 2.0 (authorization code + refresh token)

User consent URL

https://app.iclosed.io/oauth/authorize

Token endpoint

POST https://public.api.iclosed.io/v1/oauth/token

Client ID format

ocl_…

Access token lifetime

1 hour

Refresh token lifetime

30 days (rotated on each refresh)

PKCE

Required for public clients; optional for confidential clients

Important Notes

Every OAuth request needs to be approved before use.

Webhooks can’t currently be subscribed via OAuth. Set them up from Settings > Developer > Webhooks instead.

OAuth clients are available on every iClosed plan, and a workspace can register up to 20 clients.

Super Admins can see Developer tools by default.


Where to find OAuth Clients

OAuth clients are managed from the Developer area in your iClosed account settings — the same place as Webhooks and API Keys.

  1. Log in at app.iclosed.io.

  2. Open Settings → Developer.

  3. In the left sidebar, click OAuth Clients.

OAuth Clients page in Settings → Developer with an empty state and a Register client button.

From this page you can:

  • Register client — create a new OAuth application.

  • View registered clients, their scopes, and approval status.

  • Edit or delete clients you own (via the row menu).


Registering an OAuth client

Add App details:

Field

Required

What to enter

Name

Yes

Display name shown on the consent screen (for example Get Cast).

App Homepage URL

No

Link to your app's website. Shown on the consent screen so users know who is requesting access.

Logo

No

Square image (JPG, PNG, WebP, or SVG; max 2 MB, 512×512 px). Shown on the consent screen.

Redirect URIs

Yes

One or more callback URLs. Must be exact HTTPS URLs (or http://localhost for local development). Add multiple URIs with + Add redirect URI if you have staging and production environments.

Register OAuth client form showing name, homepage URL, logo upload, redirect URIs, and grant types.

Field

Required

What to enter

Name

Yes

Display name shown on the consent screen (for example Get Cast).

App Homepage URL

No

Link to your app's website. Shown on the consent screen so users know who is requesting access.

Logo

No

Square image (JPG, PNG, WebP, or SVG; max 2 MB, 512×512 px). Shown on the consent screen.

Redirect URIs

Yes

One or more callback URLs. Must be exact HTTPS URLs (or http://localhost for local development). Add multiple URIs with + Add redirect URI if you have staging and production environments.

Pro Tips

  • The URI you send in the authorize request must match one of the registered URIs character for character (including trailing slashes).

  • Use HTTPS in production. Localhost is allowed for development only.


Grant Types & Scopes

Grant types

Both grant types are typically enabled:

Grant type

Purpose

Authorization code

Standard browser-based OAuth flow. Users sign in and approve access; your app receives a short-lived code to exchange for tokens.

Refresh token

Lets your app obtain new access tokens without asking the user to sign in again.


Allowed scopes

Scopes define the maximum permissions your app can request. When a user authorizes your app, they grant a subset of these scopes.

  • Add scopes from the suggestion chips, or type a scope and press Enter.

  • Only request scopes your integration actually needs — users see grouped permissions on the consent screen.

  • Scope names follow the same pattern as API keys (for example contacts:read, contacts:write, deals:read, events:write).

See the API Reference for the full list of available scopes on each endpoint.

Register OAuth client form showing grant types, allowed scopes chips, and public vs confidential client type.

Client type

Choose the client type based on where your app runs:

Type

When to use

Secret

PKCE

Public

Browser apps, mobile apps, SPAs — anything that cannot store a secret securely

No client secret is issued

Required

Confidential

Server-side apps that can store credentials in a vault or environment variable

A client secret is generated once at registration — save it immediately

Optional (still supported)

Choose Public if your code runs entirely in the user's browser or on a device you do not fully control.

Choose Confidential if token exchange happens on your backend and you can store client_secret securely.

Click Register client when the form is complete.


Post-Registration Process

After registration

On success, iClosed shows your Client ID and next-step guidance.

Save your Client ID — you need it to start the OAuth flow. If you registered a confidential client, also copy the client secret now; it is shown only once.

Client registered success dialog showing Client ID, pending approval notice, and PKCE instructions for public clients.

Pending approval

New clients registered through the Developer portal start in Pending approval status.

While pending:

  • Only users in the workspace that registered the client can complete the OAuth flow and connect the app.

  • Users in other iClosed accounts cannot authorize the app — they will see an access-denied message on the consent screen or receive a 403 access_denied error.

This lets you build and test safely before the app is available to all iClosed customers.

To make your app available to users in any iClosed account, contact our support team via in-app chat button and request approval for your OAuth client.

After iClosed approves the client (status moves to Approved or Active), any authenticated iClosed user can authorize it for their workspace.

OAuth client list showing a pending client with dev/testing restriction message.

Status

Who can authorize

Pending

Users in the registering workspace only

Approved / Active

Any iClosed user (subject to consent)


How the OAuth flow works

The iClosed OAuth flow follows the standard authorization code pattern with optional PKCE.


Step 1 - Send the user to the consent screen

Redirect the user's browser to: https://app.iclosed.io/oauth/authorize


Include these query parameters:

Parameter

Required

Description

response_type

Yes

Must be code.

client_id

Yes

Your ocl_… client ID.

redirect_uri

Yes

One of your registered redirect URIs.

scope

No

Space-delimited scopes to request. Must be a subset of your client's allowed scopes. If omitted, all allowed scopes are requested.

state

Recommended

Opaque value returned unchanged on redirect — use it to prevent CSRF.

code_challenge

Yes for public clients

PKCE challenge (see below).

code_challenge_method

Yes when challenge is sent

Must be S256.

Example (public client with PKCE):

https://app.iclosed.io/oauth/authorize
?response_type=code
&client_id=ocl_rG0xAZ2yRda3Yhhr8v3wf-rOEdhuZdq6
&redirect_uri=https://yourapp.example.com/oauth/callback
&scope=contacts:read%20contacts:write
&state=random-state-value
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256

Step 2 - User signs in and approves access

If the user is not signed in, iClosed shows the login screen first. After authentication, the consent screen lists the app name, logo, and requested permissions.

OAuth consent screen showing pending approval banner and Contacts read/write permissions.

For pending clients, a yellow banner explains that only users in the registering workspace can connect for now.

The user can Allow access or Deny. On allow, iClosed shows a brief confirmation and redirects back to your redirect_uri.

Access granted confirmation showing connected workspace, app name, scopes, and token expiry.

Step 3 - Receive the authorization code

iClosed redirects the browser to your redirect_uri with:

https://yourapp.example.com/oauth/callback
?code=AUTHORIZATION_CODE
&state=random-state-value
  • Verify state matches what you sent.

  • The authorization code is short-lived (10 minutes) and single-use.

  • If the user denied access, the redirect includes error=access_denied instead of code.


Step 4 - Exchange the code for tokens

Send a server-side POST request to the token endpoint:

POST https://public.api.iclosed.io/v1/oauth/token
Content-Type: application/json

Authorization code grant (public client):

{
"grant_type": "authorization_code",
"code": "AUTHORIZATION_CODE_FROM_CALLBACK",
"redirect_uri": "https://yourapp.example.com/oauth/callback",
"client_id": "ocl_rG0xAZ2yRda3Yhhr8v3wf-rOEdhuZdq6",
"code_verifier": "YOUR_PKCE_CODE_VERIFIER"
}

Authorization code grant (confidential client):

Authenticate with HTTP Basic auth (client_id:client_secret) or include credentials in the JSON body:

{
"grant_type": "authorization_code",
"code": "AUTHORIZATION_CODE_FROM_CALLBACK",
"redirect_uri": "https://yourapp.example.com/oauth/callback",
"client_id": "ocl_…",
"client_secret": "YOUR_CLIENT_SECRET"
}

If you used PKCE on /authorize, include code_verifier even for confidential clients.

Successful response:

{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "kN9b-4wFQxQXoXr3Jra6D2xSAn8YvXSVF0yP9fvQX1A",
"scope": "contacts:read contacts:write"
}

Store the refresh token securely. Access tokens expire after 1 hour.


Step 5 - Call the API

Use the access token like an API key:

Authorization: Bearer <access_token>

Example:

curl "https://public.api.iclosed.io/v1/contacts/detail?email=jane@example.com" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json"

The token is scoped to the workspace the user selected during authorization and limited to the granted scopes.


Step 6 - Refresh expired access tokens

When expires_in has passed, exchange the refresh token for a new pair:

{
"grant_type": "refresh_token",
"refresh_token": "YOUR_REFRESH_TOKEN",
"client_id": "ocl_…"
}

For confidential clients, also send client_secret (or use Basic auth). iClosed rotates refresh tokens — save the new refresh_token from each response and stop using the old one.


PKCE (public clients)

Public clients must use PKCE. Generate a code_verifier (43–128 random URL-safe characters), then derive the challenge:

// Node.js example
import { createHash, randomBytes } from 'crypto';

const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');
  1. Store codeVerifier in the user's session (or your server session).

  2. Send code_challenge and code_challenge_method=S256 on the /oauth/authorize URL.

  3. Send code_verifier when calling /v1/oauth/token.

If the verifier does not match the challenge, token exchange returns invalid_grant.


Revoking access

User revokes from iClosed

Users can disconnect an app from Settings → Developer → Connected apps: click Revoke on one connection, or Revoke all for every connection to that app. After revocation, existing tokens stop working and the app has to ask for access again.

App revokes programmatically

Call the revocation endpoint:

Content-Type: application/json

{

"token": "ACCESS_OR_REFRESH_TOKEN",

"token_type_hint": "refresh_token",

"client_id": "ocl_…"

}

Confidential clients must authenticate with client_secret. The endpoint is idempotent.


Security best practices

  • Use the minimum scopes needed for your integration.

  • Never expose client secrets in browser or mobile code — use a public client with PKCE instead.

  • Always validate state on the callback to prevent CSRF.

  • Store refresh tokens encrypted on your server.

  • Use HTTPS redirect URIs in production.

  • Rotate credentials if a secret or refresh token is leaked — revoke old tokens immediately.

  • Request production approval from iClosed Support before listing your integration to customers outside your workspace.


OAuth API Reference (1.0.0)

OAuth 2.0 authorization, token management, and dynamic client registration (RFC 7591/7592)

OAuth authorization endpoint (authorization code)

Request

Backend step after the iClosed consent UI (https://app.iclosed.io/oauth/authorize): validates client, redirect URI, and scopes, requires an authenticated resource owner session (Bearer session JWT from login — not an API key or OAuth access token), then redirects to redirect_uri with code and optional state. Third-party OAuth clients start the flow in the browser at the consent URL; they do not call this endpoint directly.

const query = new URLSearchParams({

response_type: 'code',

client_id: 'string',

redirect_uri: 'http://example.com',

scope: 'string',

state: 'string',

code_challenge: 'stringstringstringstringstringstringstrings',

code_challenge_method: 'S256'

}).toString();

const resp = await fetch(

{method: 'GET'}

);

const data = await resp.text();

console.log(data);

Query

response_typestringrequired

OAuth response type. Must be "code".

Value:"code"

client_idstring, [ 1 .. 255 ] charactersrequired

OAuth client identifier.

redirect_uristring, (uri), <= 2048 charactersrequired

Redirect URI registered for the client. Must use https://, or http:// on localhost.

scopestring, <= 2000 characters

Space-delimited scopes requested by the client. Defaults to the full client-allowed scope set.

statestring, <= 2048 characters

Opaque value returned unchanged to prevent CSRF in client-side state handling.

code_challengestring, [ 43 .. 128 ] characters

PKCE code challenge. Required together with code_challenge_method for public clients and for every client registered through anonymous dynamic client registration; optional for account-owned confidential clients.

code_challenge_methodstring

PKCE method; required when code_challenge is present. Only S256 is supported.

Value:"S256"

Responses

302

400

401

429

Redirect to the validated redirect_uri with code/state query params


OAuth token endpoint

Request

Exchanges an authorization code for access/refresh tokens (with code_verifier when the code was issued with PKCE), or rotates refresh tokens. Supports authorization_code and refresh_token grants. Referenced by components.securitySchemes.oauth2 for MCP and OpenAPI OAuth clients.

const resp = await fetch(
`https://developer.iclosed.io/_mock/openapi/v1/oauth/v1/oauth/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
grant_type: 'authorization_code',
code: 'string',
redirect_uri: 'http://example.com',
code_verifier: 'stringstringstringstringstringstringstrings',
refresh_token: 'string',
scope: 'string',
client_id: 'string',
client_secret: 'string'
})
}
);

const data = await resp.json();
console.log(data);

Bodyapplication/jsonrequired

grant_typestringrequired

Enum:"authorization_code""refresh_token"

codestring, [ 1 .. 255 ] characters

redirect_uristring, (uri), <= 2048 characters

code_verifierstring, [ 43 .. 128 ] characters

refresh_tokenstring, [ 1 .. 255 ] characters

scopestring, <= 2000 characters

client_idstring, [ 1 .. 255 ] characters

client_secretstring, [ 1 .. 255 ] characters

POST

/v1/oauth/token

JavaScript

const resp = await fetch(   `https://developer.iclosed.io/_mock/openapi/v1/oauth/v1/oauth/token`,   {     method: 'POST',     headers: {       'Content-Type': 'application/json'     },     body: JSON.stringify({       grant_type: 'authorization_code',       code: 'string',       redirect_uri: 'http://example.com',       code_verifier: 'stringstringstringstringstringstringstrings',       refresh_token: 'string',       scope: 'string',       client_id: 'string',       client_secret: 'string'     })   } );  const data = await resp.json(); console.log(data);

Try it

Responses

200400401429

OAuth access token response

Bodyapplication/json

access_tokenstringrequired

token_typestringrequired

Value:"Bearer"

expires_ininteger, > 0required

refresh_tokenstringrequired

scopestringrequired


OAuth token revocation endpoint

Request

Revokes an access token or refresh token. Endpoint is idempotent and always returns 200 for known clients.

Bodyapplication/jsonrequired

tokenstring, [ 1 .. 2048 ] charactersrequired

token_type_hintstring

Enum:"access_token""refresh_token"

client_idstring, [ 1 .. 255 ] characters

client_secretstring, [ 1 .. 255 ] characters

const resp = await fetch(
`https://developer.iclosed.io/_mock/openapi/v1/oauth/v1/oauth/revoke`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
token: 'string',
token_type_hint: 'access_token',
client_id: 'string',
client_secret: 'string'
})
}
);

const data = await resp.json();
console.log(data);

Responses

200401429

Token revoked (or token not found)

Bodyapplication/json


Did this answer your question?