Send a CSV recipient list with Python and IllyVoIP

Validate a CSV, send reviewed SMS rows with a local ledger, and preserve accepted and uncertain submissions when restarting a batch.

Use a small Python sender to turn an approved CSV into individual IllyVoIP SMS requests. The important part is keeping a durable record for every message: restarting a batch must not silently resend recipients already processed.

This guide uses Python 3 and its standard library on one controlled machine. It is a bounded batch example, not a distributed campaign service. You need API access, an allowed sender, sufficient balance and recipients who expect the messages. Keep the CSV and ledger private; they contain phone numbers and message content.

Restart without guessing. Explanatory diagram showing the workflow, not an interface or completed transaction.
Restart without guessing. Explanatory diagram showing the workflow, not an interface or completed transaction. Open the image for a larger view.

1. Prepare the CSV and stable message IDs

Create columns named message_id,to,message,approved. Use a UTF-8 CSV exported with normal CSV quoting, not a file assembled by joining strings. Store international numbers as text. Put YES in approved only after reviewing that row.

The CSV message_id is your own unique logical message reference, such as an order event plus notification version. It is not IllyVoIP’s returned queued message ID. Never reuse it for another recipient or different text. The script rejects duplicate IDs and duplicate recipients within the approved batch rather than guessing which row you intended.

2. Save the sender script

Save the code below as send_csv.py. It validates the whole approved list before sending anything. In send mode, it stores the exact payload in a SQLite ledger before making each request. A previously claimed ID is skipped, including one whose outcome remains uncertain.

import argparse, csv, hashlib, json, os, re, sqlite3, time
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import HTTPError

parser = argparse.ArgumentParser()
parser.add_argument('csv_file')
parser.add_argument('--send', action='store_true')
parser.add_argument('--delay', type=float, default=2.0)
args = parser.parse_args()
if args.delay < 1:
    raise SystemExit('Use at least one second; follow your account limit')
rows, ids, numbers = [], set(), set()
with open(args.csv_file, newline='', encoding='utf-8-sig') as source:
    reader = csv.DictReader(source)
    if not {'message_id', 'to', 'message', 'approved'} <= set(reader.fieldnames or []):
        raise SystemExit('Required columns are missing')
    for line, row in enumerate(reader, 2):
        if row['approved'] != 'YES':
            continue
        ident, to, message = row['message_id'].strip(), row['to'].strip(), row['message']
        if not ident or ident in ids or to in numbers:
            raise SystemExit(f'Duplicate/empty ID or recipient at line {line}')
        if not re.fullmatch(r'\+[1-9]\d{6,14}', to) or not message.strip():
            raise SystemExit(f'Invalid number shape or empty message at line {line}')
        ids.add(ident); numbers.add(to)
        rows.append((ident, to, message))
print(f'{len(rows)} approved unique rows passed local preflight; no price quote implied')
if not args.send:
    raise SystemExit('Dry run only. Review content, sender, costs and permissions first.')
sender, api_key = os.environ['SMS_FROM'], os.environ['ILLYVOIP_API_KEY']
os.umask(0o077)
db = sqlite3.connect('sms-ledger.sqlite', timeout=30)
db.execute('CREATE TABLE IF NOT EXISTS sends '
           '(id TEXT PRIMARY KEY, payload TEXT NOT NULL, state TEXT NOT NULL, result TEXT)')
for ident, to, message in rows:
    payload = json.dumps({'from': sender, 'to': [to], 'message': message},
                         ensure_ascii=False, sort_keys=True)
    db.execute('BEGIN IMMEDIATE')
    old = db.execute('SELECT payload FROM sends WHERE id=?', (ident,)).fetchone()
    if old:
        db.rollback()
        if old[0] != payload:
            raise SystemExit('Existing ID has different content; stop and reconcile')
        print('Skipping previously claimed ID:', ident)
        continue
    db.execute('INSERT INTO sends VALUES (?, ?, ?, ?)',
               (ident, payload, 'uncertain', 'Not yet reconciled'))
    db.commit()  # A crash after this point must not cause an automatic resend.
    key = 'csv-' + hashlib.sha256(ident.encode()).hexdigest()
    request = Request('https://api.illyvoip.com/api/v1/sms/messages',
        data=payload.encode(), method='POST',
        headers={'X-Api-Key': api_key, 'Idempotency-Key': key,
                 'Content-Type': 'application/json'})
    state = 'uncertain'
    try:
        try:
            with urlopen(request, timeout=45) as response:
                http, raw = response.status, response.read().decode()
        except HTTPError as error:
            http, raw = error.code, error.read().decode()
        body = json.loads(raw)
        accepted = (200 <= http < 300 and body.get('status') == 'success'
                    and body.get('queued_count') == 1
                    and len(body.get('queued_message_ids', [])) == 1)
        state = 'submitted' if accepted else 'review'
        result = json.dumps({'http': http, 'body': body}, ensure_ascii=False)
    except Exception:
        result = 'No usable response; reconcile before any resend'
    db.execute('UPDATE sends SET state=?, result=? WHERE id=?', (state, result, ident))
    db.commit()
    print(ident, state)
    if state != 'submitted':
        raise SystemExit('Batch stopped for review; previous IDs will be skipped')
    time.sleep(args.delay)
db.close()

3. Run the dry run and review costs

python3 send_csv.py recipients.csv

This makes no API call and does not calculate a price or prove deliverability. Review the actual sender, recipients, content, encoding and segment counts in your account. Check destination availability, balance and account rate limits. Longer or Unicode messages can use multiple segments, so row count is not a total-cost estimate.

Start with a small approved file. Keep each original CSV, its sender and the corresponding ledger together. The example’s two-second delay is conservative pacing, not a promise that it matches every account’s allowance. Increase it if your account requires a lower request rate.

4. Send only after deliberate approval

Set ILLYVOIP_API_KEY and SMS_FROM securely in the process environment. Run this command from the same private working directory each time:

python3 send_csv.py recipients.csv --send --delay 2

This command can incur real charges. Do not run separate copies using different ledgers. Keep the SQLite file on reliable local storage and back it up privately. Deleting it removes the sender’s durable history; do not rely on an API retry key as a permanent replacement for that history.

5. Resume and reconcile safely

A restart with the same CSV and ledger skips every ID already claimed. If the batch stopped on a rejection or uncertain result, investigate that row before continuing. The saved result holds the immediate response, including accepted IDs or rejection details; an exception leaves the uncertainty marker intact.

The deliberate tradeoff is that a crash just before the network request can leave a row unsent but marked uncertain. That is safer than guessing and sending twice. Reconcile using the Portal, saved response and request key. The script never automatically resets the row. If an identical controlled retry is appropriate, derive the same key and use the saved payload; a confirmed corrected/new message requires a deliberate new logical ID.

6. Follow delivery

For each submitted row, read its saved queued_message_ids and query GET /api/v1/sms/messages/status with message_id, or process verified delivery webhooks. Keep submission and delivery states separate. A submitted row is not proof of handset delivery or a read receipt.

Frequently asked questions

Why stop the whole batch on one unsuccessful row?

Sender, account and balance errors can affect every following row. Stopping makes the cause visible instead of generating a long series of failed requests.

Can I share the ledger across many servers?

This example is for one controlled machine and local SQLite storage. A multi-worker service needs a shared transactional queue, leases, immutable message identity and reconciliation designed for its failure cases.

Related guides and official references

Reviewed on 8 October 2026 against the current IllyVoIP implementation and available official documentation. Examples use placeholders and are not evidence of a completed live transaction. Check your account’s current availability and prices before executing paid requests.