# Introduction

The goal of this documentation is to show you how to work with Pitchly's APIs from a developer's perspective.

To get started, you will need to register an app with Pitchly by [contacting us](https://www.pitchly.com/contact). Once you have done this, we will send you a Client ID and Client Secret that you can use to acquire access tokens and authenticate requests against our API.

If you have already received your Client ID and Client Secret, proceed to the next page.


# Authentication

Once you have a Client ID and Client Secret, the next step to making requests to the Pitchly API is to generate an access token that you can use to authenticate requests.

While your Client ID and Client Secret will generally remain the same for a long period of time, access tokens are usually short-lived. This makes your requests more secure.

Different types of access tokens can be generated for different types of use cases. Find your use case below and follow its instructions to learn how to generate an access token.

## Accessing data in your own organization

This is the most common use case for customers who want to access their own data via Pitchly’s API. Generating an access token for this use case is also the most straightforward.

The access token in this case is a JSON Web Token (JWT) containing your Client ID and is signed with your Client Secret. Optionally, you may also set an expiry time on the token, specify which workspaces the token will provide access to, and specify which permissions the token will have.

Choose your specific use case below:

<details>

<summary>I am generating a one-time access token to paste into other tools</summary>

The best way to generate a one-time access token is to use the online [JWT Debugger](https://jwt.io/#debugger-io?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI8Q0xJRU5UX0lEPiIsImlhdCI6MTY4OTA4MzQ0MH0.SMgHDqqoYzJVXNhTYXYS-Ee9xaOIcXFE0_vmGz6vzDg).

In the payload of your token, paste your Client ID into the `iss` claim. Then, where it says "your-256-bit-secret", paste in your Client Secret. Do **not** check "secret base64 encoded".

The resulting "Encoded" token on the left is your access token. Copy and paste it into your tool of choice or [learn how to make API requests with your access token](/graphql-api).

Note that, unless you add the `exp` claim to your payload, your access token will remain valid for as long as your Client Secret remains valid. In order to expire the token, you must rotate your Client Secret.

</details>

<details>

<summary>I am generating an access token programmatically</summary>

Many programming languages and frameworks offer prebuilt [packages and libraries](https://jwt.io/libraries) that make generating JWTs easy, such as [this one](https://www.npmjs.com/package/jsonwebtoken) or [this one](https://www.npmjs.com/package/jose) for Node.js.

Whether you use a prebuilt library or generate a JWT yourself, the JWT can be generated using the below pseudocode. Obviously, you will replace the client\_id and client\_secret with your own Client ID and Client Secret.

```javascript
HMACSHA256(
  base64UrlEncode({ // header
    "alg": "HS256",
    "typ": "JWT"
  }) + "." +
  base64UrlEncode({ // payload
    "iss": "app3GAJiLqhYX5FnSN57", // client_id
    "exp": 1689087040,
    "iat": 1689083440
  }),
  "plys_h5qScFLwuT7zTGKIftVT_EeWrUpv3lIlC5Mc_xClDQ5" // signature - client_secret
)
```

The resulting access token should look something like:

```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhcHAzR0FKaUxxaFlYNUZuU041NyIsImV4cCI6MTY4OTA4NzA0MCwiaWF0IjoxNjg5MDgzNDQwfQ.P9Q0d_vpBG0SnylWQU14GmhQrW2t2KXZmLjFR0bi3Q8
```

[Click here](https://jwt.io/#debugger-io?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhcHAzR0FKaUxxaFlYNUZuU041NyIsImV4cCI6MTY4OTA4NzA0MCwiaWF0IjoxNjg5MDgzNDQwfQ.P9Q0d_vpBG0SnylWQU14GmhQrW2t2KXZmLjFR0bi3Q8) to view this JWT in the JWT Debugger.

Below are the supported claims that you can include in your payload:

```javascript
{

    // Your Client ID
    "iss": "<CLIENT_ID>",
    
    // Unix timestamp representing when this token was created.
    // Many prebuilt libraries add this claim automatically.
    "iat": 1689083440,
    
    // Optional. Unix timestamp representing when this token will expire.
    // If not given, the token will be valid as long as the Client Secret
    // is valid.
    "exp": 1689087040,
    
    // Optional. A list of workspace IDs this token should have access to.
    // If not set, this token will have access to all workspaces.
    "workspace_ids": ["wks..."],
    
    // Optional. A list of permissions this token should have.
    // If not set, this token will have no permissions aside from read.
    "permissions": ["..."]

}
```

[Click here](#permissions) for a list of all possible permissions.

Note: Specifying an empty array for `workspace_ids` is ***not*** the same as not setting the claim. Specifying an empty array means the token should not have access to any workspaces, while not specifying `workspace_ids` will automatically provide access to all workspaces.

</details>

## Accessing data on behalf of a user

This use case is the most common in situations where users log into your app, and you want to make API requests to Pitchly after the user has connected their Pitchly account.

In this section, you will learn how to authorize a user against Pitchly using OAuth to ultimately acquire an access token that can be used to make API requests against Pitchly.

### Step #1: Redirect the user to our authorization page

Once the user is in your app, the first step is to redirect them to our authorization page. The URL will take the following format:

```html
https://platform.pitchly.com/oauth/authorize?
    response_type=code
    &client_id=<CLIENT_ID>
    &redirect_uri=<REDIRECT_URI>
    &state=<STATE>
    &code_challenge_method=S256
    &code_challenge=<CODE_CHALLENGE>
```

Click on each section below to learn how to populate each variable:

<details>

<summary>CLIENT_ID</summary>

This is the Client ID we gave you after registering your app.

</details>

<details>

<summary>REDIRECT_URI</summary>

This is the Redirect URL you gave us prior to registering your app. If you did not provide one, please [let us know](https://www.pitchly.com/contact) so that we can register it with your app.

The Redirect URL must be the URL you wish to redirect users to *after* they have authorized your app with Pitchly. When we redirect to it, we will append a `code` to its query parameters, which you will eventually [exchange for an access token](#step-2-exchange-authorization-code-for-an-access-token).

</details>

<details>

<summary>STATE</summary>

This should be a unique, randomly generated, non-guessable string. Before redirecting the user, put this value in a cookie, session, or localstorage in order to remember it when the user returns to your app.

When we redirect the user back to your app, we will append the `state` you gave us to the URL as a query parameter. Verify that this `state` parameter matches the value you have stored in cookies, session, or localstorage. If it matches, it means this is a valid request. If it doesn't match, it means this request was either unsolicited or forged, and you should **not** continue to the [next step](#step-2-exchange-authorization-code-for-an-access-token).

Below is browser code (without dependencies) that you can use to generate a secure random string and store it in localstorage:

```javascript
// Generate a secure random string using the browser crypto functions
function generateRandomString() {
    var array = new Uint32Array(28);
    window.crypto.getRandomValues(array);
    return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join('');
}

// Create and store a random "state" value
var state = generateRandomString();
localStorage.setItem("state", state);
```

[View source](https://gist.github.com/ahmetgeymen/a9dcd656a1527f6c73d9c712ea2d9d7e)

Note: In cases where you want to pass along arbitrary data from *before* you redirect to Pitchly to *after* the user has returned to your app, you can sometimes use the `state` parameter to accomplish this. This can be done by base64-url encoding your data plus a random nonce, for example.

</details>

<details>

<summary>CODE_CHALLENGE</summary>

While [`state`](#state) verifies that the same user who initiated the redirect from your app to Pitchly is the same user who returns to your app after we redirect back, the `code_challenge` parameter solves a similar but different problem.

We want to verify that the same user who initiated the redirect from your app to Pitchly is also the same user who ultimately attempts to exchange the returned `code` for an access token (which we will cover in the [next step](#step-2-exchange-authorization-code-for-an-access-token)).

To do this, we use a common standard called "[PKCE](https://www.oauth.com/oauth2-servers/pkce/)" *(pronounced "pixie")*, which stands for "Proof Key for Code Exchange". This is an extension of the OAuth standard and aims to prevent forgery attacks.

In a nutshell, PKCE requires these things (which at the end provides us a `code_challenge`):

1. Generate a unique and random string (must be different from `state`). This will be known as our `code_verifier`.
2. Store the `code_verifier` in cookies, session, or localstorage.
3. Hash the `code_verifier` with the function `BASE64_URL(SHA256(code_verifier))`. This result is the `code_challenge`, which you will pass in the authorization URL.

Below is browser code (without dependencies) that can be used to generate your `code_challenge` and store your `code_verifier` in localstorage. Note that the `generateRandomString` function has been carried over from the `state` code example [above](#state).

```javascript
// Generate a secure random string using the browser crypto functions
function generateRandomString() {
    var array = new Uint32Array(28);
    window.crypto.getRandomValues(array);
    return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join('');
}

// Calculate the SHA256 hash of the input text. 
// Returns a promise that resolves to an ArrayBuffer
function sha256(plain) {
    const encoder = new TextEncoder();
    const data = encoder.encode(plain);
    return window.crypto.subtle.digest('SHA-256', data);
}

// Base64-urlencodes the input string
function base64urlencode(str) {
    // Convert the ArrayBuffer to string using Uint8 array to conver to what btoa accepts.
    // btoa accepts chars only within ascii 0-255 and base64 encodes them.
    // Then convert the base64 encoded to base64url encoded
    //   (replace + with -, replace / with _, trim trailing =)
    return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// Return the base64-urlencoded sha256 hash for the PKCE challenge
async function pkceChallengeFromVerifier(v) {
    hashed = await sha256(v);
    return base64urlencode(hashed);
}

// Create and store a new PKCE code_verifier (the plaintext random secret)
var code_verifier = generateRandomString();
localStorage.setItem("pkce_code_verifier", code_verifier);

// Hash and base64-urlencode the secret to use as the challenge
var code_challenge = await pkceChallengeFromVerifier(code_verifier);
```

[View source](https://gist.github.com/ahmetgeymen/a9dcd656a1527f6c73d9c712ea2d9d7e)

Later, when the user returns to your app and you send us a request to exchange the returned `code` for an access token (which we will cover in the [next step](#step-2-exchange-authorization-code-for-an-access-token)), you will pass the `code_verifier` from localstorage to that request. So hold onto it until you have an access token!

</details>

### Step #2: Exchange authorization code for an access token

After the user successfully authorizes your app to access their Pitchly data, we will redirect the user to your `redirect_uri` with a `code` in the URL query parameters. This code is only valid for a short time and must be exchanged for an access token.

To exchange this code for an access token, you will need to make a POST request to our token endpoint. See the details of that request below.

#### Exchange the authorization code for an access token

<mark style="color:green;">`POST`</mark> `https://platform.pitchly.com/api/oauth/token`

`Content-Type: application/x-www-form-urlencoded`

#### Request Body

| Name                                             | Type   | Description                                                                                                                                                                                                                               |
| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| grant\_type<mark style="color:red;">\*</mark>    | String | "authorization\_code"                                                                                                                                                                                                                     |
| code<mark style="color:red;">\*</mark>           | String | The `code` you received                                                                                                                                                                                                                   |
| redirect\_uri<mark style="color:red;">\*</mark>  | String | The same `redirect_uri` that was sent in [step 1](#step-1-redirect-the-user-to-our-authorization-page)                                                                                                                                    |
| code\_verifier<mark style="color:red;">\*</mark> | String | The `code_verifier` that was created in the process of generating the [`code_challenge`](#code_challenge) in step 1                                                                                                                       |
| client\_id<mark style="color:red;">\*</mark>     | String | The `client_id` of your app                                                                                                                                                                                                               |
| client\_secret<mark style="color:red;">\*</mark> | String | The `client_secret` of your app. This is required unless your app is a public client - i.e. an SPA, Mobile, or Desktop app that cannot keep a secret. [More details](#my-app-is-an-spa-mobile-or-desktop-app-that-cannot-keep-a-secret).  |
| scope                                            | String | A space-delimited list of downscoped permissions you want the resulting access token to have. Note that you can only request the access token have fewer permissions than approved by the organization admin, not more.                   |
| workspace\_ids                                   | String | A space-delimited list of downscoped workspaces you want the resulting access token to have access to. Note that you can only request the access token have access to fewer workspaces than approved by the organization admin, not more. |

#### Response

{% tabs %}
{% tab title="200: OK Success" %}
The response will include an `access_token`, `refresh_token` (which you can use to [get a new access token](#step-3-refresh-access-token-after-expiration-optional) once this one expires), how long the access token will be valid for in seconds via `expires_in`, and a space-delimited list of permissions this access token has via `scope`. Note that refresh tokens will remain valid until they are used.

```json
{
    "access_token": "plyu_BASyd6ePeMAR1RHVbpWOxU7Bda9ze9i-rFWuk-dEAZW",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "plyr_UQb-_2dHYpL7ExpHDkrLaTOrmUMYx27NYSDgeVNMdMo",
    "scope": "records:create records:update"
}
```

{% endtab %}

{% tab title="400: Bad Request" %}
You will receive a `400` HTTP response for any error, other than one caused by passing an invalid Client ID or Client Secret. In this case, you will receive a `401` HTTP response code.

```json
{
    "error": "invalid_grant",
    "error_description": "Please provide a valid 'code'."
}
```

{% endtab %}

{% tab title="401: Unauthorized" %}
You will receive an `invalid_client` error and `401` HTTP response code if you provided an invalid Client ID or Client Secret.

```json
{
    "error": "invalid_client",
    "error_description": "Please provide a valid 'client_id' (this may also be called your 'App ID')."
}
```

{% endtab %}
{% endtabs %}

Where you make this request from will depend on the capabilities of your app. Choose your app type below:

<details>

<summary>My app has a server backend that can securely keep a secret</summary>

#### Your app is known as a "confidential client"

If your app has a server backend and can securely keep a secret, this request should be made on your server backend and you should include the `client_secret` we gave you in your request. This is the assumption of most apps by default.

**Just a friendly reminder**: Never share your `client_secret` or use it in client-side code that could expose it to your end users, unless you want them to have total control of your app!

**Also note**: It's safest to store both the resulting `access_token` and `refresh_token` you receive behind your server and proxy API calls using the access token via your server. But if you need to make API calls client side using the access token, you can do so as long as the access token is only shared with the user the access token is for. But remember: the access token will carry the full permissions the admin of the organization approved for your app unless you've requested the access token have fewer permissions. This means it is possible that the access token can do things that the user wouldn't normally be able to; for example, create or update records while the user only has read privilege. To counter this, you can either downscope the access token permissions by providing the [`scope`](#exchange-the-authorization-code-for-an-access-token) option in your [authorization code request](#exchange-the-authorization-code-for-an-access-token) or proxy API calls through your server.

The `refresh_token`, when possible, should always be stored server-side.

</details>

<details>

<summary>My app is an SPA, Mobile, or Desktop app that cannot keep a secret</summary>

#### Your app is known as a "public client"

If your app is an SPA, Mobile, or Desktop app that cannot keep a secret, you can make this request from the frontend of your SPA or in your core application code without including a `client_secret` in the request. However, you must do the following:

1. [Let us know](https://www.pitchly.com/contact) that your app is a public client, so we can register it accordingly.
2. If you are making the request from a browser, the page origin that initiated the request must match one of the origins in the list of valid [Redirect URLs](#redirect_uri) you gave us. [Let us know](https://www.pitchly.com/contact) if you need us to update your list of Redirect URLs.

**Note**: If you are initiating this request from a public client, make sure to **NOT** include the `client_secret` in your request, as this would also expose it to all of your users!

</details>

### Step #3: Refresh access token after expiration (optional)

Access tokens are only valid for one hour. If you want to continue to access the user's Pitchly data after that time, you will need to get a new access token. You have two options to do that:

1. Redirect the user back through the OAuth flow again (starting from [step 1](#step-1-redirect-the-user-to-our-authorization-page)).
2. Get a new access token using the `refresh_token` provided in the [authorization code response](#exchange-the-authorization-code-for-an-access-token).

The remainder of these instructions will focus on option #2.

Assuming you have held onto the `refresh_token` returned in the [authorization code response](#exchange-the-authorization-code-for-an-access-token), you can acquire a new access token by making the following request.

#### Get a new access token using the refresh token

<mark style="color:green;">`POST`</mark> `https://platform.pitchly.com/api/oauth/token`

`Content-Type: application/x-www-form-urlencoded`

#### Request Body

| Name                                             | Type   | Description                                                                                                                                                                                                                               |
| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| grant\_type<mark style="color:red;">\*</mark>    | String | "refresh\_token"                                                                                                                                                                                                                          |
| refresh\_token<mark style="color:red;">\*</mark> | String | The stored `refresh_token`                                                                                                                                                                                                                |
| client\_id<mark style="color:red;">\*</mark>     | String | The `client_id` of your app                                                                                                                                                                                                               |
| client\_secret<mark style="color:red;">\*</mark> | String | The `client_secret` of your app. This is required unless your app is a public client - i.e. an SPA, Mobile, or Desktop app that cannot keep a secret. [More details](#my-app-is-an-spa-mobile-or-desktop-app-that-cannot-keep-a-secret).  |
| scope                                            | String | A space-delimited list of downscoped permissions you want the resulting access token to have. Note that you can only request the access token have fewer permissions than approved by the organization admin, not more.                   |
| workspace\_ids                                   | String | A space-delimited list of downscoped workspaces you want the resulting access token to have access to. Note that you can only request the access token have access to fewer workspaces than approved by the organization admin, not more. |

#### Response

{% tabs %}
{% tab title="200: OK Success" %}
Response is identical to the [authorization code response](#exchange-the-authorization-code-for-an-access-token), except you will receive a new `access_token` and a new `refresh_token`. Note that the old refresh token will be immediately invalidated, so you will want to replace the old refresh token with this one, so that you can get a new access token again once this one expires. The new refresh token will remain valid up until it is used to get a new access token again.

```json
{
    "access_token": "plyu_EcH6g3bSVdjHtAAYuUOy4pbvWYIPxhDD97FBEmw-PT9",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "plyr_M5abea-_6JCE2kMRAHDjAWSISAbIXwWNEGwJlaUkbeq",
    "scope": "records:create records:update"
}
```

{% endtab %}

{% tab title="400: Bad Request" %}
You will receive a `400` HTTP response for any error, other than one caused by passing an invalid Client ID or Client Secret. In this case, you will receive a `401` HTTP response code.

```json
{
    "error": "invalid_grant",
    "error_description": "Please provide a valid 'refresh_token'."
}
```

{% endtab %}

{% tab title="401: Unauthorized" %}
You will receive an `invalid_client` error and `401` HTTP response code if you provided an invalid Client ID or Client Secret.

```json
{
    "error": "invalid_client",
    "error_description": "Please provide a valid 'client_id' (this may also be called your 'App ID')."
}
```

{% endtab %}
{% endtabs %}

{% hint style="info" %}
Just like in [step 2](#step-2-exchange-code-for-an-access-token), if your app has a server backend and can keep a secret, you must include your `client_secret` in the request. If your app cannot keep a secret, you must exclude the `client_secret` from the request.
{% endhint %}

Each time your access token expires, you can repeat this step again to get a new access token. If this request fails, you can redirect the user back through the OAuth flow (from [step 1](#step-1-redirect-the-user-to-our-authorization-page)) to try to get an access token again. This can happen if the organization has uninstalled your app and it must be approved again.

## Accessing data in an organization that has installed your app, on behalf of your app (instead of a user)

Most apps generally fall into one of the two use cases listed above. But if you’re trying to get data from an organization other than your own, the [user flow](#accessing-data-on-behalf-of-a-user) carries one significant limitation: it requires a user.

What do you do if you want to access data in an organization that has installed your app but your app doesn’t have a user interface to allow users to sign in, or you simply want to get data from the organization at any time, regardless of whether a user has signed in recently?

Some examples may include:

* An analytics app that emails you statistics on your account periodically.
* An image tracker that notifies you of images in your tables that are copyrighted.
* An integration to an outside system that syncs your data automatically.

This is where the `client_credentials` grant can come in handy. To get an access token using this grant, make the following request.

#### Get an access token to an organization on behalf of your app

<mark style="color:green;">`POST`</mark> `https://platform.pitchly.com/api/oauth/token`

`Content-Type: application/x-www-form-urlencoded`

#### Request Body

| Name                                               | Type   | Description                                                                                                                                                                                                                               |
| -------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| grant\_type<mark style="color:red;">\*</mark>      | String | "client\_credentials"                                                                                                                                                                                                                     |
| organization\_id<mark style="color:red;">\*</mark> | String | The organization you want to access data from                                                                                                                                                                                             |
| client\_id<mark style="color:red;">\*</mark>       | String | The `client_id` of your app                                                                                                                                                                                                               |
| client\_secret<mark style="color:red;">\*</mark>   | String | The `client_secret` of your app                                                                                                                                                                                                           |
| scope                                              | String | A space-delimited list of downscoped permissions you want the resulting access token to have. Note that you can only request the access token have fewer permissions than approved by the organization admin, not more.                   |
| workspace\_ids                                     | String | A space-delimited list of downscoped workspaces you want the resulting access token to have access to. Note that you can only request the access token have access to fewer workspaces than approved by the organization admin, not more. |

#### Response

{% tabs %}
{% tab title="200: OK Success" %}
Unlike the response you receive for a [user access token](#exchange-the-authorization-code-for-an-access-token), this response will not include a `refresh_token`. Once this access token expires, you can get a new one by simply making this request again.

```json
{
    "access_token": "plyi_T4_71q61OKQzyJMRwlF8LH6KQ5wfuqJIcruT6OS-_a9",
    "token_type": "Bearer",
    "expires_in": 3600,
    "scope": "records:create records:update"
}
```

{% endtab %}

{% tab title="400: Bad Request" %}
You will receive a `400` HTTP response for any error, other than one caused by passing an invalid Client ID or Client Secret. In this case, you will receive a `401` HTTP response code.

```json
{
    "error": "invalid_grant",
    "error_description": "Please provide a valid 'organization_id'."
}
```

{% endtab %}

{% tab title="401: Unauthorized" %}
You will receive an `invalid_client` error and `401` HTTP response code if you provided an invalid Client ID or Client Secret.

```json
{
    "error": "invalid_client",
    "error_description": "Please provide a valid 'client_id' (this may also be called your 'App ID')."
}
```

{% endtab %}
{% endtabs %}

{% hint style="info" %}
This request may only be made from a secure environment, such as a server, because it requires a `client_secret`. Public clients, such as SPAs, Mobile, or Desktop apps that do not have a server backend are prohibited from using this grant.
{% endhint %}

Using the returned access token (which we call an “install access token”), you may now make API calls to the desired organization on behalf of your app itself instead of on behalf of a specific user.

Compared to [user access tokens](#accessing-data-on-behalf-of-a-user), note that install access tokens will differ in the following ways:

* Install access tokens will have access to all workspaces the organization approves the app to access. User access tokens, in contrast, only have access to an intersection of workspaces the app has been approved access to + the workspaces the current user is a member of.
* Install access tokens will not have access to anything that is private to a specific user, such as private views.

Keep these factors in mind if you plan to use a combination of user access tokens and install access tokens throughout your app, since one won't necessarily have access to the same resources as the other.

## Next: Make API calls with GraphQL

Now that you have an access token, you can make API calls against our GraphQL API. Click the link below to learn how.

{% content-ref url="/pages/JvprD6gzbwfWkydJWzkA" %}
[GraphQL API](/graphql-api)
{% endcontent-ref %}

## Resources

### Permissions

Below are all possible permissions an app can have. The workspaces an app can access will depend on which workspaces the admin of the requested organization approves the app to access, as well as the type of access token that is generated.

| Permission              | Description                                    |
| ----------------------- | ---------------------------------------------- |
| records:create          | Create records in workspaces                   |
| records:update          | Update records in workspaces                   |
| records:delete          | Delete records in workspaces                   |
| workspace\_members:read | Read members of workspaces                     |
| teams:read              | Read teams across the organization             |
| documents:read          | Read content and templates in the Elements app |
| table:create            | Create a new table in a workspace              |
| table:modify            | Modify table dropdown field choices            |
| rootTable:create        | Create a table in the organization             |


# GraphQL API

Equipped with an [access token](/authentication), you may now use it to make requests to our GraphQL API.

If you've never used GraphQL before, below you will find everything you need to make requests. If you're already familiar with GraphQL or are ready to start making GraphQL requests, you can head over to our [GraphQL schema documentation](https://studio.apollographql.com/public/Production-zmrahp/variant/current/home) to find example queries and test them using the [Explorer](https://studio.apollographql.com/public/Production-zmrahp/variant/current/explorer).

## Why GraphQL?

We chose to deliver our API with GraphQL because, compared to traditional REST endpoints, GraphQL allows you to specify exactly the data you want returned, and it enables fetching complex data structures in one request instead of many smaller requests. GraphQL also supports real-time updates via GraphQL subscriptions with websockets out of the box.

## Do I need special software to make GraphQL calls?

No. Our GraphQL API can be called in the same way you make REST API calls, by just sending a request to an HTTP endpoint.

## How do I make GraphQL calls?

Compared to typical REST APIs, all requests to GraphQL are **POST** requests to the following endpoint:

```url
https://api.pitchly.com/graphql
```

In the headers of your request, you must include:

```javascript
{
    "Content-Type": "application/json",
    "Authorization": `Bearer ${accessToken}`
}
```

Replace `${accessToken}` with your [access token](/authentication).

Next, the `body` of your request should be a JSON-encoded object containing your GraphQL query and any applicable variables in the following format.

```javascript
{
    query: "...", // your GraphQL query as a string
    variables: { ... } // any applicable variables to include in the query (optional)
}
```

When you receive a response back to your request, the response will take the following form:

```json
// if successful
{
    "data": { ... }
}

// if error
{
    "errors": [ ... ],
    "data": null
}
```

{% hint style="warning" %}
Note: Unlike most REST APIs, GraphQL will always return a **200 OK** HTTP status code, even if your query failed. You can instead determine if an error occurred by checking whether `data` in the response is `null`.
{% endhint %}

## Example request

The following example demonstrates how you would get a list of tables in a specific workspace using the Pitchly GraphQL API in Node.js.

```javascript
// third-party fetch package for making HTTP calls
import fetch from "node-fetch";

const response = await fetch("https://api.pitchly.com/graphql", {
  method: "POST",
  headers: {
    "Content-Type": "application/json", // must be included in GraphQL requests
    "Authorization": "Bearer " + accessToken
  },
  body: JSON.stringify({
    query: `
      query workspace($id: ID!) {
        workspace(id: $id) {
          tables {
            id
            name
          }
        }
      }
    `,
    variables: {
      id: workspaceId
    }
  })
});

// GraphQL will always return a 200 OK status code,
// even when there is an error. So first check that
// the HTTP status code is 200, and then check that
// "data" exists in the response, indicating success.

if (!response.ok) {
  throw new Error("HTTP request returned error status code.");
}

// decode response json
const data = await response.json();

if (!data.data) {
  throw new Error("GraphQL query failed.");
}

console.log(data);
```

A successful response for this request may look something like this:

```json
{
  "data": {
    "workspace": {
      "tables": [
        {
          "id": "wksEtbAYd6bcTKigHGm4|tbloJnfkHi85WscsjTvG",
          "name": "Companies"
        },
        {
          "id": "wksEtbAYd6bcTKigHGm4|tblR59H2hbufDMpp5BTp",
          "name": "Matters"
        },
        {
          "id": "wksEtbAYd6bcTKigHGm4|tblqafbaJW9ZqeiZbJ9Q",
          "name": "Employees"
        }
      ]
    }
  }
}
```

If the request had failed due to an invalid access token, for example, `data` would have looked something like this (and you still would have received a **200 OK** HTTP status code):

{% code fullWidth="false" %}

```json
{
  "errors": [
    {
      "message": "You must be authenticated to access this resource. Please provide a valid Bearer Token in the Authorization header.",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "workspace"
      ],
      "extensions": {
        "code": "UNAUTHENTICATED"
      }
    }
  ],
  "data": null
}
```

{% endcode %}

## Resources

### GraphQL Schema Documentation

[Click here](https://studio.apollographql.com/public/Production-zmrahp/variant/current/home) to view our full GraphQL schema documentation and all query options.

### GraphQL Explorer

[Click here](https://studio.apollographql.com/public/Production-zmrahp/variant/current/explorer) to access the GraphQL Explorer, a web interface where you can test GraphQL requests against our API straight from your browser!

### Pitchly Field Types/Returns

[Click here](https://developer.pitchly.com/pitchly-field-types-returns) to consult the full list of pitchly field types and sample return values for each type.


# GraphQL Filters

Filters are used in the recordsConnection query to specify criteria for specific records to be returned.  They consist of a series of one or more conditions with a logical operator of "and" or "or." \
\
Filters can contain *condition groups*:  nested conditions that take place of each condition in a filter.  The primary example shown below of a request body and filter contains a condition group to illustrate the use of this grouping, but it is not necessary to use in your filter.\
\
The easiest way to create a filter is to go to the platform UI and create a filter, and then copy that filter criteria to use in your query.  Note that reference subfield filters cannot be constructed this way and are only available in the API, so please refer to the [documentation](#reference-subfield-filters) below for them.  \
\
More about each filter type, including the reference subfield filters, is shown below this section.

**Follow these steps to copy the filter from your UI:**\
\
To go to the UI, open up the platform, go into a workspace table, and click the filter button here.<br>

<figure><img src="/files/VpCySikqkBwMJMZQCB2K" alt=""><figcaption></figcaption></figure>

Once you have created a filter in the UI, you can copy the filter from the browser console and use it in a graphQL request inside the variables for that request.  Here is an example of a browser console log showing the filter:\
\ <img src="/files/Vna2pSZWDpjJJrfnTUF8" alt="" data-size="original">\
\
And here is an example of a graphQL query body consistent with the example request shown in [GraphQL API](/graphql-api) to filter a set of records.  Note that it includes the filter along with any other variables pertinent to the query.  It also includes a condition group to illustrate how this is used:

```json
body: JSON.stringify({
    query: `
      query ExampleQuery($tableId: ID!, $filter: JSON) {
        recordsConnection(tableId: $tableId, filter: $filter) {
          edges {
            node {
              id
              fields {
                  fieldId 
                  value 
                  stringValue
                  }
            }
          }
        }
      }
    `,
    variables: {
      "tableId": "wksZbcuvFwooPY2JSbRD|tblDAfBe745NNcTHswrQ",
      "filter": {
      "conditions": [
        {
          "left": {
            "type": "field",
            "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3ft"
          },
          "comparison": {
            "operator": "contains"
          },
          "right": {
            "type": "input",
            "value": "uy"
          }
        },
        {
          "conditionGroup": [
            {
              "left": {
                "type": "field",
                "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3ft"
              },
              "comparison": {
                "operator": "contains"
              },
              "right": {
                "type": "input",
                "value": "a"
              }
            },
            {
              "left": {
                "type": "field",
                "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3ft"
              },
              "comparison": {
                "operator": "contains"
              },
              "right": {
                "type": "input",
                "value": "mp"
              }
            }
          ],
          "logicalOperator": "or"
        }
      ],
      "logicalOperator": "or"
    }
    
    }
  })
```

## Examples of Filters for Each Type

### Single-line Text Filter

The single-line text filter can be created in the console log as explained above.  It supports the of "contains," "does-not-contain," "is," "is-not," "starts-with," "ends-with," "is-empty," "has-any-value" operators. \
\
A sample is given here for a "contains" filter:

```postman_json
{
      "tableId": "wksARcgrLaJkqc2zvzKA|tblnJkpPvq9RaJcgd3Fn",
      "filter": {
      "conditions": [
        {
          "left": {
            "type": "field",
            "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3ft"
          },
          "comparison": {
            "operator": "contains"
          },
          "right": {
            "type": "input",
            "value": "asdf"
          }
        }
      ],
      "logicalOperator": "and"
    }
    
    }
```

### Number Filter

The number filter can be created in the console log as explained above.  It supports "is," "is-not," \
"is-more-than," "is-less-than," "is-empty," "has-any-value".  \
\
A sample is given here for a "is-more-than" filter:

```postman_json
{
      "tableId": "wksARcgrLaJkqc2zvzKA|tblnJkpPvq9RaJcgd3Gn",
      "filter": {
      "conditions": [
        {
          "left": {
            "type": "field",
            "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3fx"
          },
          "comparison": {
            "operator": "is-more-than"
          },
          "right": {
            "type": "input",
            "value": 3000
          }
        }
      ],
      "logicalOperator": "and"
    }
    
    }
```

### Currency Filter

The currency filter can be created in the console log as explained above.  Similar to the number filter, it supports "is," "is-not," "is-more-than," "is-less-than," "is-empty," "has-any-value".  \
\
Unlike the number filter, the currency is included in the right object.  Without matching the currency, the row will not be returned for this filter.\
\
A sample is given here for a "is-more-than" filter:

```postman_json
{
      "tableId": "wksARcgrLaJkqc2zvzKA|tblnJkpPvq9RaJcgd3Gn",
      "filter": {
      "conditions": [
        {
          "left": {
            "type": "field",
            "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3fx"
          },
          "comparison": {
            "operator": "is-more-than"
          },
          "right": {
            "type": "input",
            "value": 3000
            "currency": "USD"
          }
        }
      ],
      "logicalOperator": "and"
    }
    
    }
```

### Dropdown Filter

The dropdown filter can be created in the console log as explained above.  It supports the of "has-any-of," "is", 'has-none-of", "is-empty," "has-any-value" operators. \
\
A sample is given here for a "has-any-of" filter:

```postman_json
{
      "tableId": "wksARcgrLaJkqc2zvzKA|tblnJkpPvq9RaJcgd3Fn",
      "filter": {
      "conditions": [
        {
          "left": {
            "type": "field",
            "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3ft"
          },
          "comparison": {
            "operator": "has-any-of"
          },
          "right": {
            "type": "input",
            "value": ['Lincoln']
          }
        }
      ],
      "logicalOperator": "and"
    }
    
    }
```

### Dropdown Multiple Filter

The dropdown multiple filter can be created in the console log as explained above.  It supports the of "has-any-of," "has-all-of," "is", 'has-none-of", "is-empty," "has-any-value" operators. \
\
A sample is given here for a "has-any-of" filter:

```postman_json
{
      "tableId": "wksARcgrLaJkqc2zvzKA|tblnJkpPvq9RaJcgd3Fn",
      "filter": {
      "conditions": [
        {
          "left": {
            "type": "field",
            "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3ft"
          },
          "comparison": {
            "operator": "has-any-of"
          },
          "right": {
            "type": "input",
            "value": ['Lincoln', 'Omaha']
          }
        }
      ],
      "logicalOperator": "and"
    }
    
    }
```

### Reference Multiple Filter

The reference multiple filter can be created in the console log as explained above.  Similar to dropdown multiple it supports the of "has-any-of," "has-all-of," "is", 'has-none-of", "is-empty," "has-any-value" operators.  The values specified are the recordID.\
\
A sample is given here for a "has-any-of" filter:

```postman_json
{
      "tableId": "wksARcgrLaJkqc2zvzKA|tblnJkpPvq9RaJcgd3Fn",
      "filter": {
      "conditions": [
        {
          "left": {
            "type": "field",
            "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3ft"
          },
          "comparison": {
            "operator": "has-any-of"
          },
          "right": {
            "type": "input",
            "value": ['wksARcgrLaJkqc2zvzKA|recDRQYYXT6FLAiatC7x', 'wksARcgrLaJkqc2zvzKA|recFYinCfj79cmsJHaCN']
          }
        }
      ],
      "logicalOperator": "and"
    }
    
    }
```

### Single Reference Filter

The reference filter can be created in the console log as explained above.  It supports "is," "is-not," "is-empty," "has-any-value".  The value specified is the recordID.\
\
A sample is given here for an "is" filter:

```postman_json
{
      "tableId": "wksARcgrLaJkqc2zvzKA|tblnJkpPvq9RaJcgd3Fn",
      "filter": {
      "conditions": [
        {
          "left": {
            "type": "field",
            "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3ft"
          },
          "comparison": {
            "operator": "is"
          },
          "right": {
            "type": "input",
            "value": "wksARcgrLaJkqc2zvzKA|recDRQYYXT6FLAiatC7x"
          }
        }
      ],
      "logicalOperator": "and"
    }
    
    }
```

### Reference Subfield Filters

Reference subfield filters allow you to set a filter on a reference field in the current table that includes a filter for the referenced row (usually in another table).  It uses the dot notation for fields to separate the reference field ID and the ID of the field in the referenced table.\
\
The field in the other table is filtered according to its type and operators available for that field. \
\
These filters are \*only\* available in the API, and not in the platform UI.  They cannot contain more than one dot and so therefore cannot follow a reference of a reference.\
\
A sample is given here for a contains filter on a single-line text subfield:

```postman_json
{
      "tableId": "wksARcgrLaJkqc2zvzKA|tblnJkpPvq9RaJcgd3Fn",
      "filter": {
      "conditions": [
        {
          "left": {
            "type": "field",
            "value": "wksARcgrLaJkqc2zvzKA|fldRLwBvjeqHZTEJXASe.wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3ft"
          },
          "comparison": {
            "operator": "contains"
          },
          "right": {
            "type": "input",
            "value": "Bo"
          }
        }
      ],
      "logicalOperator": "and"
    }
    
    }
```

### Attachment Filter

The attachment filter can be created in the console log as explained above.  It supports the "is-empty" and "has-any-value" operators. \
\
A sample is given here for a "is-empty" filter:

```postman_json
{
      "tableId": "wksARcgrLaJkqc2zvzKA|tblnJkpPvq9RaJcgd3Fn",
      "filter": {
      "conditions": [
        {
          "left": {
            "type": "field",
            "value": "wksARcgrLaJkqc2zvzKA|fldDXnDqrwoz2PoPF3fz"
          },
          "comparison": {
            "operator": "is-empty"
          },
          "right": {}
        }
      ],
      "logicalOperator": "and"
    }
    
    }
```

### Formula and Rollup Filters

The formula and rollup filters can be created in the console log as explained above.  They support the "contains," "does-not-contain," "is," "is-not," "starts-with," "ends-with," "is-empty," "has-any-value" operators, as well as the "is-more-than," and "is-less-than" operators.\
\
For brevity examples are not given, but please refer to the single-line text and number filters for explicit examples.

### Other Filter Types

If the type of filter needed isn't listed here, please contruct the filter in the platform UI with [the instructions above](https://developer.pitchly.com/graphql-filters) and use that as an example to build your filter.

## Resources

### GraphQL Schema Documentation

[Click here](https://studio.apollographql.com/public/Production-zmrahp/variant/current/schema/sdl?selectedSchema=%23%40%21api%21%40%23) to view our full GraphQL schema documentation and all query options.

### GraphQL Explorer

[Click here](https://studio.apollographql.com/public/Production-zmrahp/variant/current/explorer) to access the GraphQL Explorer, a web interface where you can test GraphQL requests against our API straight from your browser!

### Pitchly Field Types/Returns

[Click here](https://developer.pitchly.com/pitchly-field-types-returns) to consult the full list of pitchly field types and sample return values for each type.


# Plugin API

## What is a plugin?

Pitchly apps take many different forms. Most of the time, apps exist outside of the Pitchly platform and either make API requests on behalf of the current user or on behalf of the organization that owns the app. But there is another type of app: what we call a "**plugin**".

A **plugin** is an app that sits side-by-side with the Pitchly platform in the same window, as shown in the example below. In this example, the Unsplash app allows users to search for stock photos and insert them directly into their table.

<figure><img src="/files/NKtbJ5BnZ0RZ7VMnmV54" alt=""><figcaption><p>The Unsplash app as a plugin (seen right)</p></figcaption></figure>

The benefit of making your app a plugin inside of Pitchly is that your app can intimately interact with the user's data tables in real time while the user is working inside Pitchly. This is advantageous for data-first workflows, where you expect the user to primarily work inside their database but you would like to extend their workflow with some functionality provided by your app.

The example below shows a more sophisticated workflow with our Elements app, where as the user filters their data and selects records, the elements in the right pane change to reflect the current view and selection. The reverse is also true: when the user selects elements in the right pane, the records are selected in the table on the left.

<figure><img src="/files/ocaWHIAzd4mtH6s0qqU2" alt=""><figcaption><p>Interaction between Elements and Pitchly</p></figcaption></figure>

## Prerequisites

In order to serve your app as a plugin to Pitchly users, your interface must be publicly accessible at a specific URL you host. You will need to [provide us](https://www.pitchly.com/contact) with that URL and let us know that you want to display it as a plugin. When users install your app and click on its button to the right of their workspace, we will load your plugin URL in an iFrame.

## Initialization

Plugins interact with the platform via a series of [postMessage events](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). In order to display your plugin and start receiving postMessage events from the platform, an initial handshake must first be performed:

1. When your app is ready to receive messages, the app must send Pitchly the `ready` message.
2. In response to the `ready` message, Pitchly will send your app multiple messages indicating the current state of the UI.
3. As the user interacts with Pitchly, your app will continue to receive individual messages when there are changes to the UI state.

{% hint style="warning" %}
Important: If your plugin is stuck on the loading page and not displaying, most likely Pitchly hasn't received the `ready` message from your plugin. We will only display your plugin once we receive the `ready` message, and we will additionally send back the current UI state when we receive the `ready` event. This handshake must be performed because Pitchly can't otherwise  predict when your app is ready to receive events.
{% endhint %}

To send the `ready` event, simply call:

```javascript
window.parent.postMessage({ type: "ready" }, "https://platform.pitchly.com");
```

The second parameter ensures that you only send this message to Pitchly and no other websites that may have iFramed your plugin interface. It's important that you include this in order to keep your cross-iFrame communication secure.

On Pitchly's side, we similarly only send messages to the same origin as the plugin URL you originally gave us. This means if you redirect the user inside your iFrame to another origin, you will not be able to receive our postMessages.

## Workflow

Most commonly, when an event happens inside Pitchly, such as a filter being set or a record selection being made, this may trigger something to change inside your plugin's UI. You may expect that when a record is selected, for example, you will get the entire record data delivered to your app via postMessage. However, passing entire record data is not only inefficient but a potential security risk, as we don't yet have proof that your plugin should receive access to this user's data, and we don't want to trust only the browser to make this confirmation.

For this reason, postMessages themselves do not typically contain any sensitive information. You will normally receive only IDs to things, such as records, tables, the current user, etc. At most, you may receive the strings users have typed into filter conditions, for example.

In order to receive the actual data behind these IDs, you must make requests to our [GraphQL API](/graphql-api), just as you would if your app lived outside of Pitchly. For *that* to work, you must first possess proof that you have authorized access to this user's data. You do so by obtaining an access token.

Most of the time, the [per-user OAuth flow](/authentication#accessing-data-on-behalf-of-a-user) is most appropriate for plugins because the access it offers aligns with the current user who is using your plugin. After redirecting the user to the authorization endpoint and completing the OAuth flow, you can use the resulting access token to make API requests, informed by the postMessage events you receive.

## How to receive and send messages

To receive messages from Pitchly into your plugin, you will need to create a message event handler:

```javascript
window.addEventListener("message", function(event) {
  // Verify the message came from Pitchly
  if (event.origin!=="https://platform.pitchly.com") return;
  const message = event.data;
  // Replace saveInLocalState with your own function to save message data.
  // Messages from Pitchly are designed to work consistently with reactive
  // frameworks, so you can expect to receive several messages right after
  // you send the "ready" message to set the initial local state. Subsequent
  // events will trigger only individual messages to be sent as needed,
  // updating your local state as changes occur. Every message is guaranteed
  // to contain at least a type (string) and data (object).
  saveInLocalState(message.type, message.data);
});
```

To send a message to Pitchly from your plugin, simply call the following:

```javascript
const message = { type: "ready" }; // replace with your message
window.parent.postMessage(message, "https://platform.pitchly.com");
```

Importantly, Pitchly will only start sending your plugin messages after you send Pitchly the "ready" message. So most often, it makes sense to call your ready event right after you initialize your message listener, since your message listener is now ready to receive messages.

Below you can find a list of all messages that can be sent between your plugin and Pitchly.

## Plugin -> Pitchly messages

### Ready

Send this when your plugin is ready to receive messages.

```javascript
{ type: "ready" }
```

### Selected records

Send with a list of record IDs you would like to select in the current table.

If `recordIds` is `null`, indicates that a "select all" should be performed, which will select all records in the current [`view`](#current-view), not just the records showing on the current page.

To clear the current selection, `recordIds` should be an empty array.

```javascript
{ type: "selectedRecords", data: { recordIds: [] || null } }
```

## Pitchly -> Plugin messages

### Current organization

Gets sent with the ID of the current organization.

```javascript
{ type: "organization", data: { organizationId: null } }
```

### Current person

Gets sent with the ID of the current person logged in.

```javascript
{ type: "person", data: { personId: null } }
```

### Current workspace

Gets sent with the ID of the current workspace.

```javascript
{ type: "workspace", data: { workspaceId: null } }
```

### Current table

Gets sent with the ID of the current table being viewed.

```javascript
{ type: "table", data: { tableId: null } }
```

### Current table ready

Gets sent with a boolean indicating whether the current table is "ready".

```javascript
{ type: "tableReady", data: { isTableReady: false } }
```

{% hint style="warning" %}
**NOTE**: This differs from the [ready](#ready) event that is sent to Pitchly to indicate the plugin is ready to receive messages. The main purpose of this "tableReady" event is to indicate whether the user has marked this table as "ready" to be accessible by non-admins and apps in the workspace. The intention behind this functionality is to provide admins with the opportunity to configure a table's "default view" before the table is made available more broadly. When a table isn't ready, your plugin will be unable to get data about the table from the GraphQL API, but this event is still provided so that you can accommodate in the UI by, for example, showing a banner to tell the user their table hasn't been marked as "ready" yet.
{% endhint %}

### Current view

Gets the current view configuration representing the view of the data currently being displayed on the screen. Note that this does not represent a view being selected necessarily, but instead represents the current view config. So if the user changes filter or sort criteria manually, for example, you will receive another updated view representing that change in state.

When not empty, each property will be an object representing the associated item.

```javascript
{ type: "view", data: { filter: null, sort: null, visibleFields: null, fieldOrder: null, columnWidths: null } }
```

### Currently active data cell

Gets sent with the field and record ID of the currently active data cell (the cell highlighted with a border around it).

```javascript
{ type: "activeCell", data: { recordId: null, fieldId: null } }
```

{% hint style="info" %}
If there are no records in the current view, `recordId` and `fieldId` will both be `null`. If at least one data cell becomes available again, the first data cell will automatically become active again.
{% endhint %}

### Selected records

Gets sent with a list of record IDs the user has selected in the current table.

If `recordIds` is `null`, indicates that a "select all" has been performed, which will select all records in the current [`view`](#current-view), not just the records showing on the current page.

`recordIds` will be an empty array if no selection is made.

```javascript
{ type: "selectedRecords", data: { recordIds: [] || null } }
```

### Record pagination

Gets sent with before or after cursor indicating where the current records data begins for the purposes of pagination.

```javascript
{ type: "recordPagination", data: { before: null, after: null } }
```

### Plugin focus state

Gets sent when the plugin becomes visible (`isFocused` true) or hidden (`isFocused` false). Note that when a plugin is closed in Pitchly, it may remain running in the background and continue to receive postMessages. Detecting the `focus` state is useful for doing something when the user returns to the plugin, or if you want to stop certain tasks from running while the plugin isn't visible.

```javascript
{ type: "focus", data: { isFocused: true } }
```

## Security precautions

One potential edge case to consider when displaying your plugin in Pitchly is that, sometimes, the Pitchly account the user used to log into your plugin may not match the account they're currently logged in with in Pitchly. You can easily check this scenario by verifying whether the [`personId`](#current-person) we sent you matches the ID of the Person currently logged into your plugin. If they do not match, then you should log the user out of their Pitchly account in your plugin and have them re-login.

Although this isn't a security risk in the traditional sense of giving an unauthorized user access to your user's data, what could happen is one of the user's accounts could inadvertently share data about another account that the same physical user has also logged in with. While confidential data would not be shared, information such as filters used, view criteria, etc. could be shared just by the nature of how postMessages work and deliver information across frames.

While this is only a problem if the same user has multiple accounts, it is best to avoid this scenario altogether by simply checking whether the accounts between your plugin and Pitchly match, and automatically log the user out if they don't.

{% hint style="info" %}
Note that if you keep receiving errors such as "You do not have access to this resource" from your API calls when making GraphQL queries about the IDs you're receiving via postMessages, being logged into Pitchly and your plugin with different accounts can often be the cause.
{% endhint %}


# V2 Migration Guide

If you have previously used Pitchly's V2 API and would like to learn how to move to the V4 API, this guide can act as a cheatsheet to help you during that migration process, whether you're adding support for V4 or replacing your current V2 implementation with V4.

{% hint style="info" %}
If you have any questions about migrating from V2 to V4, please don't hesitate to [reach out to us](https://www.pitchly.com/contact). Our staff is happy to help in any way we can.
{% endhint %}

## Authentication

In V2, you generate a Client ID and Secret by registering a new key set. In V4, you will need to [contact us](https://www.pitchly.com/contact) to get a Client ID and Secret, since we are rolling out our V4 API on a controlled basis temporarily.

Once you have a Client ID and Secret, the way you authenticate will depend on your desired flow:

<details>

<summary>I only want to use the API to access my own organization's data</summary>

In V2, this was previously accomplished by just passing your Client Secret as the `secretKey` parameter in your GraphQL variables.

In V4, we now use [JSON Web Tokens (JWTs)](/authentication#accessing-data-in-your-own-organization) to authenticate requests in cases where you want to use a one-time access token to your own organization. You will eventually pass this JWT as a Bearer token in the Authorization header of your GraphQL requests, [as shown in the following steps](#how-to-pass-access-tokens). The benefits of using JWTs are:

1. You can embed permission data in the access token itself
2. You can customize usage details of the token, such as when the access token expires, by using claims embedded in the token
3. Unlike secret keys, if your JWT is compromised, the attacker still wouldn't have access to your secret key, so they would be unable to generate new access tokens once your token expires or use your Client Secret for other nefarious purposes.

[Click here](/authentication#accessing-data-in-your-own-organization) to learn how to generate a JWT access token to access your own organization's data.

</details>

<details>

<summary>I want to use the API to access data in a client's organization</summary>

In both V2 and V4, you would typically use the OAuth authorization code flow to get an access token on a per-user basis, and then use that access token to get data from the user's organization. The process is nearly the same between both V2 and V4, with some exceptions:

1. The authorization code flow in [V4 now requires PKCE](/authentication#code_challenge). This is an extension to the OAuth standard that helps prevent forgery attacks. To use PKCE, you must now include a `code_challenge_method` and `code_challenge` parameter in the initial authorization redirect, and the subsequent code exchange request will now require a `code_verifier` parameter. [Click here](/authentication#step-1-redirect-the-user-to-our-authorization-page) for more details.
2. The `redirect_uri` parameter is now required (in both the initial redirect and code exchange request), consistent with OAuth standards. Before, if not provided, we would use the first registered Redirect URI.
3. The `state` parameter is now required in the initial authorize redirect.
4. The `organization_id` and `force_prompt` parameters are no longer respected. The user has the ability to choose the account they want to authenticate with. If their organization has already approved access to your app, the user will be automatically authorized without being prompted.

In V2, access tokens would remain valid for two hours. In V4, this has been changed to one hour.

In V4, the authorization code flow can now be used regardless or whether you have a server backend or not, which was not the case in V2. To learn how to use it when you don't have a server backend (or your app otherwise can't securely keep a secret), [click here](/authentication#my-app-is-an-spa-mobile-or-desktop-app-that-cannot-keep-a-secret).

[Click here](/authentication#accessing-data-on-behalf-of-a-user) to learn how to use the authorization code flow in V4.

</details>

{% hint style="warning" %}
If your app used the implicit OAuth flow in V2 (`response_type=token`), this flow has been [deprecated in accordance with updated OAuth standards](https://oauth.net/2/grant-types/implicit/) in favor of the authorization code flow. In V4, the authorization code flow can now be used regardless of whether you have a server backend or not. In the past, the implicit flow was the only option if your app did not have a server backend. [Learn more about how to implement the authorization code flow in V4](/authentication#accessing-data-on-behalf-of-a-user).
{% endhint %}

### How to pass access tokens

Once you have an access token, it's now time to use it.

In V2, depending on whether you were accessing data in your own organization or opted to use the OAuth authorization code flow, you would pass either your Client Secret directly in the `secretKey` parameter or your access token in the `accessToken` parameter in the variables of your GraphQL request.

In V4, you now pass all access tokens as a Bearer token in the Authorization header of every request, as shown below. [See a full example request](/graphql-api#example-request).

```javascript
const headers = {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${accessToken}`
};
```

The location of the GraphQL endpoint has also changed in V4. It is now:

```
https://api.pitchly.com/graphql
```

### Error handling & token refresh

In V4, errors are also now delivered differently. Errors now take the following form:

```json
{
  "errors": [
    {
      "message": "You must be authenticated to access this resource. Please provide a valid Bearer Token in the Authorization header.",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "workspace"
      ],
      "extensions": {
        "code": "UNAUTHENTICATED"
      }
    }
  ],
  "data": null
}
```

In V2, you would normally receive a `token-invalid` error whenever a token was no longer valid. In V4, you will now receive an `UNAUTHENTICATED` error in `extensions.code` of the error response whenever the current access token is invalid.

If you are using the OAuth authorization code flow, when you receive an `UNAUTHENTICATED` error response, you have the option to refresh the user's access token by calling the "refresh\_token" grant.

The process of refreshing access tokens is nearly identical from V2 to V4. The only thing to note is that, if you downscope access tokens using the `scope` parameter, the list of permissions you can pass have changed in V4. [See the entire list of possible permissions](/authentication#permissions).

## GraphQL requests

While the process of using our GraphQL API is very similar between V2 and V4 and many of the core objects remain the same, the structure of requests have changed significantly to reflect the new capabilities and data structures in V4. This is also due in part to accommodate for scale. The V4 API utilizes more of the advantages of GraphQL, such as nested objects instead of only providing IDs to objects, which then must be separately queried, like in V2. You are generally able to get more data for less effort in V4.

Here, we include only a few common requests to show how they have changed from V2 to V4. To see the full documentation of our V4 API, [visit our schema reference](https://studio.apollographql.com/public/pitchly/variant/main/home).

<details>

<summary>Getting Database/Table fields</summary>

Note that IDs are now represented by "id" instead of "\_id". Also note that instead of only providing the database ID of reference fields, we now return the actual table object that reference fields refer to in V4.

Note: The terminology has changed between V2 and V4. We now refer to Databases as Tables in V4. [See all terminology changes](#terminology-changes).

#### V2

```graphql
query($secretKey: String!, $id: ID!) {
  database(secretKey: $secretKey, id: $id) {
    _id
    name
    fields {
      _id
      name
      description
      type
      primary
      required
      restrict
      database
    }
  }
}
```

#### V4

```graphql
query($id: ID!) {
  table(id: $id) {
    id
    name
    fields {
      id
      name
      description
      type
      primary
      required
      ... on EnumField {
        choices
      }
      ... on ReferenceField {
        table {
          id
          name
        }
      }
    }
  }
}
```

</details>

<details>

<summary>Getting Records in a Database/Table</summary>

In V4, any property ending in "Connection" will return its data in a consistent pagination format. This format is a [GraphQL standard](https://graphql.org/learn/pagination/) for pagination. Note that the pagination strategy has been changed from `skip`/`limit` to a cursor-based pagination strategy that now uses `first`/`after`. `first` functions similarly to `limit`. `after` should generally be the last `cursor` of the previous result. [recordsConnection](https://studio.apollographql.com/public/pitchly/variant/main/schema/reference/objects/Query#recordsConnection) will return results that come after the `after` cursor you specify.

If you don't want to return all the actual record data but would like to just get the end cursor, you can use `pageInfo.endCursor` as a shortcut to the last cursor on the current page. `hasNextPage` and `hasPreviousPage` are also boolean values that indicate whether or not there are more results before or after the current page. Note that you can also paginate backwards by using the parameters `last` and `before`, which function similarly to `first` and `after` except they allow you to paginate backwards.

`totalCount` also returns the total results matching the result set, not just the number of results on the current page. You can use this to get a total record count instead of having to make a separate request to `dataCount`.

Note that not all options for recordsConnection are shown here. [Click here](https://studio.apollographql.com/public/pitchly/variant/main/schema/reference/objects/Query#recordsConnection) to see all parameter options.

#### V2

```graphql
query($secretKey: String!, $databaseId: ID!, $filter: JSON, $sort: JSON, $limit: Int, $skip: Int, $in: [String], $fields: [String], $removedAt: Boolean, $search: String) {
  data(secretKey: $secretKey, databaseId: $databaseId, filter: $filter, sort: $sort, limit: $limit, skip: $skip, in: $in, fields: $fields, removedAt: $removedAt, $search: String) {
    _id
    cols {
      fieldId
      value
    }
  }
}
```

#### V4

```graphql
query($tableId: ID!, $first: Int, $after: String) {
  recordsConnection(tableId: $tableId, first: $first, after: $after) {
    edges {
      node {
        id
        fields {
          fieldId
          stringValue
          value
        }
      }
      cursor
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
    totalCount
  }
}
```

</details>

<details>

<summary>Get a list of available Templates from Documents/Elements app</summary>

In V4, templates are now identified by ID instead of a path.

#### V2

```graphql
query($secretKey: String!, $id: ID!) {
  database(secretKey: $secretKey, id: $id) {
    _id
    templates {
      path
      name
    }
  }
}
```

#### V4

```graphql
query($id: ID!) {
  table(id: $id) {
    id
    ... on WorkspaceTable {
      contentTemplates {
        id
        name
      }
    }
  }
}
```

</details>

<details>

<summary>Get generated Content from the Documents/Elements app</summary>

In V2, the content for all templates were previously returned. In V4, you must specify the template. This change was made to increase the performance of results returned. If you wish to get the content for all templates still, you can iterate over all of the [contentTemplates](#get-a-list-of-available-templates-from-documents-elements-app) and get their associated content.

Notably, content is now returned as part of record data instead of being a standalone endpoint that replicated a lot of the functionality of records. This means you can now more easily get record data and content together in the same record object without making multiple requests, if you choose, and you can use all of the same parameters offered by [recordsConnection](https://studio.apollographql.com/public/pitchly/variant/main/schema/reference/objects/Query#recordsConnection).

There is also no longer a "default" template because the template name is no longer stored in the database in V4. We now also provide a "hash", which is guaranteed to be the same until either the template or data changes, resulting in new content. "path" was also changed to a template ID.

#### V2

```graphql
query($databaseId: ID!, $secretKey: String!, $filter: JSON, $limit: Int, $skip: Int, $in: [String], $sort: JSON, $search: String, $templatePaths: [String]) {
  content(databaseId: $databaseId, secretKey: $secretKey, filter: $filter, limit: $limit, skip: $skip, in: $in, sort: $sort, search: $search, templatePaths: $templatePaths) {
    _id
    templates {
      name
      path
      pptxUrl
      imageUrl  
      default          
    }
  }
}
```

#### V4

```graphql
query($tableId: ID!, $templateId: ID!) {
  recordsConnection(tableId: $tableId) {
    edges {
      node {
        id
        content(templateId: $templateId) {
          pptxURL
          hash
          imageURLs {
            original
          }
        }
      }
    }
  }
}
```

</details>

## Terminology changes

Below are common product terms and their associated changes between V2 and V4.

| V2            | V4           |
| ------------- | ------------ |
| Organization  | Organization |
| N/A           | Workspaces   |
| Databases     | Tables       |
| Data/Rows     | Records      |
| Columns       | Fields       |
| Content       | Content      |
| Saved Filters | Views        |
| User          | Person       |
| N/A           | Team         |
| App           | App          |
| App           | Plugin       |
| Integrations  | Integrations |
| Integrations  | Automations  |

## Help is available

If you have any questions about migrating from V2 to V4, please don't hesitate to [reach out to us](https://www.pitchly.com/contact). We are happy to help in any way we can, and we are with you every step of the way.


# Pitchly Field Types/Returns

The following are all the field types in Pitchly, and sample return values when returned in a recordsConnection query.

\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-38-53-9155-PM.png?width=21\&height=24\&name=image-png-Mar-08-2024-08-38-53-9155-PM.png) | <p><strong>Single-line Text:</strong> Short single-line text<br><br><strong>Sample Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldpLdoGAFzJae8BfTG7","value":{"val":"Name"},"stringValue":"Name"}</p>                                                                                                                                                                                                            |
\| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-48-07-7091-PM.png?width=24\&height=23\&name=image-png-Mar-08-2024-08-48-07-7091-PM.png) | <p><strong>Number:</strong> Number with possible decimal<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldFawgZ4fMZC7PRhjbE","value":{"val":2300},"stringValue":"2,300"}</p>                                                                                                                                                                                                                        |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-40-18-8579-PM.png?width=22\&height=21\&name=image-png-Mar-08-2024-08-40-18-8579-PM.png) | <p><strong>Yes/No:</strong> Binary yes/no value<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldPSYXsMQY9qhLZB2Cg","value":{"val":true},"stringValue":"Yes"}</p>                                                                                                                                                                                                                                   |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-49-50-5552-PM.png?width=27\&height=20\&name=image-png-Mar-08-2024-08-49-50-5552-PM.png) | <p><strong>Multi-line Text:</strong> Long multi-line text (can store clickable URLs)<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldpLdoGAFzJae8BfTG7","value":{"val":"longer text"},"stringValue":"longer text"}</p>                                                                                                                                                                             |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-41-45-8490-PM.png?width=26\&height=24\&name=image-png-Mar-08-2024-08-41-45-8490-PM.png) | <p><strong>Dropdown:</strong> Picklist of predefined choices (limited to one selection)<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldnkQKPuSETPib3CPYm","value":{"val":"Lincoln"},"stringValue":"Lincoln"}</p>                                                                                                                                                                                  |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-53-50-0429-PM.png?width=25\&height=24\&name=image-png-Mar-08-2024-08-53-50-0429-PM.png) | <p><strong>Dropdown Multiple:</strong> Picklist of predefined choices (multiple selections available)<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldeZsZWHw7yYuBjCgTf","value":{"val":\["Lincoln","Omaha"]},"stringValue":\["Lincoln","Omaha"]}<br></p>                                                                                                                                            |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-43-38-4526-PM.png?width=24\&height=24\&name=image-png-Mar-08-2024-08-43-38-4526-PM.png) | <p><strong>Date:</strong> Date in the format MM/DD/YYYY<br><br><strong>Return Value:</strong></p>                                                                                                                                                                                                                                                                                                                               |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-47-09-0416-PM.png?width=16\&height=25\&name=image-png-Mar-08-2024-08-47-09-0416-PM.png) | <p><strong>Currency:</strong> Currency amount with symbol<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldxmggqWFyYsdTXjgRw","value":{"val":23045,"currency":"USD"},"stringValue":"$23,045"}</p>                                                                                                                                                                                                   |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-42-37-9413-PM.png?width=21\&height=23\&name=image-png-Mar-08-2024-08-42-37-9413-PM.png) | <p><strong>Attachment:</strong> Any type of file attachment, including images. <code>type</code> is the mime type of the file, and <code>size</code> is the size of the file in bytes. <code>val</code> is the URL of the file.<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldxmggqWFyYsdTXjgRw","value":{"val":"https\://..., "type":"image/jpeg", size: 50000},"stringValue":"https\://..."}</p> |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-45-36-5479-PM.png?width=24\&height=25\&name=image-png-Mar-08-2024-08-45-36-5479-PM.png) | <p><strong>Reference:</strong> Creates a relationship/link to one record in another table<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldqrYiD9XpWw8AQkhEf","value":{"val":"wksdMHiQmzTt6Dhf9N77 recguqydmvmsWfCkJxHu"},"stringValue":"Jacob"}<br></p>                                                                                                                                            |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-44-37-2760-PM.png?width=28\&height=24\&name=image-png-Mar-08-2024-08-44-37-2760-PM.png) | <p><strong>Reference Multiple:</strong> Creates a relationship/link to multiple records in another table<br><br><strong>Return Value:</strong></p><p>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldKp2RtY9XnVw7mLs4Q","value":{"val":\["wksdMHiQmzTt6Dhf9N77recguqydmvmsWfCkJxHu","wksdMHiQmzTt6Dhf9N77recM4ria9LpqZvTnBxKe2"]},"stringValue":\["Jacob","Maria"]}</p>                                                                       |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-48-56-5531-PM.png?width=27\&height=23\&name=image-png-Mar-08-2024-08-48-56-5531-PM.png) | <p><strong>Lookup:</strong> Read-only subfield pulled from a referenced table<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldLk8QmzTt6Dhf9N7zXa","value":{"val":"Acme Inc"},"stringValue":"Acme Inc"}</p>                                                                                                                                                                                         |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-55-53-3822-PM.png?width=23\&height=24\&name=image-png-Mar-08-2024-08-55-53-3822-PM.png) | <p><strong>Created At:</strong> Displays creation date and time<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldCr8AtZ9XmVw7mLs4Q","value":{"val":"2026-06-17T14:30:00.000Z"},"stringValue":"2026-06-17 2:30 pm"}</p>                                                                                                                                                                              |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-54-43-8230-PM.png?width=23\&height=21\&name=image-png-Mar-08-2024-08-54-43-8230-PM.png) | <p><strong>Updated At:</strong> Displays date and time of last update<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldUp8AtZ9XmVw7mLs4Q","value":{"val":"2026-06-17T18:45:00.000Z"},"stringValue":"2026-06-17 6:45 pm"}</p>                                                                                                                                                                        |
\| ![](https://support.pitchly.com/hs-fs/hubfs/image-png-Mar-08-2024-08-51-11-1728-PM.png?width=25\&height=22\&name=image-png-Mar-08-2024-08-51-11-1728-PM.png) | <p><strong>Formula:</strong> Calculates values based on predefined logic<br><br><strong>Return Value:</strong><br>{"fieldId":"wksdMHiQmzTt6Dhf9N77|fldFm8La9XpWw8AQkhEf","value":{"val":1234.5},"stringValue":"1,234.5"}</p>                                                                                                                                                                                                    |


