In my previous tutorial I walked you through setting up a Mastodon bot that posts data from your Google Sheets spreadsheet. Here I will show you how you can upload a chart as an image with your post.
As a reminder, this was the finished script.
function postToMastodon(event) {
try {
if (event) {
const range = event.range;
console.log(
`cell in column ${range.columnStart} and row ${range.rowStart} was updated`
);
if (range.columnStart === 2 && range.columnEnd === 2) {
const scriptProperties = PropertiesService.getScriptProperties();
const accessToken = scriptProperties.getProperty(
"MASTODON_ACCESS_TOKEN_SECRET"
);
const apiUrl = scriptProperties.getProperty("MASTODON_API_URL");
const row = 1;
const col = 5;
const sum = SpreadsheetApp.getActiveSheet()
.getRange(row, col)
.getValue();
const USDollar = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
const response = UrlFetchApp.fetch(`${apiUrl}/statuses`, {
method: "POST",
contentType: "application/json",
payload: JSON.stringify({
status: `The total sum of donations is ${USDollar.format(sum)}.`,
}),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: "Bearer " + accessToken,
},
});
console.log(response.getContentText());
} else {
console.log("skipping...");
}
} else {
console.log("no data was sent");
}
} catch (err) {
console.log(`there was an error: ${err.message}`);
}
}
And this is the table we worked with.
Date | Amount | Total | =SUM(B:B) | |
2024-01-24 | $59.78 | |||
2024-02-02 | $41.03 | |||
2024-02-04 | $67.58 | |||
2024-02-06 | $3.27 | |||
2024-02-11 | $61.96 |
For the purpose of this tutorial, I am going to assume that you know how to create a chart in Google Sheets. If not, the official Google Sheets documentation should have you covered. Here’s what my chart looks like.

Posting an image on Mastodon is a two-step process.
- First, we need to upload the image using the
api/v2/media
endpoint. - And then we attach the image to the post.
An important part of the image upload will be the image description, or alt text, which will be a bit more involved, so let’s address that towards the end.
First, we will need to grab the chart from our spreadsheet.
const sheet = event.range.getSheet();
const charts = sheet.getCharts();
const chart = charts[0];
const imageData = chart.getBlob();
And now we can add the code that handles the image upload.
const mediaUploadResponse = UrlFetchApp.fetch(
`${apiUrl.replace("/api/v1", "/api/v2")}/media`,
{
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
},
payload: {
file: imageData.setName("temp-chart-file.png")
},
muteHttpExceptions: true,
}
);
console.log("debug:response", mediaUploadResponse.getContentText());
const mediaData = JSON.parse(mediaUploadResponse.getContentText());
const mediaId = mediaData.id;
The mediaUploadResponse
response object will contain the ID of our uploaded image, which we can then attach to the status we’re posting.
const postStatusResponse = UrlFetchApp.fetch(`${apiUrl}/statuses`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: "Bearer " + accessToken,
},
payload: JSON.stringify({
status: `The total sum of donations is ${USDollar.format(sum)}.`,
media_ids: [mediaId],
}),
});
Here’s the script we have so far in full.
function postToMastodon(event) {
try {
if (event) {
const range = event.range;
console.log(
`cell in column ${range.columnStart} and row ${range.rowStart} was updated`
);
if (range.columnStart === 2 && range.columnEnd === 2) {
const scriptProperties = PropertiesService.getScriptProperties();
const accessToken = scriptProperties.getProperty(
"MASTODON_ACCESS_TOKEN_SECRET"
);
const apiUrl = scriptProperties.getProperty("MASTODON_API_URL");
const row = 1;
const col = 5;
const sum = SpreadsheetApp.getActiveSheet()
.getRange(row, col)
.getValue();
const USDollar = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
const sheet = event.range.getSheet();
const charts = sheet.getCharts();
const chart = charts[0];
const imageData = chart.getBlob();
const mediaUploadResponse = UrlFetchApp.fetch(
`${apiUrl.replace("/api/v1", "/api/v2")}/media`,
{
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
},
payload: {
file: imageData.setName("temp-chart-file.png"),
},
muteHttpExceptions: true,
}
);
console.log("debug:response", mediaUploadResponse.getContentText());
const mediaData = JSON.parse(mediaUploadResponse.getContentText());
const mediaId = mediaData.id;
const postStatusResponse = UrlFetchApp.fetch(`${apiUrl}/statuses`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: "Bearer " + accessToken,
},
payload: JSON.stringify({
status: `The total sum of donations is ${USDollar.format(sum)}.`,
media_ids: [mediaId],
}),
});
console.log(postStatusResponse.getContentText());
} else {
console.log("skipping...");
}
} else {
console.log("no data was sent");
}
} catch (err) {
console.log(`there was an error: ${err.message}`);
}
}
Great, you should now see your bot posting an image of your chart! We’re nearly finished.
The last thing we need to do is to add a good image description for our chart for folks who rely on screen readers to browse the web, or folks with poor internet connection. (I wrote a short article on bots and accessibility for more tips on this topic.)
Ideally, we’d use something like:
A line chart showing daily donations between FIRST_DATE and LAST_DATE with $XX average and $XX median donations.
To get this text, we will need to figure out:
- the average donation amount
- the median donation amount
- the first date when a donation was made
- and the last date when a donation was made
To make things a bit easier, we can compute the average and median values inside our spreadsheet.
Date | Amount | Total | =SUM(B:B) | |
2024-01-24 | $59.78 | Median | =MEDIAN(B:B) | |
2024-02-02 | $41.03 | Average | =AVERAGE(B:B) | |
2024-02-04 | $67.58 | |||
2024-02-06 | $3.27 | |||
2024-02-11 | $61.96 |
And then we can get the values the same way we got the total sum.
const donationsSum = SpreadsheetApp.getActiveSheet()
.getRange(1, 5)
.getValue();
const donationsMedian = SpreadsheetApp.getActiveSheet()
.getRange(2, 5)
.getValue();
const donationsAverage = SpreadsheetApp.getActiveSheet()
.getRange(3, 5)
.getValue();
And we can also format these values nicely.
const USDollar = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
const donationsSumFormatted = `${USDollar.format(donationsSum)}`;
const donationsMedianFormatted = `${USDollar.format(donationsMedian)}`;
const donationsAverageFormatted = `${USDollar.format(
donationsAverage
)}`;
Now let’s look at the date.
const chartData = chart.getRanges()[0].getValues();
// const chartDataHeaders = chartData.shift();
const dates = chartData
.map((row) => row[0])
.filter((val) => String(val).trim().length > 0);
const dateOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
const firstDate = new Date(dates.at(0)).toLocaleDateString(
undefined,
dateOptions
);
const lastDate = new Date(dates.at(-1)).toLocaleDateString(
undefined,
dateOptions
);
Now we can write our description.
const description = `A line chart showing daily donations between ${firstDate} and ${lastDate} with ${donationsAverageFormatted} average and ${donationsMedianFormatted} median donations.`;
And we’re ready to put this all together.
function postToMastodon(event) {
try {
if (event) {
const range = event.range;
console.log(
`cell in column ${range.columnStart} and row ${range.rowStart} was updated`
);
if (range.columnStart === 2 && range.columnEnd === 2) {
const scriptProperties = PropertiesService.getScriptProperties();
const accessToken = scriptProperties.getProperty(
"MASTODON_ACCESS_TOKEN_SECRET"
);
const apiUrl = scriptProperties.getProperty("MASTODON_API_URL");
const sheet = event.range.getSheet();
const charts = sheet.getCharts();
const chart = charts[0];
const imageData = chart.getBlob();
const donationsSum = SpreadsheetApp.getActiveSheet()
.getRange(1, 5)
.getValue();
const donationsMedian = SpreadsheetApp.getActiveSheet()
.getRange(2, 5)
.getValue();
const donationsAverage = SpreadsheetApp.getActiveSheet()
.getRange(3, 5)
.getValue();
const USDollar = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
const donationsSumFormatted = `${USDollar.format(donationsSum)}`;
const donationsMedianFormatted = `${USDollar.format(donationsMedian)}`;
const donationsAverageFormatted = `${USDollar.format(
donationsAverage
)}`;
const chartData = chart.getRanges()[0].getValues();
const dates = chartData
.map((row) => row[0])
.filter((val) => String(val).trim().length > 0);
const dateOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
const firstDate = new Date(dates.at(0)).toLocaleDateString(
undefined,
dateOptions
);
const lastDate = new Date(dates.at(-1)).toLocaleDateString(
undefined,
dateOptions
);
const description = `A line chart showing daily donations between ${firstDate} and ${lastDate} with ${donationsAverageFormatted} average and ${donationsMedianFormatted} median donations.`;
console.log(description);
const mediaUploadResponse = UrlFetchApp.fetch(
`${apiUrl.replace("/api/v1", "/api/v2")}/media`,
{
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
},
payload: {
file: imageData.setName("temp-chart-file.png"),
description,
},
muteHttpExceptions: true,
}
);
console.log("debug:mediaUploadResponse", mediaUploadResponse.getContentText());
const mediaData = JSON.parse(mediaUploadResponse.getContentText());
const mediaId = mediaData.id;
const postStatusResponse = UrlFetchApp.fetch(`${apiUrl}/statuses`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: "Bearer " + accessToken,
},
payload: JSON.stringify({
status: `The total sum of donations as of today is ${donationsSumFormatted}!`,
media_ids: [mediaId],
}),
});
console.log("debug:postStatusResponse", postStatusResponse.getContentText());
} else {
console.log("skipping...");
}
} else {
console.log("no data was sent");
}
} catch (err) {
console.log(`there was an error: ${err.message}`);
}
}
And we’re finished!

Hope you enjoyed this tutorial, until next time!