---
url: "https://vueda.dev/v3/guides/vdq-sms-twilio.md"
title: "Send SMS from VDQ with Twilio"
---

# Send SMS from VDQ with Twilio

This guide sets up Twilio as the SMS provider for the [VDQ (VUEDA Dispatch Queue)](/v3/reference/glossary.md#vdq-vueda-dispatch-queue): credentials, delivery status by webhook or by polling, and the Celery beat schedule that checks status. It then sends a test message and shows how each Twilio status changes the [Queue Item](/v3/reference/glossary.md#queue-item).

[Set Up and Operate the VUEDA Dispatch Queue (VDQ)](/v3/guides/vdq-actions.md) describes installing and routing VDQ, creating senders and receivers, running the worker, and retrying, cancelling, or resending items. This guide assumes that setup. [VDQ and Background Work Model](/v3/core-concepts/vdq-and-background-work.md) describes the queue item lifecycle and its known gaps.

## Configure Twilio Credentials

[core.default\_settings.get\_defaults](/v3/reference/api/py/vueda.core.default_settings.md#get_defaults) reads three Twilio keys from your project's `config.toml` (or `config.local.toml`):

```toml
TWILIO_ACCOUNT_SID = "AC..."
TWILIO_AUTH_TOKEN = "..."
TWILIO_CALLER_ID = "+18005550100"
```

- `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN` create the Twilio client in [TwilioQueueItemHandler](/v3/reference/api/py/vueda.vdq.handlers/TwilioQueueItemHandler.md). The auth token also checks the signature on status callbacks.
- `TWILIO_CALLER_ID` is the Twilio number that VUEDA's own user messages send from. VUEDA offers SMS for welcome, forgot password, and TOTP code messages only when [`is_twilio_configured`](/v3/reference/api/py/vueda.user.utils.md#is_twilio_configured) finds all three keys set. With VDQ installed, [`DefaultUserAdapter.send_sms`](/v3/reference/api/py/vueda.user.adapters/DefaultUserAdapter.md#send_sms) queues those messages through VDQ.

Without `TWILIO_ACCOUNT_SID`, the handler has no client. The worker then fails each SMS item and moves it to `errored` with a traceback in `result`.

## Choose How Delivery Status Arrives

Twilio reports delivery status after it accepts a message. The `TWILIO_WEBHOOK_URL` setting selects how VDQ receives that status.

| Mode    | `TWILIO_WEBHOOK_URL` | How status arrives                                            | Needs inbound access |
| ------- | -------------------- | ------------------------------------------------------------- | -------------------- |
| Webhook | Set                  | Twilio posts each status change to the callback endpoint.     | Yes                  |
| Polling | Not set              | A periodic task lists recent messages from Twilio every 30 s. | No                   |

Use polling on a development machine that Twilio cannot reach. Use the webhook in production, so each status arrives when Twilio sends it.

### Webhook mode

1. Set `TWILIO_WEBHOOK_URL` in your settings module. `get_defaults` does not read it, so read it from your config yourself:

    ```python
    TWILIO_WEBHOOK_URL = env("TWILIO_WEBHOOK_URL", default="")
    ```

2. Set the value to the public HTTPS URL of [vueda.vdq\_twilio\_status\_callback\_create](/v3/reference/api/rest/vueda.vdq/vueda.vdq_twilio_status_callback_create.md). Use the prefix where your project includes [vdq.urls](/v3/reference/api/py/vueda.vdq.urls.md). If you include them under the scaffolded `routes/` prefix, the URL is `https://app.example.com/routes/vueda.vdq/twilio-status-callback/`.

3. If a proxy terminates TLS in front of Django, configure the proxy to set the `X-Forwarded-Proto` header to `https`. The proxy must replace any value that the client sent. `get_defaults` sets [Django: SECURE\_PROXY\_SSL\_HEADER](https://docs.djangoproject.com/en/6.1/ref/settings/#std-setting-SECURE_PROXY_SSL_HEADER), so Django treats a request with that header as HTTPS. If the proxy rewrites the `Host` header, also set [Django: USE\_X\_FORWARDED\_HOST](https://docs.djangoproject.com/en/6.1/ref/settings/#std-setting-USE_X_FORWARDED_HOST).

    Twilio signs the full URL it posts to. [vdq.views.validate\_twilio\_request](/v3/reference/api/py/vueda.vdq.views.md#validate_twilio_request) rebuilds that URL with [Django: HttpRequest.build\_absolute\_uri](https://docs.djangoproject.com/en/6.1/ref/request-response/#django.http.HttpRequest.build_absolute_uri). It then checks the `X-Twilio-Signature` header against the rebuilt URL. A scheme or host mismatch fails every callback with `403`. A port difference does not.

When `TWILIO_WEBHOOK_URL` is set, [`send_sms`](/v3/reference/api/py/vueda.vdq.handlers/TwilioQueueItemHandler.md#send_sms) passes it to Twilio as `status_callback` on every message. It adds the queue item's primary key to the URL as the `queue_item` query parameter. The endpoint accepts requests without a login or a CSRF token, so the signature is its only check. Keep `TWILIO_AUTH_TOKEN` secret.

A callback can arrive before the worker stores the message SID. The endpoint then finds the item by its `queue_item` key, stores the SID, and applies the status. Because the signature covers the full URL, a callback with a changed key fails with `403`. [Matching a status update to its queue item](/v3/core-concepts/vdq-and-background-work.md#matching-a-status-update-to-its-queue-item) describes this fallback.

If the key finds no item, or the callback URL has no key, the endpoint queues [`check_previously_received_message_sid`](/v3/reference/api/py/vueda.vdq.tasks.md#check_previously_received_message_sid). That task looks for the message SID again every 10 seconds. After `VDQ_TWILIO_SMS_TIMEOUT_HOURS`, the task stops and logs an error, and VDQ drops the status from that callback.

### Polling mode

Leave `TWILIO_WEBHOOK_URL` unset or empty. VDQ sends messages with no callback URL, and [vdq.tasks.check\_sms\_status](/v3/reference/api/py/vueda.vdq.tasks.md#check_sms_status) polls Twilio instead. It lists the messages sent since the day that the oldest awaiting SMS item was queued. It then updates each awaiting item whose message SID matches.

## Run Celery Beat

Both modes rely on a periodic task that Celery beat schedules. When `TWILIO_ACCOUNT_SID` is set, [vdq.celery.setup\_periodic\_tasks](/v3/reference/api/py/vueda.vdq.celery.md#setup_periodic_tasks) registers one task on the VDQ Celery app ([vdq.celery](/v3/reference/api/py/vueda.vdq.celery.md)):

- Webhook mode: [vdq.tasks.check\_sms\_timeout\_only](/v3/reference/api/py/vueda.vdq.tasks.md#check_sms_timeout_only) every 30 seconds.
- Polling mode: `check_sms_status` every 30 seconds.

Set `DJANGO_SETTINGS_MODULE` to your settings module, then run the worker with an embedded beat scheduler:

```console
celery -A vueda.vdq.celery:app worker -l info -B
```

The `-B` flag runs beat inside the worker. Without the flag or a separate `celery beat` process, SMS items stay `awaiting`: polling never runs, and the timeout task never runs. In webhook mode, a later non-final callback can still time out an `awaiting` item. The app picks the task when it starts, so restart the worker after you change `TWILIO_WEBHOOK_URL` or `TWILIO_ACCOUNT_SID`.

## Send a Test Message

Open a Django shell (`python manage.py shell`) and queue a message with [`add_sms`](/v3/reference/api/py/vueda.vdq.schedulers.md#add_sms). The sender's `cell` must be a number that your Twilio account can send from.

```python
from django.conf import settings

from vueda.vdq.models import Receiver, Sender
from vueda.vdq.schedulers import add_sms

sender, _ = Sender.objects.get_or_create(name="System", cell=settings.TWILIO_CALLER_ID)
receiver, _ = Receiver.objects.get_or_create(name="Test recipient", cell="+18005550199")

[qi] = add_sms(sender=sender, receiver=receiver, body="VDQ test message")
```

`add_sms` takes [Sender](/v3/reference/api/py/vueda.vdq.models/Sender.md) and [Receiver](/v3/reference/api/py/vueda.vdq.models/Receiver.md) rows. If either has no `cell`, it raises `ValueError` before it creates anything. Otherwise it creates one queue item and one [SMSQueueItem](/v3/reference/api/py/vueda.vdq.models/SMSQueueItem.md), and returns the queue item in a one-item list. The `cell` fields store numbers in E.164 form, and `send_sms` sends them in that form.

After the worker runs, reload the item and read its state:

```python
qi.refresh_from_db()
qi.workflow_state.code, qi.sms.message_sid, qi.result
```

Once Twilio accepts the message, the item is `awaiting`. It has a message SID, and `result` holds Twilio's status text, such as `queued`. When a status update reached the item before the send finished, the send leaves `result` as that update set it. The next status update moves it to `succeeded` or `errored`, as the next section describes. [Set Up and Operate the VUEDA Dispatch Queue (VDQ)](/v3/guides/vdq-actions.md) shows how to inspect items through the API.

## Status Mapping

[`update_sms_qi`](/v3/reference/api/py/vueda.vdq.handlers/TwilioQueueItemHandler.md#update_sms_qi) applies each Twilio status the same way in both modes:

| Twilio status                  | Queue item result                                                                                               |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `delivered`                    | Moves to `succeeded`, and `result` is cleared.                                                                  |
| `undelivered` or `failed`      | Moves to `errored`. `result` holds the status, the Twilio error code and message, and doc links.                |
| `queued`, `sending`, or `sent` | Moves a `sending` item to `awaiting`. An `awaiting` item stays `awaiting`, unless the timeout below has passed. |
| Any other status               | Stays in its current state, unless the item is `awaiting` and the timeout below has passed.                     |

If Twilio rejects the send request itself (a `TwilioRestException`), the item moves to `errored` with Twilio's error message in `result`. Other worker exceptions also end in `errored`, with a traceback in `result`.

The timeout is `VDQ_TWILIO_SMS_TIMEOUT_HOURS`, 2 hours by default. It counts from the queue item's creation time, stored in [QueueItem.done\_since](/v3/reference/api/py/vueda.vdq.models/QueueItem.md#done_since). An item with no final status after that moves to `unconfirmed`, a [Done State](/v3/reference/glossary.md#done-state). Its `result` then reads "Carrier did not confirm delivery".

- In webhook mode, `check_sms_timeout_only` fetches each timed-out item's status from Twilio once more. It applies a final status if Twilio has one, and otherwise times the item out. A failed fetch also times the item out. A callback with a non-final status times out an `awaiting` item whose window has passed.
- In polling mode, an item times out when a poll returns its message with a non-final status.

A retried item keeps its creation time, so its first non-final status after the retry can time it out. A resent item is a new queue item with a new timeout window.
