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.
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).
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.