← Back to blog

Send a Calendar Invite (.ics) Email via Rovela Attachment API (2026)

Build a VEVENT .ics, base64-attach it as text/calendar on POST /emails, and use Idempotency-Key = cal-invite-{bookingId}. Meeting/booking angle—distinct from invoice PDF and the attachments overview. Docs-only Rovela limits.

Updated November 4, 2026
Send a Calendar Invite (.ics) Email via Rovela Attachment API (2026)

Why booking apps send a .ics instead of a PDF

When someone books a demo, a consultation, or a classroom seat, they need the event on their calendar—not a static PDF. The open format for that is iCalendar (RFC 5545): a small text/calendar file, usually named invite.ics, with a VEVENT block (UID, DTSTART, DTEND, SUMMARY, ORGANIZER, ATTENDEE).

Rovela does not generate ICS for you. Your booking service builds the .ics bytes, then sends them the same way any other attachment goes out: POST https://api.rovela.dev/emails with attachments[] = { filename, content (base64), content_type }, plus an Idempotency-Key so a retried webhook never double-invites.

This post follows the live attachment rules in Invoice with an attachment and the attachments field on Send Email. It is not Send an Invoice Email with a PDF Attachment (finance PDFs) and not Email Attachments Guide (formats overview). Sibling transactional posts: Stripe webhook receipt, Shopify order webhook, Team invite email links.

What you own vs what Rovela sends

Your scheduling / booking job owns:

  • Building a valid ICS string (BEGIN:VCALENDAR … END:VCALENDAR) with a stable UID per booking
  • Putting title, time, timezone, join URL, and location in both the ICS and the HTML/text bodies (phone previews rarely open attachments)
  • Encoding the .ics as single-line base64 and setting content_type: "text/calendar"
  • Choosing Idempotency-Key from the booking id (e.g. cal-invite-bk_8f3a2c)
  • Preferring one POST /emails per invitee (or one message with multiple to if that matches your product) rather than stuffing large ICS packs into /emails/batch

Rovela owns:

  • Accepting the JSON, decoding attachments, building a proper MIME multipart, DKIM-signing, queueing, retries, and event history
  • Enforcing the documented request size cap (see below)

A 200 with {"object":"email","id":"..."} means accepted and queued, not “opened in Google Calendar.”

Prerequisites

  1. A verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start).
  2. An API key that looks like re_…. Prefer sending_access for production invite senders (Authentication).
  3. A booking id you can reuse as the idempotency key when the webhook or worker retries.

Size limit (from the invoice attachment docs)

The live invoice example states: the whole JSON request is capped at 2 MB. Base64 expands the payload by about a third, which leaves roughly 1.5 MB for all attachments together. Over the limit → 413 before anything is sent.

A single .ics is usually a few kilobytes—well under that cap. If you ever attach decks or recordings alongside the invite, keep the same 2 MB JSON budget in mind, or host large files and put links in the body. See Invoice with an attachment.

Build a minimal VEVENT

Generate the ICS on your server. Keep UID stable for the booking so calendar clients can update or cancel later if you resend a revised file. Times below use UTC (Z); for floating local times, use a VTIMEZONE block instead of inventing unsupported Rovela fields—Rovela only transports the bytes you attach.

function buildInviteIcs({ uid, summary, description, startUtc, endUtc, organizerEmail, attendeeEmail, location, url }) {
  // RFC 5545 lines should stay ≤ 75 octets; keep fields short or fold in production.
  const lines = [
    "BEGIN:VCALENDAR",
    "VERSION:2.0",
    "PRODID:-//Acme Booking//EN",
    "CALSCALE:GREGORIAN",
    "METHOD:REQUEST",
    "BEGIN:VEVENT",
    `UID:${uid}`,
    `DTSTAMP:${startUtc}`,
    `DTSTART:${startUtc}`,
    `DTEND:${endUtc}`,
    `SUMMARY:${summary}`,
    `DESCRIPTION:${description}`,
    `ORGANIZER:mailto:${organizerEmail}`,
    `ATTENDEE:mailto:${attendeeEmail}`,
    location ? `LOCATION:${location}` : null,
    url ? `URL:${url}` : null,
    "STATUS:CONFIRMED",
    "SEQUENCE:0",
    "END:VEVENT",
    "END:VCALENDAR",
  ].filter(Boolean);

  return lines.join("\r\n") + "\r\n";
}

Example values:

  • uid: bk_8f3a2c@mail.acme.com
  • startUtc / endUtc: 20261105T150000Z / 20261105T153000Z
  • summary: Acme product demo
  • url: your Meet / Zoom / in-app room link

Escape commas, semicolons, and newlines in SUMMARY/DESCRIPTION per RFC 5545 if your titles can contain them. That escaping is your job; Rovela does not rewrite ICS content.

curl: strip newlines, then POST

GNU base64 wraps at 76 characters by default. The Rovela decoder rejects line breaks inside the base64, so strip them the same way the invoice docs show:

# Write invite.ics first, then:
ICS=$(base64 < invite.ics | tr -d '\n')

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cal-invite-bk_8f3a2c" \
  -d @- <<EOF
{
  "from": "Acme Scheduling <schedules@mail.acme.com>",
  "to": "alex@customer.com",
  "reply_to": "schedules@acme.com",
  "subject": "Calendar invite: Acme product demo — Nov 5",
  "html": "<p>Hi Alex,</p><p>Your <strong>Acme product demo</strong> is confirmed for <strong>Nov 5, 2026 · 15:00–15:30 UTC</strong>.</p><p>Join: <a href=\"https://meet.acme.com/bk_8f3a2c\">https://meet.acme.com/bk_8f3a2c</a></p><p>The attached <code>invite.ics</code> adds the event to your calendar.</p>",
  "text": "Hi Alex,\n\nYour Acme product demo is confirmed for Nov 5, 2026 · 15:00–15:30 UTC.\nJoin: https://meet.acme.com/bk_8f3a2c\n\nThe attached invite.ics adds the event to your calendar.\n",
  "attachments": [
    {
      "filename": "invite.ics",
      "content": "$ICS",
      "content_type": "text/calendar"
    }
  ]
}
EOF

Why content_type: "text/calendar"

Without content_type, the part is sent as application/octet-stream. Many clients then show an “unknown file” instead of offering Add to Calendar. Set "content_type": "text/calendar" every time—the same rule the docs give for application/pdf on invoices.

Why Idempotency-Key

Booking webhooks, queue workers, and “resend invite” buttons will POST twice if the first response times out. With Idempotency-Key: cal-invite-bk_8f3a2c (up to 256 characters; retries within 24 hours return the original response), the guest gets one invite email, not two. See Idempotency on Send Email.

Node.js: build ICS → base64 → fetch

export async function sendCalendarInvite({ booking, attendeeEmail }) {
  const ics = buildInviteIcs({
    uid: `${booking.id}@mail.acme.com`,
    summary: booking.title,
    description: `Join: ${booking.joinUrl}`,
    startUtc: booking.startUtc, // e.g. 20261105T150000Z
    endUtc: booking.endUtc,
    organizerEmail: "schedules@acme.com",
    attendeeEmail,
    location: booking.location || booking.joinUrl,
    url: booking.joinUrl,
  });

  const res = await fetch("https://api.rovela.dev/emails", {
    method: "POST",
    headers: {
      Authorization: "Bearer " + process.env.ROVELA_API_KEY,
      "Content-Type": "application/json",
      // Booking id is the natural key: a retried job never double-invites.
      "Idempotency-Key": "cal-invite-" + booking.id,
    },
    body: JSON.stringify({
      from: "Acme Scheduling <schedules@mail.acme.com>",
      to: attendeeEmail,
      reply_to: "schedules@acme.com",
      subject: "Calendar invite: " + booking.title,
      html:
        "<p>Your <strong>" +
        booking.title +
        "</strong> is confirmed.</p><p>Join: <a href=\"" +
        booking.joinUrl +
        "\">" +
        booking.joinUrl +
        "</a></p><p>Open the attached invite.ics to add it to your calendar.</p>",
      text:
        "Your " +
        booking.title +
        " is confirmed.\nJoin: " +
        booking.joinUrl +
        "\n\nOpen the attached invite.ics to add it to your calendar.\n",
      attachments: [
        {
          filename: "invite.ics",
          content: Buffer.from(ics, "utf8").toString("base64"),
          content_type: "text/calendar",
        },
      ],
    }),
  });

  if (!res.ok) throw new Error("calendar invite email failed: " + res.status);
  return (await res.json()).id;
}

reply_to sends answers to a mailbox people actually read, while the message still comes from the verified sending subdomain—same pattern as the invoice example.

Good habits for meeting invites

  • Body first, ICS second. Put time, timezone, and join URL in HTML and text. Preview panes and lock screens will not open the attachment.
  • Filename without spaces or PII. Prefer invite.ics or demo-bk_8f3a2c.ics over Alex Smith Meeting.ics.
  • Stable UID + SEQUENCE. When the meeting moves, bump SEQUENCE, rebuild the ICS, and send again with a new idempotency key (e.g. cal-invite-bk_8f3a2c-seq-1) so the update is not suppressed as a duplicate of the original send.
  • One invitee per message when personalization matters. Rovela allows up to 50 recipients across to/cc/bcc on one message (Send Email), but calendar clients often expect a single ATTENDEE. Prefer one POST per guest for demos and 1:1 bookings.
  • Skip /emails/batch for attached invites when you care about per-booking keys and clear failure isolation—the invoice docs note the batch shares the same 2 MB body and one invalid entry can reject the whole batch for attached PDFs; keep the same caution for ICS packs. See Batch and scheduled sends.

Errors you will actually hit

From Send Email and the invoice attachment example:

  • 400 — missing fields, base64 that does not decode (often leftover newlines), or from domain not on your organization
  • 401 — missing/invalid key, or a sending_access key used on read/cancel endpoints
  • 413 — JSON body over the 2 MB request cap
  • 429 — rate limit; back off and retry with the same Idempotency-Key

What this is not

  • Rovela does not host calendars, free/busy, or RSVP callbacks. Accept/decline buttons in Outlook/Google are client-side ICS behavior; wire RSVP webhooks in your own product if you need them.
  • Do not invent MIME parameters (method=REQUEST as a content-type parameter, Content-Disposition: inline) as Rovela API fields—the documented attachment shape is only filename, content, content_type. If a client needs a METHOD, put METHOD:REQUEST inside the ICS, as shown above.
  • For large attachments or non-calendar files, start from Email Attachments Guide and Invoice with an attachment.

Next steps

  1. Verify your sending domain and create a sending_access key.
  2. Generate a real .ics for one booking and send it with content_type: "text/calendar".
  3. Confirm the message is accepted (id returned), then open it in Gmail / Apple Mail / Outlook and add the event.
  4. Store Idempotency-Key = cal-invite-{bookingId} next to the booking row so retries stay safe.

Docs: Send Email · Invoice with an attachment · Authentication