Skip to content
couryo

Developer guideIntegrations

Send email from Django

Send email with Couryo from Django with send_mail and EmailMultiAlternatives, through the Python SDK's backend or over SMTP, and receive signed webhooks.

View as Markdown

send_mail, EmailMessage and Django's own emails (password reset from django.contrib.auth, error reports) go out through Couryo by changing only the backend.

Option 1: SDK backend#

Terminal
pip install "couryo[django]"

On Django 6.1 or newer, the backend goes in MAILERS:

settings.py
import os

MAILERS = {
    "default": {
        "BACKEND": "couryo.django.EmailBackend",
        "OPTIONS": {"api_key": os.environ["COURYO_API_KEY"]},  # ck_test_... while testing
    },
}
DEFAULT_FROM_EMAIL = "Example <hello@example.com>"
SERVER_EMAIL = "alerts@example.com"

On Django 6.0 or older, use EMAIL_BACKEND (Django 6.1 still accepts it with a deprecation warning, but not together with MAILERS):

settings.py
import os

EMAIL_BACKEND = "couryo.django.EmailBackend"
COURYO_API_KEY = os.environ["COURYO_API_KEY"]  # ck_test_... while testing
DEFAULT_FROM_EMAIL = "Example <hello@example.com>"
SERVER_EMAIL = "alerts@example.com"

Your existing code does not change:

orders/emails.py
from django.core.mail import EmailMultiAlternatives, send_mail
from django.template.loader import render_to_string

send_mail("Order received", "Thanks for your order!", None, ["ana@example.com"])

context = {"order": order}
msg = EmailMultiAlternatives(
    subject=f"Order {order.number} shipped",
    body=render_to_string("emails/order_shipped.txt", context),
    to=[order.customer.email],
    headers={"X-Couryo-Tags": "kind=order"},
)
msg.attach_alternative(render_to_string("emails/order_shipped.html", context), "text/html")
msg.send()

Attachments (msg.attach(...)) and reply_to, cc and bcc go through to the API. The X-Couryo-Stream: marketing header sends on the marketing stream; without it, the email is transactional.

Call the API directly#

accounts/views.py
from couryo import Couryo

couryo = Couryo(language="en")  # reads COURYO_API_KEY

couryo.events.track(
    {"contact": {"email": user.email, "attributes": {"name": user.first_name}}, "name": "user.signed_up"},
    idempotency_key=f"signup-{user.pk}",
)

In async views, use AsyncCouryo with await.

Option 2: SMTP#

On Django 6.1 or newer:

settings.py
MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {
            "host": "smtp.couryo.com",
            "port": 587,
            "use_tls": True,  # STARTTLS; for port 465, use "use_ssl": True instead
            "username": "couryo",
            "password": os.environ["COURYO_API_KEY"],
        },
    },
}
DEFAULT_FROM_EMAIL = "Example <hello@example.com>"

On Django 6.0 or older:

settings.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.couryo.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True  # STARTTLS; for port 465, use EMAIL_USE_SSL = True
EMAIL_HOST_USER = "couryo"
EMAIL_HOST_PASSWORD = os.environ["COURYO_API_KEY"]
DEFAULT_FROM_EMAIL = "Example <hello@example.com>"

use_tls and use_ssl (EMAIL_USE_TLS and EMAIL_USE_SSL) cannot both be True. Details and reply codes in SMTP.

Receive webhooks#

webhooks/views.py
import os

from couryo import WebhookVerificationError, verify_webhook
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST


@csrf_exempt
@require_POST
def couryo_webhook(request):
    try:
        event = verify_webhook(os.environ["COURYO_WEBHOOK_SECRET"], request.headers, request.body)
    except WebhookVerificationError:
        return HttpResponseBadRequest("invalid signature")

    if event["type"] == "bounced":
        ...  # event["data"]["recipient"], event["data"]["explanation"]
    return HttpResponse(status=204)

request.body is the raw body, exactly what the signature expects. The view skips CSRF because the signature already protects it.

Django's password reset#

PasswordResetView uses the default mailer (MAILERS["default"], or EMAIL_BACKEND on Django 6.0 and older), so it already goes through Couryo. Just check DEFAULT_FROM_EMAIL (on a verified domain) and the link's domain: in production Django builds the link from the request host, so keep ALLOWED_HOSTS right. If the reset email does not arrive, the guide Password reset email not arriving walks through the causes one by one.

Common problems#

Symptom What to do
InvalidRequestError with code domain_not_verified DEFAULT_FROM_EMAIL must be on a domain verified in the project
Nothing happens and no error fail_silently=True hides errors; remove it to see Couryo's message
SMTPAuthenticationError (535) username other than couryo, or a key that is invalid, revoked or without the send scope