This guide explains how to export Issue History for Jira app report data via API and automatically import it into Google Sheets.
Overview
With the API-based data source:
-
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 Apps -> Manage apps ->Connected Apps, search for Issue History for Jira (Work Item History), and click on View app details.
-
In the app Details menu, enable the REST APIs option.
π Step 2: Generate Data Source Link
-
Open your Issue History for Jira app report.
-
Click API button.
-
Open the Data sources window.
-
Choose the saved view, then click Create data source 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 history 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_SOURCE_URL is stored securely in Script Properties.
// Set it via: π Issue History API β βοΈ Save Data Source 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 getDataSourceUrl() { return getProps().getProperty("DATA_SOURCE_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"
? "π΄ Issue History API: Refresh token has EXPIRED"
: `β οΈ Issue History API: Refresh token expires in ${daysLeft} day(s)`;
const body = type === "EXPIRED"
? `Your Issue History API 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 π Issue History API β βοΈ Save tokens`
: `Your Issue History API 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 π Issue History API β βοΈ 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 fetchDataSource(accessToken) {
const url = CONFIG.DATA_SOURCE_URL || getDataSourceUrl();
if (!url) throw new Error("No Data Source URL stored. Use π Issue History API β βοΈ Save Data Source 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 fetchDataSource(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 data source may return an array at the top level, or inside a key.
// Adjust the path below if your source has a different structure.
let rows = [];
if (Array.isArray(data)) {
rows = data;
} else if (data.value && Array.isArray(data.value)) {
rows = data.value;
} 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_SOURCE_URL || getDataSourceUrl();
if (!url) throw new Error("No Data Source URL stored. Use π Issue History API β βοΈ Save Data Source URL.");
// Warn if refresh token is close to expiry
checkTokenExpiry(true);
Logger.log("π Fetching data from Jira data source...");
const data = fetchDataSource(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("π Issue History API")
.addItem("Sync now", "syncJiraData")
.addSeparator()
.addItem("βοΈ Save tokens", "promptSaveTokens")
.addItem("βοΈ Save OAuth creds", "promptSaveOAuthCreds")
.addItem("βοΈ Save Data Source URL", "promptSaveDataSourceUrl")
.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 promptSaveDataSourceUrl() {
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_SOURCE_URL") || "";
const hint = current
? "Current URL (paste a new one to replace it):\n" + current
: "Paste the Data Source URL from your Jira report:";
const resp = ui.prompt("Save Data Source 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_SOURCE_URL", newUrl);
ui.alert("β
Data Source 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: π Issue History API
βοΈ Configure the Script
Note: Before configuring the script, make sure the script attached to the document has the necessary permissions to run. You will be asked to provide authorization before script configuration.
From the menu:
1. Save OAuth credentials
π Issue History API β βοΈ Save OAuth creds
Add from step 3:
-
Client ID
-
Client Secret
2. Save tokens
π Issue History API β βοΈ Save tokens
Add from step 3 (this is exactly what you exchanged in Postman):
-
Access token
-
Refresh token
3. Save Data Source URL
π Issue History API β βοΈ Save Data Source URL
Paste the API link you generated in step 2.
βΆοΈ Step 5: Run the Import
Click:
π Issue History API β Sync now
β The script will:
-
Fetch data from API
-
Automatically refresh expired tokens
-
Write structured data into your sheet
-
Format headers and add timestamp
For example, youβll be able to retrieve such a report:
π Automation (Optional)
You can automate syncing:
Daily data refresh
π Issue History API β β° Schedule daily sync
the
Token expiration monitoring
π Issue History API β β° 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 the 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 the view is saved
-
Confirm the token is valid
β Token expired
-
Re-run authorization flow
-
Save new tokens
β Payload too large
-
Reduce:
-
Date range
-
Filters
-
π‘ Pro Tip
Once set up, your Google Sheet becomes a live reporting dashboard that is 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 app yet? π Then youβre welcome to try it π