Field Report // NO. 008
Send calendar invites from an email API: the exact payload that gets Accept/Decline buttons in Outlook
How to send a calendar invite through the Dead Simple Email REST API so it renders with Accept and Decline buttons in Outlook: the exact httpx call, the .ics payload, and the two knobs that decide whether the buttons appear.
A calendar invite is just an email with an .ics file attached. Send that email through an email API the right way, and Outlook renders it with Accept and Decline buttons instead of a download link. The difference is two knobs: the key the API uses for the base64 attachment content, and the MIME headers that tell Outlook this is a meeting request, not a file.
This article is the recipe. It sends a meeting invite through the Dead Simple Email REST API and gets working buttons in Outlook. Every piece is here: the .ics file, the httpx call, and the two knobs that decide whether it works.
The two knobs that decide everything
Two things have to be right, and they are separate problems.
Knob one: the attachment key. The Dead Simple API takes attachments as base64, binary files encoded as text. The documented field is data, described in the API reference as “base64-encoded file content.” Send content or body instead and the API still returns 201, but the attachment is silently dropped. No error, no warning, no attachment.
Knob two: the MIME structure. The receiving client decides whether an email is a meeting request from the MIME headers, the email headers that describe what each part is, not from the file content. Outlook wants the calendar part announced at the message level and the attachment marked inline. Get this wrong and the .ics arrives as a download link, not buttons.
Both have to be right. The data key gets the file out of the API; the header and the inline flags get the buttons into Outlook.
What a calendar invite actually is
An .ics file is an iCalendar object. For a client to act on it as a request, the standard (RFC 5546) requires a specific structure:
METHOD:REQUESTon theVCALENDAR— “this is a request, not a published event”VTIMEZONE— the timezone definition, so the event time is unambiguousORGANIZER— who sent it (required for REQUEST)ATTENDEE— who is invited, withPARTSTAT=NEEDS-ACTION,RSVP=TRUE, andROLE=REQ-PARTICIPANT
Without METHOD:REQUEST, the invite is just a calendar file. Clients show a download link, not buttons.
Here is the .ics that worked:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//The Agent Files//Hermes//EN
METHOD:REQUEST
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:STANDARD
DTSTART:19701025T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19700329T020000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:meeting-20260725-001@your-agent.example.com
DTSTAMP:20260725T090000Z
DTSTART;TZID=Europe/Berlin:20260728T100000
DTEND;TZID=Europe/Berlin:20260728T103000
SUMMARY:Planning sync
ORGANIZER;CN=Your Agent:mailto:your-agent@example.com
ATTENDEE;CN=You;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto:you@example.com
END:VEVENT
END:VCALENDAR
The UID has to change per event. If you send the same UID twice, clients treat the second send as an update to the first event, not a new invite.
The endpoint
POST https://api.deadsimple.email/v1/inboxes/{inbox_id}/messages
Two headers: Authorization: Bearer dse_your_api_key and Content-Type: application/json. The body takes to, subject, a body, and an attachments array. Each attachment is an object with filename, content_type, and the file content.
The exact call
import httpx, base64
ics_bytes = open("invite.ics", "rb").read()
ics_b64 = base64.b64encode(ics_bytes).decode()
resp = httpx.post(
"https://api.deadsimple.email/v1/inboxes/{inbox_id}/messages",
headers={
"Authorization": "Bearer dse_your_api_key",
"Content-Type": "application/json",
},
json={
"to": ["you@example.com"],
"subject": "Planning sync",
"text_body": "Join me for a planning sync.",
"html_body": "<p>Join me for a planning sync.</p>",
"headers": {
"Content-Type": "text/calendar; method=REQUEST",
},
"attachments": [
{
"filename": "invite.ics",
"content_type": "text/calendar; charset=utf-8; method=REQUEST",
"disposition": "inline",
"inline": True,
"data": ics_b64,
}
],
},
)
print(resp.status_code) # 201
The three pieces that make Outlook show buttons, working together:
- A message-level
Content-Type: text/calendar; method=REQUESTheader. This is the header on the message itself, sent through the API’sheadersobject, not just thecontent_typeof the attachment. - The attachment marked inline:
disposition: inlineandinline: true. - The
content_typeof the attachment carriesmethod=REQUESTtoo.
I verified the buttons in Outlook (Microsoft). The same structure also rendered as a calendar invite in Google Calendar and Apple Calendar, but that cross-client claim is from my testing, not a guarantee in the spec.
What to change for your setup
dse_your_api_key— your Dead Simple API key, from Settings → API Keys. It starts withdse_.{inbox_id}— the inbox ID from the dashboard or the inbox create response. It starts withinb_.you@example.com— the recipient.- The
.ics— changeUID, times,SUMMARY, and theATTENDEE/ORGANIZERaddresses. KeepMETHOD:REQUEST,VTIMEZONE, and thePARTSTAT/RSVP/ROLEparameters on the attendee.
What went wrong before it worked
The two knobs above are what I learned by getting them wrong. Seven attempts, and the pattern is worth knowing because the API is unhelpful about it.
The first problem is the attachment key. The SDK’s attachment example uses a content key, and content is one of the keys the REST API silently drops. I tried content (201, no attachment), multipart form data (rejected, the API wants JSON), data (201, attachment arrived), and body (201, no attachment). The only key that works is data. The API accepts the wrong shapes and returns 201 anyway, so the failure is silent.
The second problem is the buttons. With the attachment arriving, Outlook still showed a download link. I tried disposition: inline on the attachment alone (partial), the message-level header plus the inline attachment (buttons appeared), and an HTML body plus an inline .ics (partial). The winner was the combination: the message-level Content-Type header plus the inline attachment.
The lesson: an email API that returns 201 has not necessarily sent what you think. When an attachment must survive, verify it. When a client must treat the email as a meeting, announce it at the message level.
Cost
The free tier is enough: 5 inboxes and 5,000 emails a month. The only paid upgrade that matters is a custom domain, which removes the random hash Dead Simple appends to every free-tier address to stop name squatting. For a meeting invite you send from an agent, the hashed address is fine.
The takeaway
Two knobs decide whether a calendar invite works. The attachment payload key, data for base64 and not content or body, decides whether the file arrives at all. The message-level Content-Type: text/calendar; method=REQUEST header plus the inline attachment decides whether Outlook shows Accept and Decline buttons or a download link.
This is the same trick my travel helper uses: after I book, the agent emails me two calendar invites, one for pick-up and one for drop-off, and Outlook ingests them as meetings. Building the invite as an email attachment means the agent never needs calendar write scope.
The next question is the round trip: making a recipient’s Accept or Decline reply come back to the agent’s inbox as a usable signal. That means threading the reply from Outlook into a REPLY .ics and updating the event state. Different problem, same REST API.