The Real Cost of a Missed Expiration

A contractor's license expires on a Tuesday. Your platform dispatches a job on Wednesday. The homeowner has a claim. Your insurer looks at the dispatch timestamp, looks at the CSLB record, and denies coverage because you sent an unlicensed contractor.

That scenario is not hypothetical. It describes the exact sequence of events that produces platform liability exposure in every state that requires contractor licensing. The exposure is three-layered:

Platform liability. In California, dispatching an unlicensed contractor can constitute aiding and abetting an unlicensed practice under Business and Professions Code 7028.1. Several states have extended similar liability theories to marketplace platforms that facilitate the work relationship. Even where direct statutory liability doesn't apply, civil claims from homeowners who suffered defective work or property damage routinely name the platform as a deep-pocket defendant.

Insurance gap. Most contractor general liability policies include a licensing warranty clause - the policy is void if the insured was not properly licensed at the time of loss. When a contractor's license expires and they continue working, their GL coverage lapses with it. Your platform's umbrella doesn't fill that gap automatically. You're left with an uninsured contractor, an injured homeowner, and a policy exclusion.

Regulatory exposure. State contractor boards increasingly audit marketplace platforms. The Florida Department of Business and Professional Regulation has sent investigative subpoenas to home service platforms requesting lists of dispatched contractors and the dates they were verified. If your verification records don't show continuous monitoring - not just onboarding checks - regulators treat that as evidence of willful blindness.

The monitoring problem is operationally distinct from the onboarding verification problem. Verifying a license once at signup is table stakes. The harder engineering challenge is detecting the moment a license crosses from active to expiring to expired, and acting on that transition before a job is dispatched.

Three-Tier Alert Architecture

A well-designed expiration alert system uses three discrete thresholds tied to different response requirements. Each tier has a different audience, a different urgency level, and a different operational action.

Tier Threshold Primary Audience Required Action
Warning 90 days before expiration Contractor + account manager Notify contractor to begin renewal; log in compliance record
Alert 30 days before expiration Contractor + compliance team Escalate to human review; begin job-dispatch restriction countdown
Suspension Day of expiration (or status change to INACTIVE/EXPIRED) Operations + contractor Block new job dispatch immediately; notify contractor of suspension

The 90-day warning gives contractors adequate lead time. License renewal processes vary enormously by state - California's CSLB requires 90 days advance filing for contractors who need to renew via examination, while Florida's DBPR accepts online renewals up to 60 days before expiration. Notifying at 90 days ensures you're inside the window for every state your platform operates in.

The 30-day alert triggers human review because automated systems miss things - address changes, name-change renewals, licenses that were administratively suspended before expiration. At 30 days, a compliance team member should manually pull the license record from ContractorVerify and compare it against what the contractor has told you.

Day-of suspension is not a soft block. It should prevent the job assignment engine from routing to that contractor until the license record shows a future expiration date. The suspension should be automatic and synchronous - not a background job that runs hourly.

Polling vs. Webhooks - When to Use Each

ContractorVerify API supports both a pull model (you query the API on a schedule) and a push model (the API calls your endpoint when a license status changes). They solve different problems.

Polling with a nightly cron is the right approach for expiration-date-based alerts. Expiration dates are known in advance - you don't need the API to tell you when a license is 30 days from expiration. You calculate that yourself from the expiration_date field you already have stored. The nightly cron compares today's date against stored expiration dates and fires alerts when a contractor crosses a threshold for the first time.

Webhooks are the right approach for status changes - specifically when a license transitions from ACTIVE to INACTIVE, SUSPENDED, or REVOKED outside of the normal expiration cycle. A contractor can have their license suspended by a board for disciplinary reasons at any point during their license term. That event won't show up in your expiration-date logic because the expiration date hasn't changed - the status field changed. Webhooks catch it in near-real-time.

Run both: nightly polling for expiration countdown, webhooks for out-of-cycle status changes. The two signals are complementary, not redundant.

Polling frequency note: Most state boards update their records overnight in batch jobs. Polling more than once per day gives you duplicate alerts without fresher data. Set your cron for 06:00 local time to catch board updates that typically land between midnight and 5 AM.

Database Schema for License Records and Alert State

Your database needs to track two things separately: the license record itself (sourced from the API), and the alert state (your internal tracking of what notifications have been sent). Conflating these creates problems when you need to reset alert state after a renewal without losing the audit trail.

-- License records table: mirrors ContractorVerify API response
CREATE TABLE contractor_licenses (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    contractor_id       UUID NOT NULL REFERENCES contractors(id),
    license_number      VARCHAR(50) NOT NULL,
    state               CHAR(2) NOT NULL,
    license_type        VARCHAR(100),        -- e.g. 'B - General Building Contractor'
    status              VARCHAR(30) NOT NULL, -- ACTIVE | INACTIVE | SUSPENDED | REVOKED | EXPIRED
    expiration_date     DATE,
    issue_date          DATE,
    holder_name         VARCHAR(255),
    dba_name            VARCHAR(255),
    last_verified_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    raw_response        JSONB,               -- full API response for audit
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Alert state table: tracks which tier alerts have fired
CREATE TABLE license_alert_state (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    license_id          UUID NOT NULL REFERENCES contractor_licenses(id),
    alert_tier          VARCHAR(20) NOT NULL, -- WARNING_90 | ALERT_30 | SUSPENDED
    fired_at            TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    cleared_at          TIMESTAMPTZ,          -- set when renewal detected
    notification_channels JSONB,              -- which channels were notified
    UNIQUE(license_id, alert_tier)
);

CREATE INDEX idx_licenses_expiration ON contractor_licenses(expiration_date)
    WHERE status = 'ACTIVE';
CREATE INDEX idx_licenses_contractor ON contractor_licenses(contractor_id);
CREATE INDEX idx_alert_state_license ON license_alert_state(license_id);

The UNIQUE(license_id, alert_tier) constraint is intentional - it prevents duplicate alerts from firing if the cron runs twice, and it gives you a clean record to delete when a renewal clears an alert. The notification_channels JSONB field stores which channels were notified for each alert, so you can audit delivery without a separate table.

Building the Nightly Cron Job

The cron job does three things: identify licenses crossing alert thresholds, fetch fresh data from ContractorVerify for those licenses, and fire alerts for any threshold not yet recorded in license_alert_state.

import os
import psycopg2
import requests
from datetime import date, timedelta
import json
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("license_expiration_monitor")

API_BASE = "https://api.contractorverify.io/v1"
API_KEY = os.environ["CONTRACTORVERIFY_API_KEY"]
DB_URL = os.environ["DATABASE_URL"]

THRESHOLDS = {
    "WARNING_90": 90,
    "ALERT_30":   30,
    "SUSPENDED":  0,   # expiration_date <= today
}

def get_licenses_needing_check(conn):
    """
    Returns licenses where today falls within any alert window
    and the corresponding alert has not yet fired.
    """
    today = date.today()
    with conn.cursor() as cur:
        cur.execute("""
            SELECT cl.id, cl.license_number, cl.state,
                   cl.expiration_date, cl.contractor_id,
                   ARRAY_AGG(las.alert_tier) FILTER (WHERE las.cleared_at IS NULL)
                       AS active_alerts
            FROM contractor_licenses cl
            LEFT JOIN license_alert_state las ON las.license_id = cl.id
                AND las.cleared_at IS NULL
            WHERE cl.status = 'ACTIVE'
              AND cl.expiration_date IS NOT NULL
              AND cl.expiration_date <= %(day90)s
            GROUP BY cl.id, cl.license_number, cl.state,
                     cl.expiration_date, cl.contractor_id
        """, {"day90": today + timedelta(days=90)})
        return cur.fetchall()

def refresh_license_from_api(license_number, state):
    """Fetch current license data from ContractorVerify."""
    resp = requests.get(
        f"{API_BASE}/licenses/lookup",
        params={"license_number": license_number, "state": state},
        headers={"X-Api-Key": API_KEY},
        timeout=10
    )
    resp.raise_for_status()
    return resp.json()

def determine_new_alerts(expiration_date, active_alerts):
    """Return list of alert tiers that should fire but haven't."""
    today = date.today()
    days_until = (expiration_date - today).days
    new_alerts = []

    active_set = set(active_alerts or [])

    if days_until <= 0 and "SUSPENDED" not in active_set:
        new_alerts.append("SUSPENDED")
    elif days_until <= 30 and "ALERT_30" not in active_set:
        new_alerts.append("ALERT_30")
    elif days_until <= 90 and "WARNING_90" not in active_set:
        new_alerts.append("WARNING_90")

    return new_alerts

def record_alert(conn, license_id, tier, channels_notified):
    with conn.cursor() as cur:
        cur.execute("""
            INSERT INTO license_alert_state
                (license_id, alert_tier, notification_channels)
            VALUES (%s, %s, %s)
            ON CONFLICT (license_id, alert_tier) DO NOTHING
        """, (license_id, tier, json.dumps(channels_notified)))
    conn.commit()

def run_monitor():
    conn = psycopg2.connect(DB_URL)
    licenses = get_licenses_needing_check(conn)
    logger.info(f"Found {len(licenses)} licenses in alert window")

    for row in licenses:
        lic_id, lic_num, state, exp_date, contractor_id, active_alerts = row

        # Refresh from API to confirm expiration_date hasn't changed (renewal check)
        try:
            api_data = refresh_license_from_api(lic_num, state)
            current_status = api_data.get("status")
            current_exp = api_data.get("expiration_date")  # ISO date string

            if current_exp:
                current_exp = date.fromisoformat(current_exp)

            # If status changed to non-ACTIVE, trigger suspension directly
            if current_status not in ("ACTIVE",):
                fire_alert(contractor_id, lic_id, "SUSPENDED", api_data)
                record_alert(conn, lic_id, "SUSPENDED", {"triggered_by": "status_change"})
                continue

            # License was renewed - clear pending alerts
            if current_exp and current_exp > exp_date:
                logger.info(f"License {lic_num} renewed: new exp {current_exp}")
                clear_alerts_for_license(conn, lic_id)
                continue

            exp_to_use = current_exp or exp_date

        except Exception as e:
            logger.error(f"API refresh failed for {lic_num}/{state}: {e}")
            exp_to_use = exp_date  # fall back to stored date

        new_alerts = determine_new_alerts(exp_to_use, active_alerts)
        for tier in new_alerts:
            channels = fire_alert(contractor_id, lic_id, tier, {"expiration_date": str(exp_to_use)})
            record_alert(conn, lic_id, tier, channels)

    conn.close()

if __name__ == "__main__":
    run_monitor()

Building the Alert Router

The fire_alert function referenced above routes to multiple channels based on alert tier. The routing logic should be configurable - not every platform uses Twilio, and not every contractor has an SMS number on file.

import smtplib
from email.mime.text import MIMEText
from twilio.rest import Client as TwilioClient

def fire_alert(contractor_id, license_id, tier, context):
    """Route alert to appropriate channels based on tier. Returns dict of channels used."""
    contractor = get_contractor(contractor_id)
    channels_used = {}

    messages = {
        "WARNING_90": {
            "subject": "Action Required: Your contractor license expires in 90 days",
            "body": f"Your {context.get('state', '')} contractor license expires on "
                    f"{context.get('expiration_date')}. Please begin your renewal process "
                    f"now to avoid job dispatch suspension.",
            "slack_emoji": ":warning:",
            "urgency": "low"
        },
        "ALERT_30": {
            "subject": "URGENT: Contractor license expires in 30 days - account at risk",
            "body": f"Your contractor license expires in 30 days "
                    f"({context.get('expiration_date')}). Your account will be suspended "
                    f"from receiving new jobs if not renewed.",
            "slack_emoji": ":rotating_light:",
            "urgency": "high"
        },
        "SUSPENDED": {
            "subject": "Account suspended: Contractor license expired",
            "body": f"Your contractor license has expired or become inactive. "
                    f"Your account has been suspended from new job dispatch. "
                    f"Contact support once renewed.",
            "slack_emoji": ":no_entry:",
            "urgency": "critical"
        }
    }

    msg = messages[tier]

    # Email: always send
    if contractor.get("email"):
        send_email(contractor["email"], msg["subject"], msg["body"])
        channels_used["email"] = True

    # SMS via Twilio: send at ALERT_30 and SUSPENDED only
    if tier in ("ALERT_30", "SUSPENDED") and contractor.get("phone"):
        send_sms(contractor["phone"], msg["body"][:160])
        channels_used["sms"] = True

    # Slack: always send to ops channel
    send_slack(
        channel="#contractor-compliance",
        text=f"{msg['slack_emoji']} *{tier}* | Contractor: {contractor['name']} | "
             f"License expires: {context.get('expiration_date')} | "
             f"ID: {contractor_id}"
    )
    channels_used["slack"] = True

    # In-app notification
    create_in_app_notification(contractor_id, tier, msg["body"])
    channels_used["in_app"] = True

    return channels_used

Handling Renewals - Detecting a Renewed License and Clearing Alerts

The renewal detection logic in the cron job above handles the common case: the API returns a new expiration_date that is later than the one you have stored. But there are subtleties worth explicitly handling.

Some states issue a new license number on renewal rather than extending the existing one. California's CSLB does this for certain contractor classifications when there's been a break in licensure. If you're storing alerts keyed to the old license number, the API will return the old record as expired and you won't automatically discover the new record unless you query by entity name or Federal Tax ID. ContractorVerify's entity lookup endpoint handles this by returning all licenses associated with a legal entity - use it in your renewal workflow.

The clear_alerts_for_license function should set cleared_at on all alert records for that license, update the stored expiration_date, and trigger a "license renewed" notification to the contractor confirming their account is back in good standing.

def clear_alerts_for_license(conn, license_id):
    with conn.cursor() as cur:
        cur.execute("""
            UPDATE license_alert_state
            SET cleared_at = NOW()
            WHERE license_id = %s AND cleared_at IS NULL
        """, (license_id,))
    conn.commit()
    logger.info(f"Cleared all pending alerts for license {license_id}")

Edge Cases That Will Break Naive Systems

Licenses with No Expiration Date

Several states issue licenses with no fixed expiration date, relying instead on annual renewal fees or perpetual status. Texas's TDLR issues licenses for certain trade categories with no expiration field - the record shows expiration_date: null in the API response. Your schema must allow nullable expiration_date and your cron must skip those records for the expiration countdown logic.

Don't mistake "no expiration" for "no monitoring." These licenses can still be suspended, revoked, or allowed to lapse administratively when a contractor fails to pay renewal fees. You still need webhook-based status monitoring for them - you just don't run expiration countdown math.

Apprentice Permits

Electrical and plumbing apprentice permits in states like Oregon (CCB apprentice registration) and Washington (L&I apprentice permit) expire on short cycles - often annually - and have different renewal workflows than journeyman or contractor licenses. A journeyman plumber and their apprentice on the same job may both appear in your contractor records, but the apprentice's permit expiration should trigger a different workflow: notify the supervising journeyman, not the apprentice, and check whether the apprentice can continue working under supervision during the renewal gap.

Perpetual Licenses with Continuing Education Requirements

Some states issue perpetual licenses but attach mandatory continuing education (CE) requirements for renewal. Nevada's NSCB and Arizona's ROC both have CE requirements. The license itself won't show an expiration date, but the CE deadline is a compliance trigger. ContractorVerify exposes CE compliance fields in the additional_requirements object on the license record - monitor those fields separately from the core expiration countdown.

The Full Escalation Matrix

Threshold Contractor Action Platform Action Channels
90 days Begin renewal process with state board Log alert; no dispatch restriction Email, in-app, Slack (ops)
30 days Submit renewal application if not done Human compliance review; tag account Email, SMS, in-app, Slack (ops + manager)
7 days Confirm renewal submission to platform Require documentation upload; set dispatch flag Email, SMS daily, in-app banner
Day of expiration Provide renewed license number Auto-suspend new job dispatch; notify homeowners with pending jobs Email, SMS, in-app, Slack (critical), ops ticket
Status: SUSPENDED/REVOKED Resolve board action Immediate suspension; legal review if active jobs All channels; legal team alert

The 7-day tier is intentionally absent from the automated cron - it should trigger manual outreach from a compliance team member, not another automated email. By day 7, a contractor who hasn't renewed is either in renewal processing (and can provide documentation) or is unresponsive (and needs a human to assess whether to pre-emptively suspend their account).

Implementation note: Wire your dispatch engine to check license_alert_state synchronously before assigning any job. A query against the alert state table on each dispatch call adds less than 1ms of latency and gives you a hard gate that cannot be bypassed by cron timing gaps.

For deeper coverage of the monitoring infrastructure layer beneath these alerts, see How to Automate Contractor License Expiration Monitoring. For analysis of why real-time API data matters for day-of decisions versus the cached data most verification tools provide, see Contractor License Data Freshness: Real-Time vs. Cached.

> Automate Your License Verification_

ContractorVerify API returns expiration_date, status, and additional_requirements fields for every license lookup - everything your alert system needs to fire the right notification at the right tier, automatically.

Join the Waitlist