Blog.

Mastering Slack Canvas Automation: A Journey Through API Quirks

Cover Image for Mastering Slack Canvas Automation: A Journey Through API Quirks
Josh Peterson
Josh Peterson

Automating Slack canvases sounds straightforward—grab an ID, edit some content, done. But when I set out to clean up 53 canvases by removing a pesky "Reported Issues" section and its accompanying paragraph, I fell into a rabbit hole of API quirks that turned a simple task into a full-blown odyssey. From chasing ghost endpoints to battling undocumented limits, this journey taught me more about the Slack API than I bargained for. Here's my tale of dead ends and triumph, complete with code, quirks, and hard-won lessons.

The Mission: A Canvas Cleanup

Picture this: 53 Slack channels, each with a canvas containing vehicle inspection notes. My targets? Two sections at the end of each:

  • An H2 heading: "Reported Issues."
  • A paragraph: "Please detail any issues found during the inspection below:."

The plan was simple—use Google Apps Script and the Slack API to surgically remove these sections. I'd loop through channels, find canvases, identify the sections, and delete them. What could possibly go wrong?

Dead End: The Mythical canvases.read

First, I needed those section_ids to delete anything. The canvases.edit docs said "delete" needs a section_id, but how to get it? I assumed there'd be a canvases.read endpoint—most APIs pair "get" with "edit," right? Slack's pattern with conversations.history or files.info suggested a read method to dump the canvas content: markdown, sections, IDs, everything. Plus, I'd spotted canvas:read as a scope in the permissions list. So I tried:

UrlFetchApp.fetch('https://slack.com/api/canvases.read?canvas_id=F1234567890', {
  headers: { 'Authorization': `Bearer YOUR_SLACK_TOKEN` }
});

The response? {"ok":false,"error":"unknown_method"}. My heart sank. Turns out, canvases.read isn't real—at least not in the public API as of April 2025. No mention in the docs, and my logs confirmed it's a ghost. That canvas:read scope? It exists, but for canvases.sections.lookup, not a full-read method. My assumption—born from API conventions and a misleading scope name—led me straight to strike one.

Pivot: canvases.sections.lookup to the Rescue

Without a full canvas reader, I turned to canvases.sections.lookup. It promised to find sections by type (e.g., "h2") and text. For the H2, this worked beautifully:

const h2LookupOptions = {
  method: 'post',
  headers: { 'Authorization': `Bearer YOUR_SLACK_TOKEN` },
  payload: JSON.stringify({
    canvas_id: 'F1234567890',
    criteria: { section_types: ['h2'], contains_text: 'Reported Issues' }
  })
};
const h2Response = UrlFetchApp.fetch('https://slack.com/api/canvases.sections.lookup', h2LookupOptions);
Logger.log(h2Response.getContentText()); // {"ok":true,"sections":[{"id":"temp:C:abc123"}]}

Success! The H2 section appeared with a section_id like temp:C:abc123. But the paragraph? That's where things went sideways.

Dead End: The section_types Puzzle

The docs list "paragraph" as a supported markdown element for canvases, so I figured section_types: ['paragraph'] would snag "Please detail...":

const pLookupOptions = {
  method: 'post',
  headers: { 'Authorization': `Bearer YOUR_SLACK_TOKEN` },
  payload: JSON.stringify({
    canvas_id: 'F1234567890',
    criteria: { section_types: ['paragraph'], contains_text: 'Please detail any issues found during the inspection below:' }
  })
};

No dice. {"ok":false,"error":"invalid_arguments","response_metadata":{"messages":["[ERROR] must be a valid enum value"]}}. I tried "text", "p", even wild guesses—same error. The docs don't list valid section_types beyond headers ("h1", "h2", "any_header"), leaving me stranded. Was "paragraph" just a mirage?

Pivot: Dropping section_types

Frustrated, I scrapped section_types entirely, relying on contains_text alone:

const pLookupOptions = {
  method: 'post',
  headers: { 'Authorization': `Bearer YOUR_SLACK_TOKEN` },
  payload: JSON.stringify({
    canvas_id: 'F1234567890',
    criteria: { contains_text: 'Please detail any issues found during the inspection below:' }
  })
};
const pResponse = UrlFetchApp.fetch('https://slack.com/api/canvases.sections.lookup', pLookupOptions);
Logger.log(pResponse.getContentText()); // {"ok":true,"sections":[{"id":"temp:C:def456"}]}

Jackpot! It worked—temp:C:def456 appeared. Dropping section_types let Slack match any section containing the text, bypassing the enum mystery. Lesson learned: sometimes less is more.

Dead End: The One-Change Limit

With both IDs in hand, I fired off a canvases.edit call to delete them together:

const editOptions = {
  method: 'post',
  headers: { 'Authorization': `Bearer YOUR_SLACK_TOKEN` },
  payload: JSON.stringify({
    canvas_id: 'F1234567890',
    changes: [
      { "operation": "delete", "section_id": "temp:C:abc123" },
      { "operation": "delete", "section_id": "temp:C:def456" }
    ]
  })
};
const editResponse = UrlFetchApp.fetch('https://slack.com/api/canvases.edit', editOptions);
Logger.log(editResponse.getContentText()); // {"ok":false,"error":"invalid_arguments","messages":["[ERROR] no more than 1 items allowed"]}

Another brick wall. The API rejected my batch edit with "no more than 1 items allowed". The changes array, despite its plural name, has an undocumented limit: one operation per request. My efficiency dreams crumbled again.

Pivot: One Call, One Change

The fix was splitting the deletes into separate calls:

if (h2SectionId) {
  const h2EditOptions = {
    method: 'post',
    headers: { 'Authorization': `Bearer YOUR_SLACK_TOKEN` },
    payload: JSON.stringify({
      canvas_id: 'F1234567890',
      changes: [{"operation": "delete", "section_id": h2SectionId}]
    })
  };
  UrlFetchApp.fetch('https://slack.com/api/canvases.edit', h2EditOptions);
  Utilities.sleep(1000); // Rate limit buffer
}

if (pSectionId) {
  const pEditOptions = {
    method: 'post',
    headers: { 'Authorization': `Bearer YOUR_SLACK_TOKEN` },
    payload: JSON.stringify({
      canvas_id: 'F1234567890',
      changes: [{"operation": "delete", "section_id": pSectionId}]
    })
  };
  UrlFetchApp.fetch('https://slack.com/api/canvases.edit', pEditOptions);
}

Two requests, two victories. The H2 vanished, then the paragraph, all 53 times. The Utilities.sleep(1000) kept me under Slack's rate limit (50+/minute for user tokens), crucial for my scale.

Victory Lap

After chasing the ghost of canvases.read, wrestling with enums, and splitting edits, I had pristine canvases across all channels. Logs glowed with "Successfully removed H2 section" and "Successfully removed paragraph section." It wasn't pretty, but it worked.

Quirks and Takeaways

  • No canvases.read: Don't assume it exists just because other APIs have "read" methods or scopes hint at it. Use canvases.sections.lookup instead.
  • section_types Trap: Stick to headers ("h1", "h2") or skip it entirely. For paragraphs? Just use contains_text.
  • One-Change Rule: Batch edits are a pipe dream. One changes item per call, period.
  • Rate Limits: 53 channels, two edits each—106 requests. Pace yourself with sleeps.

This journey was a crash course in Slack API quirks, sparked by a hopeful misstep with canvases.read. Turns out, victory comes from adapting to the API's rules, not the ones you expect. Next time you're scripting Slack, watch for these traps—they're sneaky but beatable. Got your own API adventure? Share it below—I'm all ears.