> ## Documentation Index
> Fetch the complete documentation index at: https://developers.scoutworks.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Events in your calendar

> A calendar feed that parents and leaders subscribe to in Google Calendar, Outlook or Apple Calendar. It updates itself.

This recipe turns your events into a calendar feed. People subscribe to it once. When a leader adds or moves an event in Scoutworks, the change reaches their calendar on its own. You can make one feed for the whole group, or one for each section.

It uses a free Google Apps Script web app. It takes about ten minutes.

## 1. Add the script

<Steps>
  <Step title="Create a project">
    Go to [script.google.com](https://script.google.com) and choose **New project**. Call it **Scoutworks calendar**.
  </Step>

  <Step title="Paste the script">
    Delete what is there and paste the script below. Choose **Save**.
  </Step>
</Steps>

```javascript Code.gs theme={null}
// Scoutworks → calendar feed. https://developers.scoutworks.app/recipes/calendar-feed

const BASE = 'https://api-demo.scoutworks.app/api/v1';
const DAY = 24 * 60 * 60 * 1000;

// Calendar apps call this address. Add ?section=cubs for one section only.
function doGet(e) {
  const section = ((e && e.parameter && e.parameter.section) || '').toLowerCase().slice(0, 50);

  // Serve a copy made in the last hour. Repeated requests then cost no API calls.
  const cache = CacheService.getScriptCache();
  const cached = cache.get('feed:' + section);
  if (cached) return ContentService.createTextOutput(cached).setMimeType(ContentService.MimeType.ICAL);

  const now = new Date();
  const from = new Date(now.getTime() - 30 * DAY).toISOString();
  const to = new Date(now.getTime() + 365 * DAY).toISOString();

  const group = fetchScoutworks(BASE + '/me').data.group.name;
  const events = readAll(
    '/events?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to)
  ).filter(function (ev) {
    if (!section || ev.sections.length === 0) return true; // Empty means the whole group.
    return ev.sections.some(function (s) {
      return s.name.toLowerCase().indexOf(section) !== -1;
    });
  });

  const lines = [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'PRODID:-//Scoutworks calendar recipe//EN',
    'CALSCALE:GREGORIAN',
    'METHOD:PUBLISH',
    'X-WR-CALNAME:' + text(section ? group + ' (' + section + ')' : group),
  ];
  events.forEach(function (ev) {
    const start = new Date(ev.startsAt);
    const end = ev.endsAt ? new Date(ev.endsAt) : new Date(start.getTime() + DAY / 24);
    const who = ev.sections.length
      ? ev.sections.map(function (s) { return s.name; }).join(', ')
      : 'the whole group';
    lines.push(
      'BEGIN:VEVENT',
      'UID:' + ev.id + '@scoutworks.app',
      'DTSTAMP:' + stamp(now),
      'DTSTART:' + stamp(start),
      'DTEND:' + stamp(end),
      'SUMMARY:' + text(ev.title),
      'DESCRIPTION:' + text(TYPES[ev.type] + ' for ' + who)
    );
    if (ev.location) lines.push('LOCATION:' + text(ev.location));
    lines.push('END:VEVENT');
  });
  lines.push('END:VCALENDAR');

  const ics = lines.map(fold).join('\r\n') + '\r\n';
  try {
    cache.put('feed:' + section, ics, 60 * 60);
  } catch (err) {
    // A feed over 100 KB is too big to keep. Serve it without the copy.
  }
  return ContentService.createTextOutput(ics).setMimeType(ContentService.MimeType.ICAL);
}

const TYPES = {
  programme_meeting: 'Meeting',
  activity_trip: 'Trip',
  camp_residential: 'Camp',
};

// 2026-09-24T18:30:00.000Z → 20260924T183000Z
function stamp(date) {
  return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}

// Escapes the characters that have a meaning in a calendar file.
function text(value) {
  return String(value)
    .replace(/\\/g, '\\\\')
    .replace(/;/g, '\\;')
    .replace(/,/g, '\\,')
    .replace(/\r?\n/g, '\\n');
}

// Calendar files keep each line short. A long line continues after a space.
function fold(line) {
  const parts = [];
  while (line.length > 73) {
    parts.push(line.slice(0, 73));
    line = ' ' + line.slice(73);
  }
  parts.push(line);
  return parts.join('\r\n');
}

// Every item in a list, following nextCursor to the last page.
function readAll(path) {
  const items = [];
  let cursor = null;
  do {
    let url = BASE + path + (path.indexOf('?') === -1 ? '?' : '&') + 'limit=200';
    if (cursor) url += '&cursor=' + encodeURIComponent(cursor);
    const body = fetchScoutworks(url);
    items.push.apply(items, body.data);
    cursor = body.nextCursor;
  } while (cursor);
  return items;
}

// Calls the API. Waits and tries again if the rate limit is reached.
function fetchScoutworks(url) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('SCOUTWORKS_API_KEY');
  if (!apiKey) throw new Error('Add SCOUTWORKS_API_KEY in Project Settings → Script Properties.');
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = UrlFetchApp.fetch(url, {
      headers: { Authorization: 'Bearer ' + apiKey },
      muteHttpExceptions: true,
    });
    const body = JSON.parse(res.getContentText());
    if (res.getResponseCode() === 200) return body;
    if (res.getResponseCode() === 429) {
      Utilities.sleep((body.retryAfter || 30) * 1000);
      continue;
    }
    throw new Error(body.error.code + ': ' + body.error.message);
  }
  throw new Error('Scoutworks is still busy. Try again in a minute.');
}
```

## 2. Add your key

In the script editor, choose **Project Settings** (the cog), then **Script Properties → Add script property**. Property: `SCOUTWORKS_API_KEY`. Value: the [sandbox key](/guides/sandbox) to try it, or your own key. Choose **Save script properties**.

## 3. Publish it as a web app

<Steps>
  <Step title="Start a deployment">
    Choose **Deploy → New deployment**. Next to **Select type**, choose the cog, then **Web app**.
  </Step>

  <Step title="Set who can use it">
    * **Execute as:** Me
    * **Who has access:** Anyone

    Choose **Deploy**. Google asks for permission the first time: the script needs to reach an external service.
  </Step>

  <Step title="Copy the address">
    Copy the **Web app URL**. It ends in `/exec`. Open it in your browser to check it: you get a short text file that starts with `BEGIN:VCALENDAR`.
  </Step>
</Steps>

For one section, add `?section=` and a word from the section’s name to the end of the address. For example, `…/exec?section=cubs` gives only the Cubs’ events, plus the events for the whole group. Choose a word that only one section’s name has: `scout` also finds “Explorer Scouts”.

## 4. Subscribe to it

| Calendar        | How                                                                                                                                                             |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Google Calendar | On a computer, next to **Other calendars**, choose **+ → From URL**. Paste the address.                                                                         |
| Outlook         | Choose **Add calendar → Subscribe from web**. Paste the address.                                                                                                |
| Apple Calendar  | On a Mac, choose **File → New Calendar Subscription**. On an iPhone, choose **Settings → Calendar → Accounts → Add Account → Other → Add Subscribed Calendar**. |

Calendar apps check a subscribed feed on their own timetable, often every few hours. Google Calendar can take up to a day. You cannot make it faster.

<Warning>
  Anyone with the address can see your events: titles, times and places. The feed never holds the names of young people. Share the address only with your parents and leaders. If it gets out, choose **Deploy → Manage deployments**, archive the deployment, and create a new one. The new one has a new address.
</Warning>

## 5. Switch to your group

In the script, change `api-demo.scoutworks.app` to `api.scoutworks.app`. In Script Properties, replace the sandbox key with your own. Your key needs the `events:read` scope. Give it no other scope.

After you change the script, choose **Deploy → Manage deployments → Edit** (the pencil). Set **Version** to **New version** and choose **Deploy**. The address stays the same. If you create a **new deployment** instead, you get a new address, and your subscribers do not see the change.

## Notes

* The feed holds events from 30 days ago to one year ahead. Change the `30` and `365` in `doGet` to change this.
* The script keeps each feed for **one hour**, so a change in Scoutworks reaches the feed within an hour. This also stops repeated requests from using up your API limit.
* Only **published** events are in the feed. Drafts and leaders-only events never are.
* Each event keeps its Scoutworks ID in the calendar, so a moved event moves in the calendar. It does not appear twice.
