How-to
Automating Reporting WorkflowsDeep dive

Send an Excel Report with the SendGrid API

Email an Excel report through Twilio SendGrid's Web API instead of SMTP: build a Mail with HTML and text bodies, attach the workbook as base64 with the right MIME type, send one personalised email per recipient, use a dynamic template, test safely with sandbox mode, handle API errors and size limits, and check the request before it leaves.

SMTP works for sending reports, but many organisations route automated email through a delivery service so they get bounce handling, suppression lists, sender authentication and a log of every message. Twilio SendGrid is one of the most common, and its Web API is often easier to use from a scheduled job than SMTP: a single HTTPS request with an API key, no ports to open, and a clear JSON error when something is wrong. This page sends an Excel report with the official sendgrid Python library — HTML and text bodies, the workbook as an attachment, one personalised message per recipient, an optional dynamic template — and covers testing without sending, error handling and the size limits. It belongs to Emailing Excel Reports with smtplib.

Prerequisites

Bash
pip install sendgrid pandas openpyxl

You need a SendGrid account, a verified sender (a single sender address or an authenticated domain) and an API key with the Mail Send permission. Store the key in the SENDGRID_API_KEY environment variable rather than in the script — see Store Credentials for Excel Report Scripts Safely. sales_report.xlsx is the report to send. The outputs on this page were produced against a local stand-in for the API, so no real email was sent; the requests are the same.

SMTP relay or Web API?

SendGrid also offers an SMTP relay, so an existing smtplib script can switch to it by changing the host, port and credentials (the username is the literal string apikey and the password is the API key). That is the quickest migration. The Web API is the better long-term choice for a scheduled job: it runs over HTTPS on port 443, which corporate firewalls rarely block, it validates the whole message in one request and answers with a specific error, and it supports personalizations, templates and sandbox mode directly.

Two ways to send through SendGrid The SMTP relay reuses an existing smtplib script by changing host and credentials, but needs an outbound SMTP port and gives less specific errors. The Web API uses HTTPS, returns JSON errors, and supports personalizations, dynamic templates and sandbox mode. SMTP relay reuse the smtplib script user 'apikey', password = key needs port 587 outbound terse SMTP errors Web API (this page) HTTPS on port 443 JSON errors per field personalizations, templates sandbox mode for tests

Step 1 — Build the message and attach the workbook

A Mail object holds sender, recipients, subject and bodies. The workbook goes in as an Attachment whose content is base64-encoded text:

Python
import base64
import os
from pathlib import Path
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Attachment, Disposition, FileContent, FileName, FileType, Mail

XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"

def excel_attachment(path):
    data = base64.b64encode(Path(path).read_bytes()).decode("ascii")
    return Attachment(FileContent(data), FileName(Path(path).name), FileType(XLSX), Disposition("attachment"))

message = Mail(
    from_email=("reports@example.com", "Reporting"),
    to_emails="finance@example.com",
    subject="Sales report — September 2026",
    plain_text_content="The September sales report is attached. Total revenue: 40,300.",
    html_content="<p>The September sales report is attached.</p><p><b>Total revenue: 40,300</b></p>",
)
message.attachment = excel_attachment("sales_report.xlsx")
payload = message.get()
print(payload["subject"], "|", payload["attachments"][0]["filename"], "|", payload["attachments"][0]["type"][:40] + "…")

from_email accepts a tuple of address and display name. Giving both a plain-text and an HTML body lets every mail client show something sensible. The MIME type tells the recipient's client to open the attachment with Excel; using application/octet-stream instead works, but some clients then show a generic file icon or warn about it. message.get() returns the JSON body the library will send — useful for checking the message before anything leaves.

Step 2 — Send it and read the response

SendGridAPIClient.send() posts the message to the /v3/mail/send endpoint:

Python
sg = SendGridAPIClient(os.environ["SENDGRID_API_KEY"])
response = sg.send(message)
print(response.status_code, response.headers.get("X-Message-Id"))

A status of 202 Accepted means SendGrid has queued the message for delivery — not that it has been delivered. The X-Message-Id header identifies the message in SendGrid's activity feed and event webhooks, so log it alongside the report run; if a recipient says they never received the report, that ID is how you find out whether it bounced, was blocked or went to spam. For EU accounts with data residency, call sg.set_sendgrid_data_residency("eu") before sending so requests go to the EU endpoint.

What happens when the job sends a report The report job posts the message with its API key to SendGrid's mail send endpoint and gets 202 Accepted with a message id. SendGrid then delivers the email and reports delivered, bounced or opened events that can be looked up by the message id. Report job SendGrid API Recipient 1. POST /v3/mail/send 2. 202 · X-Message-Id 3. delivers the email 4. events via webhook

Step 3 — One personalised email per recipient

Sending to several people with one to_emails list puts everyone in the same To: header, so recipients see each other's addresses. Personalizations send a separate copy to each recipient in a single API call, each with its own substitutions:

Python
from sendgrid.helpers.mail import Personalization, Substitution, To

managers = [
    {"email": "north@example.com", "name": "Ana", "region": "North", "revenue": 18400},
    {"email": "south@example.com", "name": "Ben", "region": "South", "revenue": 12100},
]

message = Mail(from_email=("reports@example.com", "Reporting"), subject="Your region: September 2026",
               html_content="<p>Hi -name-,</p><p>-region- revenue for September: <b>-revenue-</b>.</p>")
for m in managers:
    p = Personalization()
    p.add_to(To(m["email"], m["name"]))
    p.add_substitution(Substitution("-name-", m["name"]))
    p.add_substitution(Substitution("-region-", m["region"]))
    p.add_substitution(Substitution("-revenue-", f"{m['revenue']:,}"))
    message.add_personalization(p)
message.attachment = excel_attachment("sales_report.xlsx")

body = message.get()
print(len(body["personalizations"]), "copies:", [p["to"][0]["email"] for p in body["personalizations"]])
print(body["personalizations"][0]["substitutions"])

Each personalization becomes its own email: Ana sees only her address and "North revenue for September: 18,400". Up to 1,000 personalizations fit in one request. (The library inserts each new personalization at the front of the list, which is why South is listed first; the order makes no difference to delivery.) The attachment is shared by all copies; if each manager should get their own workbook, send one message per manager instead, as in Send Each Recipient Their Own Excel Report.

Step 4 — Use a dynamic template

For a report email whose layout marketing or finance want to edit without touching code, create a dynamic template in the SendGrid dashboard using Handlebars placeholders such as {{region}} and {{revenue}}, then send only the data:

Python
message = Mail(from_email=("reports@example.com", "Reporting"), to_emails="north@example.com")
message.template_id = "d-0123456789abcdef0123456789abcdef"
message.dynamic_template_data = {"name": "Ana", "region": "North", "revenue": "18,400", "period": "September 2026"}
message.attachment = excel_attachment("sales_report.xlsx")
body = message.get()
print(body["template_id"], "|", body["personalizations"][0]["dynamic_template_data"]["region"])

The template ID starts with d- and is copied from the dashboard. The subject can be part of the template too. Dynamic template data is per personalization, so the per-recipient pattern from Step 3 works the same way with p.dynamic_template_data = {...}. Keep numbers formatted in Python — Handlebars in SendGrid templates has only basic helpers.

Step 5 — Test without sending, and handle errors

Sandbox mode makes SendGrid validate the request completely — authentication, addresses, attachment encoding — and return 200 without delivering anything. Turn it on in tests and in a dry-run flag:

Python
from python_http_client.exceptions import HTTPError
from sendgrid.helpers.mail import MailSettings, SandBoxMode

def send_report(message, dry_run=False, api_key=None):
    if dry_run:
        message.mail_settings = MailSettings(sandbox_mode=SandBoxMode(True))
    try:
        resp = SendGridAPIClient(api_key or os.environ["SENDGRID_API_KEY"]).send(message)
        return resp.status_code, resp.headers.get("X-Message-Id")
    except HTTPError as err:
        details = [e["message"] for e in err.to_dict.get("errors", [])]
        raise RuntimeError(f"SendGrid rejected the report email ({err.status_code}): {details}") from err

msg = Mail(from_email="reports@example.com", to_emails="finance@example.com",
           subject="Dry run", plain_text_content="Test")
print("sandbox:", send_report(msg, dry_run=True))
try:
    send_report(Mail(from_email="reports@example.com", to_emails="finance@example.com",
                     subject="x", plain_text_content="x"), api_key="SG.revoked")
except RuntimeError as e:
    print(e)

The library raises HTTPError for any 4xx or 5xx response; err.to_dict holds SendGrid's JSON error list, which says exactly what is wrong — a revoked key, an unverified sender, a malformed address. Re-raising with that message makes the failure readable in the job log. Retry only on 429 (rate limited) and 5xx responses; a 400 or 401 will fail the same way every time. The general retry pattern is in Retry a Failed Excel Report Job in Python.

What to do with the response 202 means queued for delivery, so log the message id. 200 comes back in sandbox mode with nothing sent. 400 or 401 point to a problem in the request or key and should fail the job without retrying. 429 and 5xx responses are temporary and worth retrying with backoff. Which status came back? 202 queued for delivery log X-Message-Id 200 sandbox only nothing was sent 400 / 401 / 403 fix the request or key do not retry 429 / 5xx temporary retry with backoff

Size limits

SendGrid accepts messages up to 30 MB in total, including all attachments after base64 encoding, which inflates files by about a third — so a workbook much over 20 MB will not fit, and many receiving servers reject far smaller messages. Check the size before building the message:

Python
size_mb = Path("sales_report.xlsx").stat().st_size * 4 / 3 / 1024 / 1024
print(f"encoded attachment: {size_mb:.3f} MB of 30 MB")

For large reports, zip the workbook — see Zip Large Excel Attachments Before Emailing — or upload it to cloud storage and email a link.

Common pitfalls

  • Treating 202 as delivered. It means queued; check events or the activity feed for delivery.
  • All recipients in one to_emails list. Everyone sees everyone; use personalizations.
  • Unverified sender. The API returns 403 until the from address or domain is verified.
  • API key in the script. Read it from the environment or a secret store.
  • Retrying 4xx errors. They will not succeed on retry; fix the request.

Conclusion

The sendgrid library turns sending an Excel report into building a Mail, attaching the workbook as base64 with the xlsx MIME type, and calling send(). Use personalizations for per-recipient copies, a dynamic template when others own the email layout, sandbox mode for dry runs and tests, and HTTPError.to_dict for readable failures. Log the message ID from every 202 response, and keep attachments well under the 30 MB limit.

Frequently asked questions

How do I attach an Excel file with the SendGrid Python library? Base64-encode the file bytes, then create an Attachment with FileContent, FileName, FileType set to the xlsx MIME type and Disposition attachment, and assign it to message.attachment.

What does a 202 response from SendGrid mean? The message was accepted and queued for delivery. It does not confirm delivery; use the X-Message-Id header to look the message up in the activity feed or event webhooks.

How do I test SendGrid emails without sending them? Enable sandbox mode in the mail settings. SendGrid validates the whole request and returns 200 without delivering the email.

How do I send the same report to several people privately? Add one Personalization per recipient to the Mail. Each becomes a separate email with only that recipient in the To header.

More on email delivery

More on email delivery →