Python SDK

Sending messages from Flask with the Sent Python SDK

This guide shows you how to wire Sent messaging into an existing Flask app: install the Python SDK, configure a shared client, send a template message from a route, receive delivery webhooks, and verify the whole loop in sandbox mode.

Prerequisites

This guide assumes a working Flask 3.x app and familiarity with blueprints. You also need:

Install the SDK

Add the sentdm package to your existing environment:

pip install sentdm

Configure the client

Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4:

export SENT_DM_API_KEY="your-api-key"
export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret"

Create one shared client and cache it on Flask's request context; Sent() reads SENT_DM_API_KEY by default:

# app/sent_client.py
from flask import g
from sent_dm import Sent

def get_sent_client() -> Sent:
    if "sent_client" not in g:
        g.sent_client = Sent()
    return g.sent_client

Send a template message from a route

Add a blueprint route that calls messages.send; the pass-through sandbox flag lets callers exercise the route without delivering anything:

# app/messages.py
from flask import Blueprint, jsonify, request
from app.sent_client import get_sent_client

messages_bp = Blueprint("messages", __name__, url_prefix="/api/messages")

@messages_bp.post("/send")
def send_message():
    data = request.get_json(force=True)
    response = get_sent_client().messages.send(
        to=[data["phone_number"]],              # E.164 format, for example +14155551234
        template={
            "name": data["template_name"],      # reference by "name" or "id", never both
            "parameters": data.get("parameters", {}),
        },
        channel=data.get("channels"),           # omit to let Sent pick per recipient
        sandbox=data.get("sandbox", False),     # True = validate and simulate only
    )
    recipient = response.data.recipients[0]
    return jsonify({"message_id": recipient.message_id, "status": response.data.status}), 202

Register the blueprint wherever you create your app:

app.register_blueprint(messages_bp)

Sent accepts sends asynchronously: the API responds with status QUEUED and one message_id per recipient-and-channel pair. Store the message_id: delivery outcomes arrive on your webhook endpoint instead of in this response.

Receive delivery webhooks

Add a decorator that verifies the X-Webhook-Signature header against the raw request body before your handler trusts any event. The scheme is HMAC-SHA256 over {X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}, keyed with the base64-decoded secret after stripping its whsec_ prefix. See webhook signature verification for the full scheme:

# app/webhook_auth.py
import base64
import functools
import hashlib
import hmac
import os
import time
from flask import request, jsonify

def verify_webhook_signature(f):
    @functools.wraps(f)
    def decorated_function(*args, **kwargs):
        signature = request.headers.get('X-Webhook-Signature')
        if not signature:
            return jsonify({'error': 'Missing webhook signature'}), 401
        webhook_secret = os.environ.get('SENT_DM_WEBHOOK_SECRET')
        if not webhook_secret:
            return jsonify({'error': 'Webhook secret not configured'}), 500

        webhook_id = request.headers.get('X-Webhook-ID', '')
        timestamp = request.headers.get('X-Webhook-Timestamp', '')

        # Strip the "whsec_" prefix and base64-decode to get the raw HMAC key
        key_bytes = base64.b64decode(webhook_secret.removeprefix('whsec_'))
        # Signed content = "{webhookId}.{timestamp}.{rawBody}"; signature format = "v1,{base64(hmac)}"
        signed = f"{webhook_id}.{timestamp}.{request.get_data().decode('utf-8')}"
        digest = hmac.new(key_bytes, signed.encode('utf-8'), hashlib.sha256).digest()
        expected = 'v1,' + base64.b64encode(digest).decode()
        if not hmac.compare_digest(signature, expected):
            return jsonify({'error': 'Invalid signature'}), 401

        # Reject replayed events older than 5 minutes
        try:
            if abs(time.time() - int(timestamp)) > 300:
                return jsonify({'error': 'Timestamp too old'}), 401
        except ValueError:
            return jsonify({'error': 'Invalid timestamp'}), 401

        return f(*args, **kwargs)
    return decorated_function

Add the endpoint itself. Every event arrives in the same envelope (field, event, timestamp, payload), so one handler routes all of them; return 200 quickly and do slow work elsewhere:

# app/webhooks.py
import logging
from flask import Blueprint, jsonify, request
from app.webhook_auth import verify_webhook_signature

logger = logging.getLogger(__name__)
webhooks_bp = Blueprint("webhooks", __name__, url_prefix="/webhooks")

@webhooks_bp.post("/sent")
@verify_webhook_signature
def handle_sent_webhook():
    event = request.get_json(force=True)
    payload = event.get("payload", {})

    if event.get("field") != "message":
        logger.info("Unhandled webhook field: %s", event.get("field"))
        return jsonify({"received": True}), 200

    match event.get("event"):
        case "message.delivered":
            logger.info("Message %s delivered", payload.get("message_id"))
        case "message.failed":
            logger.error("Message %s failed (status %s)",
                         payload.get("message_id"), payload.get("message_status"))
        case "message.received":
            logger.info("Inbound %s from %s: %s", payload.get("channel"),
                        payload.get("inbound_number"), payload.get("text"))
        case _:
            logger.info("Message %s status: %s",
                        payload.get("message_id"), payload.get("message_status"))

    return jsonify({"received": True}), 200

Register webhooks_bp the same way as the messages blueprint, then tell Sent where to deliver events. If you prefer a UI, use the webhooks getting started guide; otherwise register over the API:

curl -X POST https://api.sent.dm/v3/webhooks \
  -H "x-api-key: $SENT_DM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Flask integration",
    "endpoint_url": "https://your-domain.example/webhooks/sent",
    "event_types": ["message"]
  }'

Copy two values from the response: the webhook id (used to test delivery in the next step) and signing_secret, which goes in SENT_DM_WEBHOOK_SECRET. The message event type covers every message event; the webhook event types reference lists all payload fields.

Verify the integration

Start the app with your credentials loaded:

flask run

Send a sandbox message through your new route (full validation runs, but nothing is delivered and no credits are consumed):

curl -X POST http://localhost:5000/api/messages/send \
  -H "Content-Type: application/json" \
  -d '{"phone_number": "+14155551234", "template_name": "welcome",
       "parameters": {"name": "Ada"}, "sandbox": true}'

The response should contain a message_id and "status": "QUEUED". A 400 here means the request shape is wrong. Sandbox requests return real validation errors.

Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook id you copied:

curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \
  -H "x-api-key: $SENT_DM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "message.delivered"}'

Your Flask log should show a Message ... delivered line, and the endpoint should have answered 200 {"received": true}. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix.

Adapt this to your app

  • If you send in bulk or on a schedule, move the messages.send call into a background worker; the Celery integration guide shows the pattern.
  • If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and process asynchronously so retries do not pile up; see handling webhook retries.
  • To send free-form text instead of a template, pass text instead of template, since each send carries exactly one of the two.
  • If you need request validation, rate limiting, or security headers around these routes, the appendix below has the scaffolding.

Appendix: production scaffolding

The numbered steps stay on the core messaging tasks. The blocks below are optional deployment scaffolding for a Flask stack. Adapt them to your own conventions rather than adopting them wholesale.

Next steps

On this page