How to Provision and Warm Up a Dedicated Email IP with Python
Shared email IPs are a good default for many applications. They require no warm-up and let a provider manage the sending infrastructure for you.
But a high-volume sender may eventually need more control over the IP-level reputation attached to its transactional email. That is where a dedicated sending IP becomes useful. It isolates your IP reputation from other senders, while leaving domain reputation, message quality, engagement, and mailbox-provider policies in the equation.
In this tutorial, we will build a small Python service that:
- Purchases a dedicated email IP.
- Starts its warm-up lifecycle.
- Waits for the required provisioning and DNS state.
- Creates a default IP pool.
- Adds the dedicated IP to that pool.
- Sends a test email and verifies that Telnyx accepted it.
The complete sample is available in the provision-and-route-email-dedicated-ips directory.
Start in demo mode
The sample defaults to DEMO_MODE=true, which simulates the workflow without purchasing an IP or sending an email. Live mode creates a billable resource and sends a real message.
The API flow
The application is a small Flask service with both a POST /run endpoint and a one-shot CLI command. Both execute the same sequence:
POST /email/dedicated_ipsPOST /email/dedicated_ips/{id}/actions/start_warmupGET /email/dedicated_ips/{id}POST /email/ip_poolsPOST /email/ip_pools/{id}/ipsPOST /email_messages
Before the purchase, the live workflow also checks the account's email domains. After warm-up, it looks for an existing default pool before creating one and checks pool membership before adding the IP. Those read-before-write checks make reruns safer.
The sample uses requests for the dedicated IP and pool operations because the Python SDK version pinned and tested by the project does not expose those resources. It uses the Telnyx Python SDK for the final email send.
1. Run the preflight before spending
The live workflow calls GET /email_domains before purchasing the IP. If the configured sender is not on a verified sending domain, it stops with instructions instead of creating a resource that cannot complete the final send.
If the sender uses Telnyx's shared domain, the preflight also checks the account owner's email with GET /whoami. Shared-domain sends are restricted to that verified address, so this catches another avoidable failure before purchase.
2. Purchase the dedicated IP safely
Purchasing an IP changes account state and creates a billable resource, so the API requires an Idempotency-Key. If a request times out after Telnyx accepts it, retrying with the same key prevents an accidental duplicate purchase.
import uuid
import requests
response = requests.post(
"https://api.telnyx.com/v2/email/dedicated_ips",
headers={
"Authorization": f"Bearer {TELNYX_API_KEY}",
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
},
json={
"region": "ewr-nj1",
"ip_version": "ipv4"
},
)
response.raise_for_status()
dedicated_ip = response.json()["data"]
The initial resource is still being provisioned. It is not ready for production traffic merely because the purchase request returned successfully.
The sample also accepts REUSE_DEDICATED_IP_ID. Set it to an existing IP ID after an interrupted or timed-out run so the next attempt resumes that resource instead of making another purchase.
3. Start warm-up
A new dedicated IP has no established sending reputation. Warm-up gradually increases traffic so mailbox providers can observe consistent sending behavior.
requests.post(
(
"https://api.telnyx.com/v2/email/dedicated_ips/"
f"{dedicated_ip['id']}/actions/start_warmup"
),
headers={"Authorization": f"Bearer {TELNYX_API_KEY}"},
).raise_for_status()
The sample intentionally attempts this action a second time in live mode and records the rejection. That demonstrates an important property of the API: warm-up is a guarded lifecycle, not a button that can be restarted repeatedly by a client.
4. Poll the actual readiness fields
Provisioning and DNS confirmation are asynchronous. The sample polls the dedicated IP until both of the conditions used by the current implementation are true:
status == "warming_up"provisioning.dns_status == "dns_confirmed"
The nested DNS field matters. Checking a similarly named top-level value would let the application move forward on the wrong state.
The loop is bounded by WARMUP_TIMEOUT_SECONDS and sleeps for WARMUP_POLL_INTERVAL_SECONDS between requests. If the deadline expires, the Flask endpoint returns 504 instead of polling forever.
deadline = time.time() + WARMUP_TIMEOUT_SECONDS
while time.time() < deadline:
response = requests.get(
f"https://api.telnyx.com/v2/email/dedicated_ips/{dedicated_ip['id']}",
headers={"Authorization": f"Bearer {TELNYX_API_KEY}"},
)
response.raise_for_status()
current = response.json()["data"]
dns_status = current.get("provisioning", {}).get("dns_status")
if current.get("status") == "warming_up" and dns_status == "dns_confirmed":
break
time.sleep(WARMUP_POLL_INTERVAL_SECONDS)
5. Resolve the default pool
The IP pool controls which infrastructure carries outbound email. An account can already have a default pool, so the sample first calls GET /email/ip_pools and reuses it when present. Otherwise, it generates a unique name and creates one:
pool_response = requests.post(
"https://api.telnyx.com/v2/email/ip_pools",
headers={
"Authorization": f"Bearer {TELNYX_API_KEY}",
"Content-Type": "application/json",
},
json={
"name": f"dip-pool-{uuid.uuid4().hex[:8]}",
"is_default": True,
},
)
pool_response.raise_for_status()
pool = pool_response.json()["data"]
It then reads the pool to see whether the IP is already a member. Only a missing membership triggers the write:
requests.post(
f"https://api.telnyx.com/v2/email/ip_pools/{pool['id']}/ips",
headers={
"Authorization": f"Bearer {TELNYX_API_KEY}",
"Content-Type": "application/json",
},
json={"dedicated_ip_id": dedicated_ip["id"]},
).raise_for_status()
Reusing both the pool and an existing membership keeps a resumed run from failing on state it created earlier. Making the pool the default means the application does not have to select it on every individual send.
6. Verify API acceptance with a test email
The final step uses the Telnyx Python SDK:
import telnyx
client = telnyx.Client(api_key=TELNYX_API_KEY)
message = client.email_messages.create(
from_=EMAIL_FROM,
to=[EMAIL_TO],
subject="Dedicated IP routing verification",
text_body="This message verifies routing through the dedicated IP pool.",
)
payload = message.model_dump(mode="json", exclude_none=True)
data = payload["data"] if isinstance(payload.get("data"), dict) else payload
assert data["status"] == "queued"
assert data["id"]
status: "queued" and a message ID confirm that the Email API accepted the request after the routing configuration was created. They do not prove inbox delivery. Delivery, bounce, and engagement events should be tracked separately through email events or webhooks.
Run the sample without purchasing anything
Clone the examples repository and install the dependencies:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/provision-and-route-email-dedicated-ips
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
The included environment file uses placeholders:
TELNYX_API_KEY=KEY...
TELNYX_EMAIL_FROM=no******@your-verified-domain.example
TELNYX_EMAIL_TO=y**@your-mailbox.example
DEMO_MODE=true
REUSE_DEDICATED_IP_ID=
Run the one-shot workflow:
python app.py run
Or start Flask and call the endpoint:
python app.py
curl -X POST http://localhost:5000/run
With DEMO_MODE=true, the response contains simulated IP, pool, warm-up, and message objects. It makes no purchase and sends no email.
Before switching to live mode
Set DEMO_MODE=false only when you are ready to:
- purchase a billable dedicated IP;
- use a sender on a verified Telnyx email domain;
- send a real message to a mailbox you control;
- and monitor and later release resources you no longer need.
If a warm-up poll times out, keep the IP ID and rerun with REUSE_DEDICATED_IP_ID=<ip-id>. A bare rerun without that value starts a fresh purchase.
Dedicated IP availability and account eligibility can vary. Check the current Email API pricing and dedicated IP documentation before running the live workflow.
Common failure modes
400on the purchase request: confirm that anIdempotency-Keyheader is present.403when purchasing: the account may not be eligible for dedicated IPs yet.429with an inventory response: the selected region may not currently have an IP available. Do not retry in a tight loop.422on the second warm-up request: expected in the sample; it demonstrates that an active warm-up cannot be started again.504from/run: the polling deadline expired before the required status and DNS state appeared.- Existing default pool error in a manual implementation: list and reuse the current default pool. The sample already does this automatically.
Where to take it next
This sample deliberately keeps the orchestration in one readable workflow. A production service could persist resource IDs, resume polling after a restart, add an audit trail, scope pools by sending domain, and consume delivery events after API acceptance.
The important pattern is straightforward: treat sender reputation as managed infrastructure. Provision it idempotently, respect its lifecycle, wait for authoritative readiness state, configure routing explicitly, and distinguish a queued request from a delivered message.
Explore the full source: Provision and route email through dedicated IPs.
Comments
No comments yet. Start the discussion.