> For the complete documentation index, see [llms.txt](https://docs.ativo.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ativo.io/rest-api/cloud-api-setup/sample-token-exchange-script.md).

# Sample token exchange script

A single Node.js file for [Cloud setup](/rest-api/cloud-api-setup.md) :

* One-time consent: obtaining the initial code
* Refresh token rotation
* Calling the API with an access token

## Before you start

* The [Cloud setup](/rest-api/cloud-api-setup.md) steps 1 and 2 are done, and the **callback URL** of your OAuth 2.0 integration is filled in as `http://localhost:8080/callback` (step 3, point 1).
* You have the **client id** and **secret** of the integration (see Atlassian console > settings) , and the **base URL** from the Ativo REST API admin page (Jira settings > Marketplace apps > Ativo Programs > REST API).
* Node.js 24 or newer is installed.

{% stepper %}
{% step %}

## Create the files

Make an empty folder, for example `ativo-api`, with two files.

**`ativo-api.js`**: the script, copied from [the bottom of this page](#the-script).

**`.env`**: your settings. A `.env` file is a plain text file with one `NAME=value` per line. It keeps your secrets out of the script, so the script can be shared or committed and the secrets cannot.

```
ATIVO_API_CLIENT_ID=<client id>
ATIVO_API_CLIENT_SECRET=<secret>
ATIVO_API_BASE_URL=https://<your-site>.atlassian.net/gateway/api/svc/jira/apps/<app-id>_<environment-id>/rest/api/1
```

No quotes, no spaces around `=`. Copy the base URL exactly as the admin page shows it.

{% hint style="danger" %}
The `.env` file and the `ativo-refresh-token.json` file that the script creates are credentials. Never commit them to git and never share them. In a git repository, add both names to `.gitignore` .
{% endhint %}
{% endstep %}

{% step %}

## Consent, once

Open a terminal in the folder and run:

```sh
node --env-file=.env ativo-api.js consent
```

`--env-file=.env` tells Node.js to load your settings; it is built in, nothing to install.

1. The script prints a long URL. Open it in your browser.
2. Atlassian asks you to choose your site and lists what the integration may read in Jira and in Ativo Programs. Click **Accept**.
3. The browser tab shows "Consent received, you can close this tab."
4. The terminal shows the granted scopes and "Consent done". A new file `ativo-refresh-token.json` now sits next to the script.

You do this once. Do it again only if the script was not used for 90 days, or if access was revoked.
{% endstep %}

{% step %}

## Call the API

```sh
node --env-file=.env ativo-api.js
```

Each run gets fresh tokens, saves the new refresh token, and prints the data model version and your teams as JSON. Run it twice: the second run proves that the refresh token rotation works.
{% endstep %}

{% step %}

## Make it yours

* **Another endpoint:** pass the path, for example `node --env-file=.env ativo-api.js /program`. All paths are in [Endpoints](/rest-api/rest-api-endpoints.md).
* **Your own logic:** replace the `console.log` in `main` by what you need, for example writing a file or feeding another system. `callApi` returns the parsed JSON.
* **On a schedule:** run the same command from Windows Task Scheduler or cron. Set the working folder to the script folder so that `.env` is found; the token file is always written next to the script. Run it at least once every 90 days, otherwise the consent of step 2 must be repeated.
  {% endstep %}
  {% endstepper %}

## If something goes wrong

| Message                                  | Meaning                                                                                       | What to do                                                                                                                                                  |
| ---------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Missing ATIVO_API_...`                  | The setting is empty, or `--env-file=.env` was left out                                       | Check the `.env` file and the command                                                                                                                       |
| `EADDRINUSE ... 8080`                    | Another program on your machine uses port 8080                                                | Pick a free port: add `ATIVO_API_CALLBACK_URL=http://localhost:8765/callback` to `.env` and the same URL as callback URL in the Atlassian developer console |
| `The Ativo scopes were not granted`      | The integration lacks the Ativo scopes                                                        | Add them under Permissions ([Cloud setup](/rest-api/cloud-api-setup.md) step 2), consent again                                                              |
| `No refresh token yet`                   | The consent was never done in this folder                                                     | Run the `consent` command                                                                                                                                   |
| `Token request failed ... invalid_grant` | The refresh token expired (90 days unused), was already used elsewhere, or access was revoked | Run the `consent` command again                                                                                                                             |
| `API call failed: 403 ...`               | App REST APIs are off for the site, or a scope is missing                                     | See the troubleshooting table of [Cloud setup](/rest-api/cloud-api-setup.md)                                                                                |

## Example script

```js
// ativo-api.js - Ativo Programs REST API client on Jira Cloud: one-time consent, then refresh and call.
// Settings come from the .env file (node --env-file=.env ...): never put secrets in this file.
const fs = require("fs");
const http = require("http");
const path = require("path");

// 1. Settings
const CLIENT_ID = requiredSetting("ATIVO_API_CLIENT_ID");
const CLIENT_SECRET = requiredSetting("ATIVO_API_CLIENT_SECRET");
const BASE_URL = requiredSetting("ATIVO_API_BASE_URL");
const CALLBACK_URL = process.env.ATIVO_API_CALLBACK_URL || "http://localhost:8080/callback"; // as configured in the Atlassian console

const SCOPES = "read:configuration:custom read:plan:custom read:forge-app:jira offline_access";
// "<app-id>.<environment-id>", taken from the base URL: tells Atlassian that the two Ativo scopes belong to Ativo Programs
const APP_AND_ENVIRONMENT = BASE_URL.split("/apps/")[1].split("/")[0].replace("_", ".");
const TOKEN_URL = "https://auth.atlassian.com/oauth/token";
const TOKEN_FILE = path.join(__dirname, "ativo-refresh-token.json"); // a credential: never commit or share it

function requiredSetting(name) {
    const value = process.env[name];
    if (!value || value.startsWith("<")) {
        console.error("Missing " + name + ": fill it in in the .env file and run with --env-file=.env");
        process.exit(1);
    }
    return value;
}

async function main() {
    const argument = process.argv[2];
    if (argument === "consent") {
        await consentOnce();
    } else {
        const accessToken = await refreshAccessToken();
        const result = await callApi(accessToken, argument || "/team");
        console.log(JSON.stringify(result, null, 2));
    }
}

// 2. One-time consent: print the authorization URL, wait for the browser to come back with the code, exchange it.
async function consentOnce() {
    const authorizationUrl = "https://auth.atlassian.com/authorize"
        + "?audience=api.atlassian.com&client_id=" + encodeURIComponent(CLIENT_ID)
        + "&scope=" + encodeURIComponent(SCOPES)
        + "&redirect_uri=" + encodeURIComponent(CALLBACK_URL)
        + "&state=consent&response_type=code&prompt=consent"
        + "&sns=" + encodeURIComponent(APP_AND_ENVIRONMENT);
    console.log("Open this URL in your browser, choose your site and accept:\n\n" + authorizationUrl + "\n");

    const code = await waitForCodeOnCallback();
    const tokens = await requestTokens({grant_type: "authorization_code", code, redirect_uri: CALLBACK_URL});
    console.log("Granted scopes: " + tokens.scope);
    if (!tokens.scope.includes(":custom")) throw new Error("The Ativo scopes were not granted: check the scopes of the integration in the developer console.");
    saveRefreshToken(tokens.refresh_token);
    console.log("Consent done. Run the script without 'consent' from now on.");
}

// A tiny web server on the callback URL, so the code does not have to be copied by hand.
function waitForCodeOnCallback() {
    return new Promise((resolve, reject) => {
        const server = http.createServer((request, response) => {
            const url = new URL(request.url, CALLBACK_URL);
            const code = url.searchParams.get("code");
            response.end(code ? "Consent received, you can close this tab." : "No code in the request.");
            if (!code) return;
            server.close();
            resolve(code);
        });
        server.on("error", reject);
        server.listen(new URL(CALLBACK_URL).port);
    });
}

// 3. Every run: exchange the stored refresh token for a new access token AND a new refresh token, and keep the new one.
async function refreshAccessToken() {
    if (!fs.existsSync(TOKEN_FILE)) throw new Error("No refresh token yet: run the script with 'consent' first.");
    const stored = JSON.parse(fs.readFileSync(TOKEN_FILE, "utf8"));
    const tokens = await requestTokens({grant_type: "refresh_token", refresh_token: stored.refresh_token});
    saveRefreshToken(tokens.refresh_token);
    return tokens.access_token;
}

async function requestTokens(grant) {
    const response = await fetch(TOKEN_URL, {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify({client_id: CLIENT_ID, client_secret: CLIENT_SECRET, ...grant}),
    });
    const body = await response.json();
    if (!response.ok) throw new Error("Token request failed: " + JSON.stringify(body));
    return body;
}

function saveRefreshToken(refreshToken) {
    fs.writeFileSync(TOKEN_FILE, JSON.stringify({refresh_token: refreshToken}));
}

// 4. The API call itself: the bearer header is all the API needs.
async function callApi(accessToken, apiPath) {
    const response = await fetch(BASE_URL + apiPath, {
        headers: {"Authorization": "Bearer " + accessToken, "Accept": "application/json"},
    });
    if (!response.ok) throw new Error("API call failed: " + response.status + " " + await response.text());
    console.log("Data model version: " + response.headers.get("X-API-Model-Version"));
    const text = await response.text();
    return text === "" ? null : JSON.parse(text);
}

main().catch(error => {
    console.error(error.message);
    process.exit(1);
});
```

## How it maps onto the guide

| Script                                                                                | Guide                                                                       |
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `consentOnce` builds the authorization URL, `waitForCodeOnCallback` receives the code | Step 3, points 2 and 3, without editing the URL or copying the code by hand |
| `requestTokens` with `authorization_code`                                             | Step 3, point 4                                                             |
| `refreshAccessToken` and `saveRefreshToken`                                           | Step 4, including the rule to keep the rotated token                        |
| `callApi`                                                                             | Step 3, point 5, and every later call                                       |

## For production use

* Keep the client secret and the refresh token in your organisation's secret store rather than in files next to the script.
* If several copies of the script can run at the same time, let only one of them refresh: each refresh token can be used once.
* Check the data model version the script prints, and stop or warn when it changes, see [Version](/rest-api/api-version.md).
