mrkeyoor.com_
Sat 08 Aug 20:58 UTC
PyPIWeb Backendupdated 08 Aug 2026

sendgrid

Twilio SendGrid's official Python client for the SendGrid Web API v3. Two things live in the package: a set of Mail helper classes that assemble the JSON body for the /mail/send endpoint, and a fluent client where sg.client.suppression.bounces.get() maps directly onto the REST path. It also ships an EventWebhook helper for verifying signed webhook callbacks. It is an HTTP wrapper, not an SMTP library and not a mail server: every object here exists to produce SendGrid-shaped JSON, and everything about deliverability stays in the SendGrid console.

Verdict

If your mail already goes through SendGrid, this is the path of least resistance and the helper classes save real boilerplate on /mail/send. Pick it knowing it is a slow-moving vendor wrapper that locks your send path to one provider, and reach for django-anymail if that lock-in matters.

API stability4/5The v6 line has held its shape for years: SendGridAPIClient, the Mail helper classes and the fluent client all behave the same across recent releases, and 6.12.x changes have been dependency and small fix work. The instability you actually face comes from the SendGrid API itself rather than from this wrapper, since the fluent interface passes paths straight through.
Docs3/5The README covers installation and the two ways to send, USAGE.md enumerates the endpoints, and the examples directory has working scripts. Beyond that the real reference is Twilio's docs site rather than the package, docstrings are terse, and several links in the README still point at old sendgrid.com documentation paths that redirect.
Maintenance2/5Version 6.12.5 was released on 2025-09-19 and the repository was last pushed on 2026-06-25, with 65 open issues and pull requests. It is staffed by the vendor rather than abandoned, and security-relevant dependency bumps do land, but feature work and issue triage move slowly, and the packaging metadata still advertises Python 2.7 support.
Ecosystem4/5Roughly 6,610,530 weekly downloads and 1,629 stars, and it is the official client that SendGrid's own documentation points Python users at. What surrounds it is thin though: the helpers, webhook verification and inbound parsing all live inside this one repository, so there is little third-party tooling to reach for.

Use it if

  • You already send through Twilio SendGrid and want the /mail/send request body assembled by helper classes instead of hand-written dictionaries
  • You need the rest of the v3 API from Python (suppressions, templates, subusers, stats, API keys) through one client that mirrors the REST paths
  • You have to verify Event Webhook callbacks in Python, which the EventWebhook helper does with the ECDSA public key from Mail Settings
  • You need EU data residency, which set_sendgrid_data_residency('eu') switches on by repointing the client at api.eu.sendgrid.com
Skip it if

Setup reality

pip install sendgrid pulls python-http-client and cryptography, so there is a compiled dependency in the tree even though you are only making HTTP calls. Before a single send works you have to create an API key in the console and authenticate either a single sender or a whole domain through DNS; until that is done every request comes back 403 no matter how correct your code is. Put the key in SENDGRID_API_KEY, because the constructor reads nothing by default and passing it inline is how keys end up in git (the package even ships a ValidateApiKey helper for pre-commit checks). The Mail helper is more permissive than it looks: to_emails accepts a string, a tuple, a To object, or a list of any of those, and the is_multiple flag is what decides whether recipients see each other, so getting it wrong leaks addresses. Responses are python_http_client Response objects with status_code, headers, and a body that is bytes, not a parsed dict, and a 202 means SendGrid accepted the message for processing, not that anyone received it. Failures raise instead of returning, and the readable error text is inside e.body. Dynamic templates are an either-or: once you set template_id and dynamic_template_data, any Content you attached is ignored, which is a common silent surprise. Testing against production is avoidable with SandBoxMode in mail_settings, which validates the payload without sending. If you need EU residency, call set_sendgrid_data_residency('eu') before the first send, since it rebuilds the underlying client.

Patterns

Send a plain text emailsend-basic-email

import os
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail

message = Mail(
    from_email='alerts@yourdomain.com',
    to_emails='user@example.com',
    subject='Deploy finished',
    plain_text_content='Build 412 is live.',
)

sg = SendGridAPIClient(os.environ['SENDGRID_API_KEY'])
response = sg.send(message)
print(response.status_code)

from_email must be an address you verified in the console, otherwise the call returns 403 with a 'from address does not match a verified Sender Identity' body.

Send both a text and an HTML partsend-html-and-text

message = Mail(
    from_email=('alerts@yourdomain.com', 'Status Bot'),
    to_emails='user@example.com',
    subject='Weekly summary',
    plain_text_content='Plain text fallback.',
    html_content='<p>Rendered <strong>summary</strong>.</p>',
)

Order matters in the generated body: pass plain_text_content as well as html_content so the text/plain part is listed first, which is what the API expects.

Send one message to many people privatelysend-to-many-recipients

from sendgrid.helpers.mail import Mail, To

recipients = [To('a@example.com'), To('b@example.com'), To('c@example.com')]
message = Mail(
    from_email='alerts@yourdomain.com',
    to_emails=recipients,
    subject='Maintenance window',
    plain_text_content='Saturday 02:00 UTC.',
    is_multiple=True,
)

is_multiple=True builds one personalization per recipient. Leave it False and everyone sees the whole To list, which is the usual way addresses leak.

Render a dynamic templateuse-dynamic-template

message = Mail(from_email='alerts@yourdomain.com', to_emails='user@example.com')
message.template_id = 'd-1234567890abcdef1234567890abcdef'
message.dynamic_template_data = {
    'first_name': 'Asha',
    'invoice_total': '1,240.00',
}

response = sg.send(message)

Once template_id is set the API ignores subject and any Content you attached. If the template renders empty, the usual cause is a key name that does not match the handlebars variable.

Attach a base64 encoded fileattach-a-file

import base64
from sendgrid.helpers.mail import Attachment, FileContent, FileName, FileType, Disposition

with open('invoice.pdf', 'rb') as fh:
    encoded = base64.b64encode(fh.read()).decode()

message.attachment = Attachment(
    FileContent(encoded),
    FileName('invoice.pdf'),
    FileType('application/pdf'),
    Disposition('attachment'),
)

The content must be base64 as a str, not bytes. Total message size including attachments is capped by the API at 30 MB, and the base64 expansion counts against it.

Dry run with sandbox modevalidate-without-sending

from sendgrid.helpers.mail import MailSettings, SandBoxMode

message.mail_settings = MailSettings()
message.mail_settings.sandbox_mode = SandBoxMode(True)

response = sg.send(message)  # 200, nothing delivered

Sandbox mode validates the payload and returns 200 instead of the usual 202. Use it in tests so CI never sends real mail, and assert on 200 rather than 202 there.

Read the actual error messagehandle-api-errors

import json
from python_http_client.exceptions import HTTPError

try:
    response = sg.send(message)
except HTTPError as exc:
    detail = json.loads(exc.body.decode())
    print(exc.status_code, detail['errors'])

exc.body is bytes and the useful part is the errors list. Catching a bare Exception here hides the field-level reason, which is often the only clue about a rejected payload.

Schedule delivery for laterschedule-a-send

import datetime

send_at = int((datetime.datetime.now(datetime.timezone.utc)
               + datetime.timedelta(hours=6)).timestamp())
message.send_at = send_at

send_at is a Unix timestamp and must be no more than 72 hours in the future. Cancelling a scheduled batch later needs a batch_id set at send time.

Add categories and custom argumentstag-for-analytics

from sendgrid.helpers.mail import Category, CustomArg

message.category = [Category('billing'), Category('invoice-reminder')]
message.custom_arg = CustomArg('tenant_id', 'acct_9182')

Categories are capped at 10 per message and show up in stats. Custom args come back on Event Webhook payloads, which is how you join events to your own records.

Verify a signed Event Webhook requestverify-event-webhook

from sendgrid.helpers.eventwebhook import EventWebhook, EventWebhookHeader

verifier = EventWebhook(os.environ['SENDGRID_WEBHOOK_PUBLIC_KEY'])
ok = verifier.verify_signature(
    raw_body,
    request.headers[EventWebhookHeader.SIGNATURE],
    request.headers[EventWebhookHeader.TIMESTAMP],
)
if not ok:
    return '', 403

Pass the exact raw request body as a string. Any framework that re-serialises JSON before you get to it breaks the signature, so read the body before parsing.

Use the fluent client for non-mail endpointscall-other-endpoints

bounces = sg.client.suppression.bounces.get()
print(bounces.status_code, bounces.body)

# path segments that are not valid Python identifiers
stats = sg.client._('stats').get(query_params={'start_date': '2026-08-01'})

Attribute access builds the URL path, so a typo becomes a 404 rather than an AttributeError. Use the _() escape hatch for segments with dashes or path parameters.

Route sends through the EU regionuse-eu-data-residency

sg = SendGridAPIClient(os.environ['SENDGRID_API_KEY'])
sg.set_sendgrid_data_residency('eu')
response = sg.send(message)

Only 'eu' and 'global' are accepted, anything else raises ValueError. Call it before the first request because it rebuilds the underlying HTTP client, and the API key must belong to an EU-provisioned account.

Alternatives

PackageRegistryPick it when
django-anymailPyPIYou want one send API across SendGrid, SES, Mailgun, Postmark and others so the provider stays a config value
resendPyPIYou are picking a transactional provider fresh and want a much smaller client surface with a maintained SDK
boto3PyPIYou are already on AWS and Amazon SES pricing and IAM integration matter more than SendGrid's template and analytics tooling