“Tonight at the cinema”: an automatic newsletter with n8n, Make or Zapier

Four n8n nodes send a mail every day at four with a cinema’s evening showtimes and booking links, grouped by film, and send nothing when nothing is on. Importable JSON, and the same flow mapped for Make and Zapier.

6 min read

On this page
  1. What you need
  2. 1.The key as a credential
  3. 2.The four nodes
  4. 3.Or import it
  5. 4.Try it
  6. Variants
  7. The same flow in Make and Zapier

Every day at four, a mail goes out: Tonight at Le Champo: 4 films, with the times and the booking links. Nobody writes it. This tutorial builds that in n8n with four nodes, one of them holding 25 lines of JavaScript, and shows the same flow in Make and Zapier. The result is a newsletter for a cinema, a film society or a local paper; swap the last node and it is a post on Slack, Discord, Telegram or Mastodon.

Every day at 16:00 ──▶ Tonight's showtimes ──▶ Write the e-mail ──▶ Send it
   (schedule)            (HTTP Request)            (Code)           (Send Email)
Subject: Tonight at Le Champo: 4 films

Tonight at Le Champo, Friday 6 November

Affreux, sales et méchants (1 h 55)
  18:10 VOST  https://www.cinema-lechampo.com/film.html#/media/574?showId=34577

Safe (1 h 59)
  19:40 VOST  https://www.cinema-lechampo.com/film.html#/media/1438?showId=34614

What you need

  • n8n: n8n Cloud, or your own instance (npx n8n starts one on your computer). This tutorial was run on n8n 2.46.
  • An API Cinéma key: create a free account, confirm your e-mail address, then create a key from the dashboard. The free plan gives 100 requests a week; this workflow uses one a day.
  • The slug of the cinema. Search it by name:
Terminal
curl "https://api.api-cinema.com/v1/cinemas?q=champo" -H "Authorization: Bearer ac_live_…"

The answer gives "slug": "le-champo-paris-5e-arrondissement". Any cinema of the API works: France, the United States, Australia.

  • An SMTP account for the mail (the one of your host, Brevo, Mailgun, Resend…), or a Gmail account with the Gmail node.

The key as a credential

In n8n, Credentials › Create › Header Auth. Name: Authorization. Value: Bearer ac_live_… (the word Bearer, a space, the key). Call it API Cinéma key. The key is stored encrypted and never appears in the workflow, so the workflow can be exported and shared.

The four nodes

Every day at 16:00: a Schedule Trigger. Trigger interval Days, every 1 day, at 16:00. In the workflow settings, set the time zone to the cinema's (Europe/Paris for Le Champo, America/New_York for a Manhattan cinema): the trigger and the date in the next node follow it.

Tonight's showtimes: an HTTP Request. Method GET, the URL as an expression (the field accepts {{ }} once it starts with =):

https://api.api-cinema.com/v1/cinemas/le-champo-paris-5e-arrondissement/showtimes?from={{ $today.toFormat('yyyy-MM-dd') }}T18:00&to={{ $today.toFormat('yyyy-MM-dd') }}&include=movie,cinema&limit=1000

Authentication: Generic Credential Type › Header Auth, then the credential of step 1. from and to are read in the cinema's local time: from 18:00 to the end of the day. include=movie,cinema adds the film (title, runtime, poster) and the cinema (name, address) to each showtime, so no second request is needed.

Write the e-mail: a Code node. Mode Run Once for All Items, language JavaScript, with this script. It groups the showtimes by film, builds a text version and an HTML version, and returns one item. When nothing is on tonight it returns no item, and n8n stops there: no empty mail.

Write the e-mail
// Tonight's programme grouped by film, as one e-mail. No item out when nothing is on: nothing is sent.
const showtimes = $input.first().json.data
if (!showtimes.length)
  return []

const cinema = showtimes[0].cinema
const films = new Map()
for (const s of showtimes) {
  const film = films.get(s.movie.id) ?? { title: s.movie.title, durationMin: s.movie.durationMin, times: [] }
  // startsAt is the cinema's local time
  film.times.push({ time: s.startsAt.slice(11, 16), label: s.version + (s.format === '2D' ? '' : ` ${s.format}`), url: s.bookingUrl })
  films.set(s.movie.id, film)
}

const date = DateTime.fromISO(showtimes[0].startsAt).setLocale('en').toFormat('cccc d LLLL')
const runtime = min => `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, '0')}`
const esc = t => String(t).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])

const html = `<h2>Tonight at ${esc(cinema.name)}</h2><p>${esc(date)}</p>${[...films.values()].map(f => `
<h3>${esc(f.title)}${f.durationMin ? ` <small>${runtime(f.durationMin)}</small>` : ''}</h3>
<p>${f.times.map(t => `${t.url ? `<a href="${esc(t.url)}">${t.time}</a>` : t.time} ${esc(t.label)}`).join(' · ')}</p>`).join('')}`

const text = `Tonight at ${cinema.name}, ${date}\n\n${[...films.values()].map(f =>
  `${f.title}${f.durationMin ? ` (${runtime(f.durationMin)})` : ''}\n${f.times.map(t => `  ${t.time} ${t.label}${t.url ? `  ${t.url}` : ''}`).join('\n')}`).join('\n\n')}`

return [{ json: { subject: `Tonight at ${cinema.name}: ${films.size} film${films.size > 1 ? 's' : ''}`, html, text } }]

DateTime is Luxon, which n8n provides in every Code node; setLocale('fr') gives vendredi 6 novembre. Titles and links go through esc before landing in HTML, as they come from outside.

Send it: a Send Email node. Your SMTP credential, from and to addresses, then three expressions: subject {{ $json.subject }}, e-mail format Both, HTML {{ $json.html }}, text {{ $json.text }}. In Options, turn off Append n8n Attribution. A Gmail node takes the same three expressions.

Or import it

Copy this JSON and paste it on an empty n8n canvas (Ctrl+V): the four nodes appear, wired. Open the HTTP node and pick your API Cinéma key credential, open the Send Email node and pick your SMTP one, put your addresses in, and save.

workflow.json
{
  "name": "Tonight at the cinema",
  "settings": {
    "timezone": "Europe/Paris",
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "name": "Every day at 16:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "daysInterval": 1,
              "triggerAtHour": 16,
              "triggerAtMinute": 0
            }
          ]
        }
      }
    },
    {
      "name": "Tonight's showtimes",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        240,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "=https://api.api-cinema.com/v1/cinemas/le-champo-paris-5e-arrondissement/showtimes?from={{ $today.toFormat('yyyy-MM-dd') }}T18:00&to={{ $today.toFormat('yyyy-MM-dd') }}&include=movie,cinema&limit=1000",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "credentials": {
        "httpHeaderAuth": {
          "id": "apicinema",
          "name": "API Cinéma key"
        }
      }
    },
    {
      "name": "Write the e-mail",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        480,
        0
      ],
      "parameters": {
        "jsCode": "// Tonight's programme grouped by film, as one e-mail. No item out when nothing is on: nothing is sent.\nconst showtimes = $input.first().json.data\nif (!showtimes.length)\n  return []\n\nconst cinema = showtimes[0].cinema\nconst films = new Map()\nfor (const s of showtimes) {\n  const film = films.get(s.movie.id) ?? { title: s.movie.title, durationMin: s.movie.durationMin, times: [] }\n  // startsAt is the cinema's local time\n  film.times.push({ time: s.startsAt.slice(11, 16), label: s.version + (s.format === '2D' ? '' : ` ${s.format}`), url: s.bookingUrl })\n  films.set(s.movie.id, film)\n}\n\nconst date = DateTime.fromISO(showtimes[0].startsAt).setLocale('en').toFormat('cccc d LLLL')\nconst runtime = min => `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, '0')}`\nconst esc = t => String(t).replace(/[&<>\"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' })[c])\n\nconst html = `<h2>Tonight at ${esc(cinema.name)}</h2><p>${esc(date)}</p>${[...films.values()].map(f => `\n<h3>${esc(f.title)}${f.durationMin ? ` <small>${runtime(f.durationMin)}</small>` : ''}</h3>\n<p>${f.times.map(t => `${t.url ? `<a href=\"${esc(t.url)}\">${t.time}</a>` : t.time} ${esc(t.label)}`).join(' · ')}</p>`).join('')}`\n\nconst text = `Tonight at ${cinema.name}, ${date}\\n\\n${[...films.values()].map(f =>\n  `${f.title}${f.durationMin ? ` (${runtime(f.durationMin)})` : ''}\\n${f.times.map(t => `  ${t.time} ${t.label}${t.url ? `  ${t.url}` : ''}`).join('\\n')}`).join('\\n\\n')}`\n\nreturn [{ json: { subject: `Tonight at ${cinema.name}: ${films.size} film${films.size > 1 ? 's' : ''}`, html, text } }]"
      }
    },
    {
      "name": "Send it",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        720,
        0
      ],
      "parameters": {
        "fromEmail": "cinema@example.org",
        "toEmail": "newsletter@example.org",
        "subject": "={{ $json.subject }}",
        "emailFormat": "both",
        "html": "={{ $json.html }}",
        "text": "={{ $json.text }}",
        "options": {
          "appendAttribution": false
        }
      },
      "credentials": {
        "smtp": {
          "id": "smtp",
          "name": "SMTP"
        }
      }
    }
  ],
  "connections": {
    "Every day at 16:00": {
      "main": [
        [
          {
            "node": "Tonight's showtimes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tonight's showtimes": {
      "main": [
        [
          {
            "node": "Write the e-mail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write the e-mail": {
      "main": [
        [
          {
            "node": "Send it",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Try it

Execute workflow runs it now, whatever the hour: the HTTP node shows the showtimes it got, the Code node the mail it wrote, and the mail arrives. Before 18:00 it lists the whole evening; late at night the Code node returns nothing and the Send Email node stays grey, which is the expected result. Then Activate the workflow: it runs every day at 16:00 on its own.

One request a day, so seven a week: the free plan covers it twenty times over, and a cinema that runs the same workflow for every screen or every day of the week stays well under the limit.

Variants

  • A post instead of a mail. Replace the last node with Slack, Discord, Telegram or Mastodon and give it {{ $json.text }}: the Code node already writes a plain-text version with the links.
  • The week, every Wednesday. Schedule Trigger on Weeks, Wednesday 10:00, and in the URL from={{ $today.toFormat('yyyy-MM-dd') }} and to={{ $today.plus({ days: 6 }).toFormat('yyyy-MM-dd') }} (French cinemas change their programme on Wednesday). In the script, group by s.startsAt.slice(0, 10) first, then by film.
  • Every cinema of a town. GET /v1/showtimes?city=Lyon&from=…&include=movie,cinema: the same script, with s.cinema.name next to each time.
  • Original version only. Add &version=VOST (or VO) to the URL.

The same flow in Make and Zapier

Both tools have the same three steps after the schedule; the script above needs small adaptations to their code steps, so take it as a map rather than a copy-paste.

  • Make. Schedule (every day at 16:00) → HTTP › Make a request (the URL above, built with Make's formatDate(now; "YYYY-MM-DD"), a header Authorization: Bearer ac_live_…, Parse response on) → Iterator on data → Text aggregator that writes one line per showtime (startsAt, movie.title, bookingUrl) → Email › Send an email or Gmail. Make has no grouping step: the aggregator works per showtime, sorted by time, which reads fine for one evening.
  • Zapier. Schedule by Zapier (every day, 16:00) → Webhooks by Zapier › GET (the URL, the Authorization header) → Code by Zapier (JavaScript: inputData holds the response body as a string, JSON.parse it, then the grouping of the script above, and return { subject, html, text }) → Gmail › Send email or Email by Zapier. Set the zap's time zone to the cinema's in its settings, so 16:00 is the cinema's 16:00.

Every parameter and field is in the documentation. For the same data in a spreadsheet, the Google Sheets tutorial fills one every morning; for a bot that answers questions rather than a daily mail, see the Discord bot.