This guide explains how to export Time in Status report data via API and automatically import it into Google Sheets.
Overview
With the API-based data feed:
-
Data is generated on demand when the API is called
-
Access is secured via OAuth tokens
-
The response format remains JSON
-
You can connect it directly to Google Sheets using Apps Script
π Step 1: Enable API Access
-
Go to Jira Administration
-
Navigate to:
Connected Apps β Time in Status -
Enable REST API
π Step 2: Generate Data Feed Link
-
Open your Time in Status report
-
Save your preset
-
Open the Data Feed window
-
Click Generate link
π You will receive a new API URL like:
https://<site-name>/gateway/api/svc/<product>/apps/<app-id>/<path>
π This link is used to fetch your report data.
π Step 3: Authorize & Get Tokens
-
Click Authorize
-
Approve access on the Atlassian consent screen
-
After redirect, copy the Authorization Code
β οΈ Important:
-
Code expires in 5 minutes
Exchange Code for Tokens
Use a tool like Postman to get:
-
Access Token (valid for 1 hour)
-
Refresh Token (valid for 90 days)
These tokens are required for API requests.
π Step 4: Import Data into Google Sheets
Quick Setup
-
Open Google Sheets
-
Go to Extensions β Apps Script
-
Paste the provided script
// ============================================================
// JIRA β Google Sheets | OAuth 3LO with token refresh
// Paste this entire file into Apps Script (Extensions β Apps Script)
// ============================================================
// ββ 1. CONFIGURATION ββββββββββββββββββββββββββββββββββββββββ
// Fill in your values here. Leave tokens blank if you prefer
// to set them via the menu (recommended for security).
const CONFIG = {
// Your Atlassian OAuth 2.0 app credentials
// Found at: https://developer.atlassian.com/console/myapps/
CLIENT_ID: "", // e.g. "m507qTf5IM8PNpE0WCYKyEj0RQYddbGq"
CLIENT_SECRET: "", // e.g. "ATOAxxxxxx..."
// Name of the sheet tab where data will be written
SHEET_NAME: "Jira Data",
};
// NOTE: DATA_FEED_URL is stored securely in Script Properties.
// Set it via: π Jira Sync β βοΈ Save Data Feed URL
// Atlassian token endpoint
const TOKEN_URL = "https://auth.atlassian.com/oauth/token";
// ββ 2. TOKEN HELPERS (stored securely in Script Properties) ββ
function getProps() {
return PropertiesService.getScriptProperties();
}
function saveTokens(accessToken, refreshToken) {
const props = getProps();
props.setProperty("ACCESS_TOKEN", accessToken);
props.setProperty("REFRESH_TOKEN", refreshToken);
Logger.log("β
Tokens saved to Script Properties.");
}
function getAccessToken() { return getProps().getProperty("ACCESS_TOKEN"); }
function getRefreshToken() { return getProps().getProperty("REFRESH_TOKEN"); }
function getDataFeedUrl() { return getProps().getProperty("DATA_FEED_URL"); }
// ββ 3. TOKEN EXPIRY CHECK βββββββββββββββββββββββββββββββββββββ
// How many days before expiry to start warning
const WARN_DAYS_BEFORE = 7;
/**
* Decodes a JWT and returns its payload as an object.
* Works without any external library β JWTs are just base64url-encoded JSON.
*/
function decodeJwtPayload(token) {
try {
const base64Url = token.split(".")[1];
// base64url β base64
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const json = Utilities.newBlob(Utilities.base64Decode(base64)).getDataAsString();
return JSON.parse(json);
} catch (e) {
Logger.log("β οΈ Could not decode JWT: " + e.message);
return null;
}
}
/**
* Returns the number of days until the refresh token expires.
* Returns null if the token cannot be decoded.
*/
function getRefreshTokenDaysLeft() {
const token = getRefreshToken();
if (!token) return null;
const payload = decodeJwtPayload(token);
if (!payload || !payload.exp) return null;
const expiresAt = new Date(payload.exp * 1000);
const now = new Date();
const msLeft = expiresAt - now;
return Math.floor(msLeft / (1000 * 60 * 60 * 24));
}
/**
* Checks the refresh token expiry and:
* - Sends an email warning if within WARN_DAYS_BEFORE days
* - Shows a popup if called from the UI (showUiAlert = true)
* Called automatically by syncJiraData() and by the daily warning trigger.
*/
function checkTokenExpiry(showUiAlert) {
const daysLeft = getRefreshTokenDaysLeft();
if (daysLeft === null) {
Logger.log("β οΈ Could not determine refresh token expiry.");
return;
}
const expiryDate = new Date(decodeJwtPayload(getRefreshToken()).exp * 1000)
.toLocaleDateString();
Logger.log(`βΉοΈ Refresh token expires in ${daysLeft} day(s) (${expiryDate}).`);
if (daysLeft < 0) {
const msg = `π΄ Your refresh token EXPIRED on ${expiryDate}.\nYou must re-authorize via the 3LO flow and save new tokens.`;
Logger.log("π΄ " + msg);
if (showUiAlert) SpreadsheetApp.getUi().alert("Token Expired β", msg, SpreadsheetApp.getUi().ButtonSet.OK);
sendExpiryEmail("EXPIRED", expiryDate, daysLeft);
} else if (daysLeft <= WARN_DAYS_BEFORE) {
const msg = `β οΈ Your refresh token expires in ${daysLeft} day(s) (${expiryDate}).\nPlease re-authorize soon and save new tokens via the menu.`;
Logger.log("β οΈ " + msg);
if (showUiAlert) SpreadsheetApp.getUi().alert("Token Expiry Warning β οΈ", msg, SpreadsheetApp.getUi().ButtonSet.OK);
sendExpiryEmail("WARNING", expiryDate, daysLeft);
}
}
/**
* Sends an email to the script owner when the token is expiring soon or expired.
* Only sends once per day to avoid spam (tracked via Script Properties).
*/
function sendExpiryEmail(type, expiryDate, daysLeft) {
const props = getProps();
const today = new Date().toDateString();
const lastKey = "LAST_EXPIRY_EMAIL_" + type;
// Don't send more than once per day
if (props.getProperty(lastKey) === today) return;
props.setProperty(lastKey, today);
const email = Session.getEffectiveUser().getEmail();
const subject = type === "EXPIRED"
? "π΄ Jira Sync: Refresh token has EXPIRED"
: `β οΈ Jira Sync: Refresh token expires in ${daysLeft} day(s)`;
const body = type === "EXPIRED"
? `Your Jira sync refresh token expired on ${expiryDate}.\n\nThe daily sync has stopped working. Please re-authorize via the 3LO flow and save new tokens using:\n π Jira Sync β βοΈ Save tokens`
: `Your Jira sync refresh token will expire on ${expiryDate} (in ${daysLeft} day(s)).\n\nPlease re-authorize soon via the 3LO flow and save new tokens using:\n π Jira Sync β βοΈ Save tokens`;
GmailApp.sendEmail(email, subject, body);
Logger.log(`π§ Expiry warning email sent to ${email}.`);
}
/**
* Menu action: show token status in a popup.
*/
function showTokenStatus() {
const daysLeft = getRefreshTokenDaysLeft();
const ui = SpreadsheetApp.getUi();
if (daysLeft === null) {
ui.alert("Token Status", "No refresh token found. Please save your tokens via βοΈ Save tokens.", ui.ButtonSet.OK);
return;
}
const expiryDate = new Date(decodeJwtPayload(getRefreshToken()).exp * 1000).toLocaleDateString();
let icon, status;
if (daysLeft < 0) {
icon = "π΄"; status = `EXPIRED on ${expiryDate}`;
} else if (daysLeft <= WARN_DAYS_BEFORE) {
icon = "β οΈ"; status = `Expires in ${daysLeft} day(s) β ${expiryDate}`;
} else {
icon = "β
"; status = `Valid for ${daysLeft} more day(s) β expires ${expiryDate}`;
}
ui.alert("Token Status " + icon, `Refresh token: ${status}`, ui.ButtonSet.OK);
}
// ββ 4. REFRESH THE ACCESS TOKEN ββββββββββββββββββββββββββββββ
function refreshAccessToken() {
const refreshToken = getRefreshToken();
if (!refreshToken) throw new Error("No refresh token stored. Run Setup β Save Tokens first.");
const clientId = CONFIG.CLIENT_ID || getProps().getProperty("CLIENT_ID");
const clientSecret = CONFIG.CLIENT_SECRET || getProps().getProperty("CLIENT_SECRET");
if (!clientId || !clientSecret) {
throw new Error("CLIENT_ID and CLIENT_SECRET are required. Set them in CONFIG or via the menu.");
}
const payload = {
grant_type: "refresh_token",
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
};
const response = UrlFetchApp.fetch(TOKEN_URL, {
method: "post",
contentType: "application/x-www-form-urlencoded",
payload: payload,
muteHttpExceptions: true,
});
const code = response.getResponseCode();
const body = JSON.parse(response.getContentText());
if (code !== 200) {
throw new Error(`Token refresh failed (${code}): ${JSON.stringify(body)}`);
}
// Atlassian rotates the refresh token on each use
saveTokens(body.access_token, body.refresh_token || refreshToken);
Logger.log("π Access token refreshed successfully.");
return body.access_token;
}
// ββ 4. FETCH DATA FROM THE API βββββββββββββββββββββββββββββββ
function fetchDataFeed(accessToken) {
const url = CONFIG.DATA_FEED_URL || getDataFeedUrl();
if (!url) throw new Error("No Data Feed URL stored. Use π Jira Sync β βοΈ Save Data Feed URL.");
const response = UrlFetchApp.fetch(url, {
method: "get",
headers: { Authorization: "Bearer " + accessToken },
muteHttpExceptions: true,
});
const code = response.getResponseCode();
// 401 = token expired β refresh and retry once
if (code === 401) {
Logger.log("β οΈ Access token expired. Refreshing...");
const newToken = refreshAccessToken();
return fetchDataFeed(newToken);
}
if (code !== 200) {
throw new Error(`API request failed (${code}): ${response.getContentText()}`);
}
return JSON.parse(response.getContentText());
}
// ββ 5. WRITE DATA TO SHEET ββββββββββββββββββββββββββββββββββββ
function writeToSheet(data) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
// Create the sheet if it doesn't exist
if (!sheet) {
sheet = ss.insertSheet(CONFIG.SHEET_NAME);
Logger.log(`π Created sheet: "${CONFIG.SHEET_NAME}"`);
}
sheet.clearContents();
// ββ Normalize the API response ββ
// The datafeed may return an array at the top level, or inside a key.
// Adjust the path below if your feed has a different structure.
let rows = [];
if (Array.isArray(data)) {
rows = data;
} else if (data.items && Array.isArray(data.items)) {
rows = data.items;
} else if (data.data && Array.isArray(data.data)) {
rows = data.data;
} else if (data.results && Array.isArray(data.results)) {
rows = data.results;
} else {
// Fallback: write raw JSON so you can inspect the structure
sheet.getRange(1, 1).setValue("Raw API Response (adjust writeToSheet to match your structure):");
sheet.getRange(2, 1).setValue(JSON.stringify(data, null, 2));
Logger.log("β οΈ Could not detect row array. Raw JSON written to sheet. Check the structure and update writeToSheet().");
return;
}
if (rows.length === 0) {
sheet.getRange(1, 1).setValue("No data returned by the API.");
return;
}
// Build header row from the keys of the first object
const headers = Object.keys(rows[0]);
const output = [headers];
// Build data rows
rows.forEach(row => {
const rowValues = headers.map(h => {
const val = row[h];
return (val !== null && typeof val === "object") ? JSON.stringify(val) : val;
});
output.push(rowValues);
});
// Write everything in one call (fast)
sheet.getRange(1, 1, output.length, output[0].length).setValues(output);
// Style the header row
const headerRange = sheet.getRange(1, 1, 1, headers.length);
headerRange.setFontWeight("bold");
headerRange.setBackground("#4a86e8");
headerRange.setFontColor("#ffffff");
sheet.setFrozenRows(1);
sheet.autoResizeColumns(1, headers.length);
// Timestamp
sheet.getRange(output.length + 2, 1).setValue("Last updated: " + new Date().toLocaleString());
Logger.log(`β
Written ${rows.length} rows Γ ${headers.length} columns to "${CONFIG.SHEET_NAME}".`);
SpreadsheetApp.getUi().alert(`β
Done! ${rows.length} rows imported to "${CONFIG.SHEET_NAME}".`);
}
// ββ 6. MAIN ENTRY POINT βββββββββββββββββββββββββββββββββββββββ
function syncJiraData() {
try {
let token = getAccessToken();
if (!token) throw new Error("No access token stored. Use Setup β Save Tokens first.");
const url = CONFIG.DATA_FEED_URL || getDataFeedUrl();
if (!url) throw new Error("No Data Feed URL stored. Use π Jira Sync β βοΈ Save Data Feed URL.");
// Warn if refresh token is close to expiry
checkTokenExpiry(true);
Logger.log("π Fetching data from Jira datafeed...");
const data = fetchDataFeed(token);
writeToSheet(data);
} catch (e) {
Logger.log("β Error: " + e.message);
SpreadsheetApp.getUi().alert("β Error:\n\n" + e.message);
}
}
// ββ 7. MENU + SETUP UI ββββββββββββββββββββββββββββββββββββββββ
function onOpen() {
SpreadsheetApp.getUi()
.createMenu("π Jira Sync")
.addItem("Sync now", "syncJiraData")
.addSeparator()
.addItem("βοΈ Save tokens", "promptSaveTokens")
.addItem("βοΈ Save OAuth creds", "promptSaveOAuthCreds")
.addItem("βοΈ Save Data Feed URL", "promptSaveDataFeedUrl")
.addSeparator()
.addItem("π Check token status", "showTokenStatus")
.addSeparator()
.addItem("β° Schedule daily sync", "createDailyTrigger")
.addItem("β° Schedule expiry check", "createExpiryCheckTrigger")
.addItem("ποΈ Remove all triggers", "removeAllTriggers")
.addToUi();
}
function promptSaveTokens() {
const ui = SpreadsheetApp.getUi();
const accessResp = ui.prompt(
"Save Access Token",
"Paste your current access token:",
ui.ButtonSet.OK_CANCEL
);
if (accessResp.getSelectedButton() !== ui.Button.OK) return;
const refreshResp = ui.prompt(
"Save Refresh Token",
"Paste your refresh token:",
ui.ButtonSet.OK_CANCEL
);
if (refreshResp.getSelectedButton() !== ui.Button.OK) return;
saveTokens(accessResp.getResponseText().trim(), refreshResp.getResponseText().trim());
ui.alert("β
Tokens saved securely in Script Properties.");
}
function promptSaveOAuthCreds() {
const ui = SpreadsheetApp.getUi();
const props = getProps();
const idResp = ui.prompt(
"Save OAuth Client ID",
"Paste your Atlassian OAuth Client ID:",
ui.ButtonSet.OK_CANCEL
);
if (idResp.getSelectedButton() !== ui.Button.OK) return;
const secResp = ui.prompt(
"Save OAuth Client Secret",
"Paste your Atlassian OAuth Client Secret:",
ui.ButtonSet.OK_CANCEL
);
if (secResp.getSelectedButton() !== ui.Button.OK) return;
props.setProperty("CLIENT_ID", idResp.getResponseText().trim());
props.setProperty("CLIENT_SECRET", secResp.getResponseText().trim());
ui.alert("β
OAuth credentials saved securely.");
}
function promptSaveDataFeedUrl() {
const ui = SpreadsheetApp.getUi();
const props = getProps();
// Pre-fill the current URL so the user can see and edit it
const current = props.getProperty("DATA_FEED_URL") || "";
const hint = current
? "Current URL (paste a new one to replace it):\n" + current
: "Paste the Data Feed URL from your Jira report:";
const resp = ui.prompt("Save Data Feed URL", hint, ui.ButtonSet.OK_CANCEL);
if (resp.getSelectedButton() !== ui.Button.OK) return;
const newUrl = resp.getResponseText().trim();
if (!newUrl) { ui.alert("No URL entered β nothing was saved."); return; }
props.setProperty("DATA_FEED_URL", newUrl);
ui.alert("β
Data Feed URL saved.\n\nYou can now run Sync now.");
}
// ββ 8. TIME-BASED TRIGGERS βββββββββββββββββββββββββββββββββββ
function createDailyTrigger() {
// Remove existing sync triggers first to avoid duplicates
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === "syncJiraData")
.forEach(t => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger("syncJiraData")
.timeBased()
.everyDays(1)
.atHour(8) // 08:00 in the script's timezone
.create();
SpreadsheetApp.getUi().alert("β° Daily sync scheduled for 8 AM.");
}
/**
* Sets up a daily trigger that checks token expiry and sends
* an email warning when the refresh token is close to expiring.
*/
function createExpiryCheckTrigger() {
// Remove existing expiry check triggers first
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === "dailyExpiryCheck")
.forEach(t => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger("dailyExpiryCheck")
.timeBased()
.everyDays(1)
.atHour(9) // 09:00 β runs after the sync trigger
.create();
SpreadsheetApp.getUi().alert("β° Daily token expiry check scheduled for 9 AM.\nYou will receive an email if the token is expiring within " + WARN_DAYS_BEFORE + " days.");
}
/**
* Called by the daily expiry check trigger.
* Does NOT show a UI popup (runs headlessly); sends email only.
*/
function dailyExpiryCheck() {
checkTokenExpiry(false);
}
function removeAllTriggers() {
ScriptApp.getProjectTriggers().forEach(t => ScriptApp.deleteTrigger(t));
SpreadsheetApp.getUi().alert("ποΈ All triggers removed.");
}
-
Save and refresh the spreadsheet
After refresh, you will see a new menu: π π Jira Sync
βοΈ Configure the Script
From the menu:
1. Save OAuth credentials
π Jira Sync β βοΈ Save OAuth creds
Add from step 3:
-
Client ID
-
Client Secret
2. Save tokens
π Jira Sync β βοΈ Save tokens
Add from step 3 (this is exactly what you exchanged in Postman):
-
Access token
-
Refresh token
3. Save Data Feed URL
π Jira Sync β βοΈ Save Data Feed URL
Paste your generated API link from step 2.
βΆοΈ Step 5: Run the Import
Click:
π Jira Sync β Sync now
β The script will:
-
Fetch data from API
-
Automatically refresh expired tokens
-
Write structured data into your sheet
-
Format headers and add timestamp
π Automation (Optional)
You can automate syncing:
Daily data refresh
π Jira Sync β β° Schedule daily sync
Token expiration monitoring
π Jira Sync β β° Schedule expiry check
β Youβll get email alerts if your token is about to expire
π Token Management
-
Access token β expires in 1 hour
-
Refresh token β expires in 90 days
The script:
-
Automatically refreshes access tokens
-
Warns you before refresh token expires
-
Sends email notifications if needed
β οΈ Limitations & Notes
-
Max request time: 25 seconds
-
Max payload: ~5 MB
-
Large reports may fail β narrow filters
-
Data visibility depends on user permissions
π Troubleshooting
β No data returned
-
Check permissions in Jira
-
Verify preset is saved
-
Confirm token is valid
β Token expired
-
Re-run authorization flow
-
Save new tokens
β Payload too large
-
Reduce:
-
Date range
-
Number of projects
-
Filters
-
π‘ Pro Tip
Once set up, your Google Sheet becomes a live reporting dashboard, automatically updated from Jira.
If you need help or want to ask questions, please contact SaaSJet Support or email us at support@saasjet.atlassian.net
Haven't used this add-on yet? Try it now!