Skip to main content

MailChannels Python SDK

Build Status PyPI PyPI - Python Version License: MIT Code style: ruff PyPI - Wheel

Built and tested against MailChannels Email API 1.7.0.

mailchannels is a typed Python SDK for the MailChannels Email API. It keeps the first send small, then opens up the production features that matter when email is part of a real product: queued delivery through /send-async, MailChannels-hosted DKIM, domain validation, templates, unsubscribe behavior, custom headers, metrics, suppressions, and webhooks.

MailChannels is especially strong for multi-tenant sending. Parent accounts can create isolated sub-accounts, issue separate credentials, set granular limits, inspect usage, and keep one customer's bad traffic from endangering the parent account or other tenants.

The SDK accepts familiar dictionary payloads for quick scripts and Pydantic models for codebases that prefer explicit runtime validation. Use the module-level resources when you want Resend-style convenience; create explicit Client instances when each tenant, account, or service needs its own credentials.

Start Here

Five-Minute Quickstart

Install

Install the SDK with uv:

uv add mailchannels

The synchronous client uses requests. Async HTTP support is optional:

uv add "mailchannels[async]"

Configure

For small applications and scripts, set the module-level API key once and use the top-level resources.

import mailchannels

mailchannels.api_key = "YOUR-API-KEY"

The SDK also reads MAILCHANNELS_API_KEY and MAILCHANNELS_API_URL from the environment. Environment variables are the cleanest option for deployed services because the same code can run in development, staging, and production without committing credentials or hostnames.

export MAILCHANNELS_API_KEY="YOUR-API-KEY"

For services that send on behalf of multiple accounts, create explicit clients. Each client carries its own API key.

import mailchannels

parent_client = mailchannels.Client(api_key="PARENT-ACCOUNT-API-KEY")
sub_account_client = mailchannels.Client(api_key="SUB-ACCOUNT-API-KEY")

Send

Use Emails.send() when you want immediate validation feedback from the regular send endpoint.

import mailchannels

mailchannels.api_key = "YOUR-API-KEY"

email = mailchannels.Emails.send(
    {
        "from": {"email": "sender@example.com", "name": "Priya Patel"},
        "to": [{"email": "recipient@example.net", "name": "Sakura Tanaka"}],
        "subject": "Testing Email API",
        "text": "Hi Sakura. This is just a test from Priya.",
    }
)

print(email)

Use Emails.queue() for /send-async when your application should hand the message to MailChannels quickly and continue without waiting for the regular send path.

queued = mailchannels.Emails.queue(
    {
        "from": {"email": "sender@example.com"},
        "to": [{"email": "recipient@example.net"}],
        "subject": "Queued message",
        "html": "<strong>Hello</strong>",
    }
)

This is usually the better default for high-throughput web applications, job workers, or any path where email should not slow down the user-facing request.

Send vs Queue, And The Two Meanings Of "Async"

The SDK exposes four sending methods, and the names can be confusing because "async" means two different things:

Method API endpoint Python execution
Emails.send() /send synchronous (blocks the calling thread)
Emails.send_async() /send asyncio (await-able)
Emails.queue() /send-async synchronous (blocks the calling thread)
Emails.queue_async() /send-async asyncio (await-able)

send vs queue is a server-side distinction: both endpoints run the same request validation and have the same delivery semantics, but /send-async defers some per-recipient processing into the background so the request returns faster. The _async suffix is a separate, client-side Python asyncio distinction — it's the same suffix used on every other resource (e.g. client.webhooks.list_async()).

Rule of thumb: prefer queue when request latency matters, and especially for sends with a large number of recipients — the per-recipient work on the /send path can be slow enough that the request times out. Pick send only when you specifically need the regular (non-deferred) path. Add _async if you're calling from asyncio code.

Core Concepts

API Coverage

The SDK covers the MailChannels Email API surfaces that production senders need most often:

Need SDK surface
Send now with /send mailchannels.Emails.send()
Queue for processing with /send-async mailchannels.Emails.queue()
Validate sender DNS and Domain Lockdown mailchannels.CheckDomain.check()
Manage hosted DKIM keys mailchannels.Dkim
Isolate tenants mailchannels.SubAccounts
Cap tenant volume mailchannels.SubAccounts.Limits
Inspect traffic health mailchannels.Metrics
Manage suppressions mailchannels.Suppressions
Receive delivery events mailchannels.Webhooks

Generated reports provide the deeper detail:

  • API coverage report summarizes endpoint coverage, contract-test coverage, online-test coverage, the OpenAPI spec hash, and the SDK version used for the report.
  • API reference lists public exports, classes, methods, parameters, return types, model fields, and compact examples from the SDK source.

Choose An Entry Point

Use this When it fits
mailchannels.Emails.send() You want synchronous validation from /send.
mailchannels.Emails.queue() You want fast handoff to /send-async; this is usually best for web requests and workers.
mailchannels.CheckDomain.check() You are onboarding or troubleshooting a sending domain.
mailchannels.Dkim You need MailChannels to create, store, rotate, or validate hosted DKIM keys.
mailchannels.SubAccounts You send for multiple tenants and need isolation, credentials, usage, or limits per tenant.
Explicit mailchannels.Client(...) You need different API keys, base URLs, or HTTP transports in the same process.

Path Variables

Pass raw identifiers to SDK methods. The SDK URL-encodes path variables such as sub-account handles, DKIM domains/selectors, suppression recipients, metric sender groups, and webhook batch IDs before building request URLs. Do not pre-encode those values yourself; a literal % is encoded as %25.

Redirects

The built-in synchronous and asynchronous transports do not follow HTTP redirects, including redirects to the same host. This prevents forwarding X-Api-Key and replaying send or management requests at a redirect target. A redirect raises ApiError through Client, with the original status and response headers. Direct transport calls return the original SDKResponse. Configure the intended API base URL explicitly; do not automatically retry a redirect or copy its Location into a new request. Custom transports should also disable redirect following. Response bodies are still read normally; this policy does not impose a response-size limit or a total elapsed deadline.

Read Responses

SDK responses behave like ordinary dictionaries, but they also support attribute access. HTTP response headers are preserved under http_headers for diagnostics and request IDs.

queued = mailchannels.Emails.queue(message)

print(queued["id"])
print(queued.id)
print(queued.http_headers)

Set strict_responses=True when you want modeled endpoints to return Pydantic response objects instead. See Advanced Usage for strict response models, custom transports, API compatibility metadata, and client lifecycle details.

Use Typed Models

Dictionary payloads are convenient, but long-lived applications often benefit from explicit types. EmailParams, EmailAddress, Content, and Personalization are Pydantic models that validate the request before it reaches the HTTP layer.

params = mailchannels.EmailParams(
    from_=mailchannels.EmailAddress(email="sender@example.com"),
    personalizations=[
        mailchannels.Personalization(
            to=[mailchannels.EmailAddress(email="recipient@example.net")]
        )
    ],
    subject="Typed message",
    content=[
        mailchannels.Content(type="text/plain", value="Hello from typed models.")
    ],
)

mailchannels.Emails.send(params)

Use typed models when you are constructing messages across several functions or want validation errors to appear close to the code that builds the payload.

Common Sending Recipes

Add Attachments

MailChannels expects attachment content to be Base64 encoded. The SDK's Attachment.from_bytes() helper handles that encoding, infers a MIME type from the filename, and preserves the MailChannels fields in the final send payload. Read the file or fetch the URL yourself and pass the bytes in — the SDK does not read files or make outbound HTTP requests on your behalf.

from pathlib import Path

invoice = mailchannels.Attachment.from_bytes(
    Path("invoice.pdf").read_bytes(),
    filename="invoice.pdf",
)

mailchannels.Emails.queue(
    {
        "from": {"email": "billing@example.com"},
        "to": [{"email": "recipient@example.net"}],
        "subject": "Your invoice",
        "text": "Your invoice is attached.",
        "attachments": [invoice],
    }
)

Preview With Dry Run

MailChannels supports dry-run validation on the send endpoint. Pass dry_run=True to send the request for validation and rendering checks without delivering the message.

preview = mailchannels.Emails.send(
    {
        "from": {"email": "sender@example.com"},
        "to": [{"email": "recipient@example.net"}],
        "subject": "Dry run",
        "text": "Validate this message without delivering it.",
    },
    dry_run=True,
)

Send A Template Email

MailChannels templates are part of the send payload rather than a separate template CRUD API. Mark each templated content part with template_type: "mustache" and provide recipient-specific values in dynamic_template_data.

preview = mailchannels.Emails.send(
    {
        "from": {"email": "sender@example.com"},
        "personalizations": [
            {
                "to": [{"email": "jane@example.net"}],
                "dynamic_template_data": {"name": "Jane Doe"},
            }
        ],
        "subject": "Template Example",
        "content": [
            {
                "type": "text/plain",
                "value": "Hello {{name}}",
                "template_type": "mustache",
            }
        ],
    },
    dry_run=True,
)

Manage DKIM Keys

MailChannels can generate and store DKIM private keys for your account. Create a key pair, publish the returned public DNS record in your own DNS zone, and then reference the selector when sending.

key = mailchannels.Dkim.create(
    "example.com",
    selector="mcdkim",
    algorithm="rsa",
    key_length=2048,
)

for record in key.get("dkim_dns_records", []):
    print(record["name"], record["type"], record["value"])

After the DNS record is published, send mail with the selector.

mailchannels.Emails.queue(
    {
        "from": {"email": "sender@example.com"},
        "to": [{"email": "recipient@example.net"}],
        "subject": "DKIM signed message",
        "text": "This message is signed by a MailChannels-hosted DKIM key.",
        "dkim_domain": "example.com",
        "dkim_selector": "mcdkim",
    }
)

See DKIM And DNS for key rotation, customer-managed private keys, and a Cloudflare DNS publication example.

Custom Tracking Domains

By default, MailChannels rewrites click/open tracking links and unsubscribe URLs to use shared MailChannels domains. Register your own hostname instead to keep those links on your brand.

The hostname needs a CNAME record pointing to links.mailchannels.net before registration can complete. If DNS ownership hasn't been verified yet, create() returns TXT record instructions instead of an active domain — add the TXT and CNAME records, then call create() again:

domain = mailchannels.CustomTrackingDomains.create(
    name="newsletter-clicks",
    hostname="click.example.com",
    scope="click",  # "click" | "open" | "unsubscribe"
)

if domain.get("status") != "active":
    print(domain.get("instructions"))
    print(domain.get("txt_record_name"), domain.get("txt_record_value"))

Once a domain is active, select it by name at send time:

mailchannels.Emails.queue(
    {
        "from": {"email": "sender@example.com"},
        "to": "recipient@example.net",
        "subject": "Newsletter",
        "text": "Hello",
        "tracking_settings": {
            "click_tracking": {"enable": True, "custom_domain_name": "newsletter-clicks"},
        },
        "unsubscribe_settings": {"custom_domain_name": "newsletter-unsubscribe"},
    }
)

CustomTrackingDomains.list(), update() (rename or toggle active/ disabled), and delete() round out the resource. See Custom Tracking Domains for the full DNS verification flow.

Add Unsubscribe Support

MailChannels can render a hosted one-click unsubscribe URL inside mustache content. Use the exported UNSUBSCRIBE_URL_PLACEHOLDER constant so the placeholder is not mistyped. MailChannels requires exactly one recipient per personalization when unsubscribe links are used.

mailchannels.Emails.queue(
    {
        "from": {"email": "sender@example.com"},
        "personalizations": [{"to": [{"email": "recipient@example.net"}]}],
        "subject": "Newsletter",
        "content": [
            {
                "type": "text/html",
                "value": (
                    "<p>Hello</p>"
                    f"<a href='{mailchannels.UNSUBSCRIBE_URL_PLACEHOLDER}'>"
                    "unsubscribe</a>"
                ),
                "template_type": "mustache",
            }
        ],
    }
)

For automatic List-Unsubscribe headers, set transactional to False. MailChannels documents that this mode also requires one recipient per personalization and DKIM signing.

Add Custom Email Headers

Use headers when a message needs additional application-specific metadata or tracking values. MailChannels may reject restricted or duplicate headers, so prefer a small, intentional set.

mailchannels.Emails.send(
    {
        "from": {"email": "sender@example.com"},
        "to": [{"email": "recipient@example.net"}],
        "subject": "Custom Header Example",
        "text": "This email includes custom headers.",
        "headers": {
            "X-Campaign-ID": "newsletter-123",
        },
    }
)

Headers can also be set per personalization. If the same header exists globally and on a personalization, MailChannels uses the personalization-level value.

Async Python

Async methods use the same payloads as the synchronous methods and are named with the _async suffix. Use them in FastAPI, Starlette, aiohttp workers, or other asyncio applications where blocking the event loop would be the wrong tradeoff.

import asyncio
import mailchannels

mailchannels.api_key = "YOUR-API-KEY"


async def main() -> None:
    queued = await mailchannels.Emails.queue_async(
        {
            "from": {"email": "sender@example.com"},
            "to": [{"email": "recipient@example.net"}],
            "subject": "Queued message",
            "text": "Hello",
        }
    )
    print(queued)


asyncio.run(main())

Install mailchannels[async] before using async methods.

Account And Domain Operations

Sub-Accounts

Sub-accounts are a major MailChannels feature, so they are exposed as a top-level resource. Parent accounts can create sub-accounts, issue API keys, manage SMTP passwords, set limits, and inspect usage.

sub_account = mailchannels.SubAccounts.create(
    company_name="Client A",
    handle="clienta",
)

sub_accounts = mailchannels.SubAccounts.list(limit=100, offset=0)
api_key = mailchannels.SubAccounts.ApiKeys.create("clienta")

Rate limits are useful when each customer, tenant, or downstream sender should have its own monthly allocation.

limit = mailchannels.SubAccounts.Limits.set(
    "clienta",
    sends=100_000,
)

current_limit = mailchannels.SubAccounts.Limits.retrieve("clienta")

Usage stats let you show customers how much of their allocation has been used or decide when to raise, lower, or suspend a limit.

usage = mailchannels.SubAccounts.retrieve_usage("clienta")
parent_usage = mailchannels.Usage.retrieve()

# monthly_limit is the effective cap for the period — 0 means the account
# can't send. A sub-account with no explicit limit set already resolves
# this to the parent account's limit (contrast SubAccounts.Limits.retrieve(),
# which reports -1 in that case).
print(f"{usage['total_usage']} / {usage['monthly_limit']}")

When sending as a sub-account, create a separate client with that sub-account's API key. This keeps account boundaries explicit in code.

client = mailchannels.Client(api_key="SUB-ACCOUNT-API-KEY")
client.emails.queue(
    {
        "from": {"email": "sender@client.example"},
        "to": [{"email": "recipient@example.net"}],
        "subject": "Client message",
        "text": "Hello",
    }
)

Domain Checks

Before sending from a domain, ask MailChannels to verify the domain's authentication posture. CheckDomain.check() calls /check-domain and returns the API's DKIM, SPF, sender-domain DNS, and Domain Lockdown results.

result = mailchannels.CheckDomain.check("example.com")

print(result.check_results["spf"]["verdict"])
print(result.references)

If you use MailChannels-hosted DKIM keys, pass the selector you expect the domain to use.

result = mailchannels.CheckDomain.check(
    "example.com",
    dkim_settings=[
        mailchannels.DkimSetting(
            dkim_domain="example.com",
            dkim_selector="mcdkim",
        )
    ],
)

Production Operations

Metrics

Metrics endpoints expose the operational view of your email traffic. Use them to build dashboards, reconcile campaign performance, or monitor sender health.

engagement = mailchannels.Metrics.engagement(
    start_time="2026-04-01",
    end_time="2026-04-24T00:00:00Z",
    campaign_id="newsletter",
    interval="day",
)

engagement() also reports unique_open/unique_click and their matching unique_open_tracking_delivered/unique_click_tracking_delivered denominators. Unlike open/click, these count each message at most once regardless of how many times it was opened or clicked, so divide them to get open/click rates that cannot exceed 100%:

engagement = mailchannels.Metrics.engagement(interval="day")

if engagement["unique_open_tracking_delivered"]:
    unique_open_rate = (
        engagement["unique_open"] / engagement["unique_open_tracking_delivered"]
    )

Sender metrics group results by campaigns or sub-accounts and support ordinary pagination controls.

senders = mailchannels.Metrics.senders(
    "sub-accounts",
    limit=50,
    offset=0,
    sort_order="desc",
)

Available metrics methods are engagement(), performance(), recipient_behaviour(), volume(), and senders(). The British spelling matches the underlying /metrics/recipient-behaviour endpoint.

Suppression Lists

Suppression lists are the MailChannels-native way to keep known unwanted recipients out of future sends. The SDK exposes list, create, and delete operations.

mailchannels.Suppressions.create(
    [
        {
            "recipient": "recipient@example.net",
            "suppression_types": ["non-transactional"],
            "notes": "Imported from billing system preference center.",
        }
    ],
    add_to_sub_accounts=True,
)

entries = mailchannels.Suppressions.list(
    source="api",
    limit=100,
)

mailchannels.Suppressions.delete("recipient@example.net", source="all")

add_to_sub_accounts=True is useful for parent-account workflows where one suppression should be copied across the tenant accounts beneath it.

Webhooks

MailChannels can send delivery events to your application for accepted, delivered, bounced, opened, clicked, complained, and unsubscribed messages. The SDK exposes webhook enrollment, validation, batch inspection, batch resend, and public signing-key retrieval.

mailchannels.Webhooks.create("https://example.com/mailchannels/events")

validation = mailchannels.Webhooks.validate(request_id="test_request_1")
batches = mailchannels.Webhooks.batches(statuses=["4xx", "5xx"], limit=50)

mailchannels.Webhooks.resend_batch(12345)

Webhook receivers should verify the customer_handle in each event and check the signature headers MailChannels sends with the request. The SDK can verify the Content-Digest, replay age, and RFC 9421 Ed25519 signature when given the public signing key returned by Webhooks.public_key(...).

import mailchannels
from mailchannels import (
    signature_key_id,
)


def receive_webhook(headers: dict[str, str], body: bytes) -> None:
    """Verify a webhook before processing events."""
    key_id = signature_key_id(headers)
    if key_id is None:
        raise ValueError("Missing MailChannels webhook key ID")

    public_key = mailchannels.Webhooks.public_key(key_id)

    if not mailchannels.Webhooks.verify(headers, body, public_key):
        raise ValueError("Invalid MailChannels webhook signature")

Error Handling

The SDK maps common MailChannels API failures to typed exceptions. Catch the specific error when your application can respond differently to authentication, authorization, invalid requests, missing resources, rate limits, conflicts, payload size problems, or server-side failures.

try:
    mailchannels.Emails.queue(message)
except mailchannels.PayloadTooLargeError:
    raise
except mailchannels.ForbiddenError:
    raise
except mailchannels.RateLimitError:
    raise
except mailchannels.NotFoundError:
    raise
except mailchannels.InvalidRequestError:
    raise
except mailchannels.ServerError:
    raise

Each exception carries structured metadata for logs and support workflows: status_code, code, error_type, headers, request_id, retry_after, suggested_action, and the parsed API response. Use to_dict() when you want to send consistent error metadata to your logger.

API Reference And Further Reading

  • API reference is the generated public surface reference.
  • API coverage shows endpoint, contract-test, and online test coverage against the MailChannels OpenAPI document.
  • Advanced usage covers strict responses, custom transports, API compatibility metadata, client lifecycle, and low-level webhook helpers.
  • DKIM and DNS covers hosted DKIM keys, rotation, customer-managed keys, and Cloudflare DNS publication.
  • Development covers local checks, online tests, SmolVM, CI, and publishing.
  • examples/ contains tested examples for async sending, attachments, templates, unsubscribe, custom headers, DKIM, Cloudflare DKIM publication, sub-accounts, metrics, domain checks, suppressions, webhooks, usage, custom HTTP clients, and structured error handling.

Using This SDK With An AI Agent

This repository ships a complete agent skill at .agents/skills/mailchannels-python/. It teaches an AI coding agent how to use the mailchannels package correctly — the same recipes documented in this README, broken into focused per-topic files and prefixed with a decision tree so the agent loads only the parts the task actually needs.

Layout:

.agents/skills/mailchannels-python/
├── SKILL.md              # entry point: scope, decision tree, conventions
└── resources/            # focused recipes (sending, dkim, webhooks, …)

Every file is plain Markdown. The skill works with any agent that can be pointed at a directory of context files — Claude Code, Cursor, Codex CLI, Aider, Continue, and similar tools all consume it without modification.

Install

The skill ships inside the mailchannels PyPI source distribution. Use pip download to fetch the sdist, extract it, and copy the skill directory wherever your agent looks for skills, rules, or context files:

# Pin the version to match the SDK you have installed — omit the pin to
# grab the latest release:
pip download --no-deps --no-binary :all: --dest /tmp/mc-python-sdk mailchannels

tar -xzf /tmp/mc-python-sdk/mailchannels-*.tar.gz -C /tmp/mc-python-sdk

# Get the name of the extracted directory, which includes the version number: (e.g. `mailchannels-1.0.0`):
sdk_dir=$(ls /tmp/mc-python-sdk/ -I *.tar.gz)

# Replace the destination with your agent's path:
mkdir -p <your-agent's-skills-dir>
cp -r /tmp/mc-python-sdk/${sdk_dir}/.agents/skills/mailchannels-python <your-agent's-skills-dir>/

rm -rf /tmp/mc-python-sdk

--no-binary :all: forces pip to download the source distribution (.tar.gz) rather than the wheel; the wheel only contains the importable Python package and doesn't carry the agent skill. --no-deps skips dependency downloads since you only need the SDK's own sdist.

Re-run the same commands when you upgrade the SDK so the skill stays in step with the installed version.

Common destinations:

Agent Where to put it
Claude Code .claude/skills/ (project) or ~/.claude/skills/ (user)
Cursor .cursor/rules/ (or attach files inline with @)
Codex CLI referenced from the project's AGENTS.md
Aider referenced from the conventions file in .aider.conf.yml
Continue registered as a custom context provider

If your tool isn't listed, look for the equivalent of "skill", "rule", "context bundle", or "conventions file" — any mechanism that lets the agent read a directory of Markdown will work.

Workflow

The flow is the same regardless of agent:

  1. The agent loads SKILL.md for the scope statement and decision tree.
  2. The decision tree routes to the relevant resources/*.md file(s).
  3. The agent reads only those files; no need to preload everything.

Ask the agent to "send an email with MailChannels", "verify a webhook signature", "rotate a DKIM key", or any similar task in the SDK's domain. The skill's frontmatter description tells the agent when to engage, and the body tells it what to do.

License

MIT License. See LICENSE. Published artifacts include the license text and the SPDX MIT license expression.

Metadata

Release files for mailchannels 1.5.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mailchannels 1.5.1
File Size Uploaded
mailchannels-1.5.1.tar.gz 287.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mailchannels 1.5.1
File Interpreter ABI Platform
mailchannels-1.5.1-py3-none-any.whl Python 3 none any Details

Total release size: 347.3 kB

Release files / mailchannels-1.5.1.tar.gz

Download URL mailchannels-1.5.1.tar.gz
Size 287.2 kB
Tags Source
SHA-256 checksum
How to use checksums
df2e230f1739369304b971950049fabd350880cc59ca904db53cff2a5c3e0c72
BLAKE2b-256 checksum
How to use checksums
2ba845820c625685be34d41f90b138fcb3fb0b0c95fd5df3f943ebda614c5a60
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.13

Release files / mailchannels-1.5.1-py3-none-any.whl

Download URL mailchannels-1.5.1-py3-none-any.whl
Size 60.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0779f5459af9d3d7ec5e87f1b993933f902b69834a6ff3848681f8d397148910
BLAKE2b-256 checksum
How to use checksums
876429be0ce28f6b73b9e14476a470e75a081e5d1d6f9c02d68d9f461b12721c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.13

Release history Release notifications | RSS feed

This release

1.5.1 This release

2 release files

1.5.0

2 release files

1.3.0

2 release files

1.2.0

2 release files

1.0.0

2 release files

0.1.3

2 release files

0.1.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page