Verifying a contractor at onboarding is necessary. It is not sufficient. A contractor who passes their initial license check with a clean Active status can become unlicensed mid-project if their license expires, is suspended, or their qualifier leaves the business. When that happens - and it does happen - your platform is potentially liable for work performed by an unlicensed contractor, and your insurance carrier will want to know why your system did not catch it.

The answer is expiration monitoring: a system that watches your contractor roster continuously, re-checks licenses as they approach their expiration dates, and fires alerts before anything falls through the cracks. This guide covers everything you need to build it - from database schema to cron jobs to alert channels to the edge cases that bite platforms who build the naive version.

Why Expiration Monitoring Matters More Than You Think

Here is the liability timeline that motivates this whole architecture. A contractor gets their license renewed in March 2025, valid through March 2027. Your platform verified them at onboarding in June 2025 - everything is fine. You do not re-verify them. By January 2026 they have quietly let their surety bond lapse (the bond company sent notices they ignored), and the state board has put their license on "Inactive - Bond Lapsed" status. They are still working jobs on your platform. A homeowner they do substandard work for files a complaint and your platform is pulled into the dispute. The first question the homeowner's attorney asks: when did you last verify this contractor was licensed?

The answer "at onboarding 7 months ago" is not a good answer. The answer "we re-verified them 3 days ago and they were Active at that time" is a defensible answer - it shows you were operating a reasonable compliance program, and the status change happened within a window you could not have predicted.

Expiration monitoring is also the right tool for catching license renewals that did not happen. Many contractors let licenses lapse by accident - they miss the renewal notice, they are busy on a job, they intend to renew next week. On many state boards, the grace period after expiration is zero - the license is simply void the day after expiration. A platform that still has this contractor listed as Active and available for new work has a data integrity problem.

The Naive Approach: Manual Calendar Checks

The first iteration many platforms build is a manual process: someone checks the expiration dates in a spreadsheet quarterly and runs lookups on any that are coming up soon. This works up to about 30-40 contractors before it becomes unreliable. The problems are predictable:

The manual system is a liability even when it is working. Once you have more than a few dozen contractors, automate it.

The Right Approach: Store Expiration, Run Crons, Alert on Change

The automated expiration monitoring architecture has three components working together:

  1. A database schema that stores license state and expiration dates alongside your contractor records
  2. A scheduled job (cron) that queries for contractors approaching expiration and re-verifies them
  3. An alerting layer that fires notifications when verification produces a status change or expiration is imminent

Each component is simple individually. The value comes from combining them reliably and handling the edge cases correctly.

Database Schema for License Records

If you are adding license monitoring to an existing contractors table, add these columns:

-- Add to your existing contractors table:
ALTER TABLE contractors ADD COLUMN license_number VARCHAR(50);
ALTER TABLE contractors ADD COLUMN license_state CHAR(2);
ALTER TABLE contractors ADD COLUMN license_status VARCHAR(20);
-- 'Active', 'Inactive', 'Suspended', 'Cancelled', 'Unknown'
ALTER TABLE contractors ADD COLUMN license_expiration_date DATE;
ALTER TABLE contractors ADD COLUMN license_verified_at TIMESTAMPTZ;
ALTER TABLE contractors ADD COLUMN license_classifications JSONB;
-- e.g. ["C-10", "C-7"] - store as JSON array
ALTER TABLE contractors ADD COLUMN license_has_discipline BOOLEAN DEFAULT FALSE;
ALTER TABLE contractors ADD COLUMN license_error_code VARCHAR(50);
-- populated when last verification attempt failed
ALTER TABLE contractors ADD COLUMN license_next_verify_at DATE;
-- calculated field: when should we re-check this contractor

CREATE INDEX idx_contractors_next_verify ON contractors (license_next_verify_at)
  WHERE license_status IN ('Active', 'Inactive', 'Suspended');

If you want a full audit trail (recommended), use a separate table rather than overwriting in place:

CREATE TABLE contractor_license_history (
  id BIGSERIAL PRIMARY KEY,
  contractor_id BIGINT NOT NULL REFERENCES contractors(id),
  checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  license_number VARCHAR(50),
  license_state CHAR(2),
  license_status VARCHAR(20),
  expiration_date DATE,
  classifications JSONB,
  has_disciplinary_actions BOOLEAN DEFAULT FALSE,
  raw_response JSONB,  -- store the full API response
  error_code VARCHAR(50),
  error_message TEXT
);

CREATE INDEX idx_clh_contractor_id ON contractor_license_history (contractor_id);
CREATE INDEX idx_clh_checked_at ON contractor_license_history (checked_at DESC);

The history table approach gives you the audit trail your insurance carrier and enterprise clients want. You can always answer "what was this contractor's license status on date X?" by querying the history table. The current state in the main contractors table is just the latest row denormalized for query convenience.

Calculating license_next_verify_at

The license_next_verify_at field drives your cron query. After every successful verification, recalculate it:

function calculateNextVerifyDate(expirationDate, currentStatus) {
  const now = new Date();
  const expiry = new Date(expirationDate);
  const daysUntilExpiry = Math.floor(
    (expiry - now) / (1000 * 60 * 60 * 24)
  );

  // Suspended or Inactive: re-check weekly regardless of expiration
  if (currentStatus === 'Suspended' || currentStatus === 'Inactive') {
    return addDays(now, 7);
  }

  // Active but expiring within 14 days: check every 2 days
  if (daysUntilExpiry <= 14) {
    return addDays(now, 2);
  }

  // Active, expiring within 30 days: check weekly
  if (daysUntilExpiry <= 30) {
    return addDays(now, 7);
  }

  // Active, expiring within 90 days: check every 2 weeks
  if (daysUntilExpiry <= 90) {
    return addDays(now, 14);
  }

  // Active, expiry more than 90 days out: monthly check
  return addDays(now, 30);
}

function addDays(date, days) {
  const d = new Date(date);
  d.setDate(d.getDate() + days);
  return d.toISOString().split('T')[0];  // YYYY-MM-DD
}

The Nightly Cron Job

The cron runs nightly (or multiple times daily for very active platforms). It queries for contractors due for re-verification, calls the API, updates records, and triggers alerts on any status changes.

const { Pool } = require('pg');
const fetch = require('node-fetch');

const db = new Pool({ connectionString: process.env.DATABASE_URL });
const API_KEY = process.env.CONTRACTORVERIFY_KEY;
const BATCH_SIZE = 100;

async function runExpirationCheck() {
  console.log('[expiration-check] Starting nightly run');

  // Find contractors due for re-verification
  const { rows: due } = await db.query(`
    SELECT id, license_number, license_state, license_status
    FROM contractors
    WHERE license_next_verify_at <= CURRENT_DATE
      AND license_number IS NOT NULL
      AND license_state IS NOT NULL
    ORDER BY license_next_verify_at ASC
    LIMIT 5000
  `);

  if (due.length === 0) {
    console.log('[expiration-check] No contractors due for re-verification');
    return;
  }

  console.log(`[expiration-check] Verifying ${due.length} contractors`);

  // Process in batches of 100
  for (let i = 0; i < due.length; i += BATCH_SIZE) {
    const chunk = due.slice(i, i + BATCH_SIZE);
    await processBatch(chunk);
    if (i + BATCH_SIZE < due.length) {
      await sleep(6500);  // stay under rate limit
    }
  }

  console.log('[expiration-check] Run complete');
}

async function processBatch(contractors) {
  const requests = contractors.map(c => ({
    id: String(c.id),
    license_number: c.license_number,
    state: c.license_state
  }));

  const res = await fetch(
    'https://api.contractorverify.io/v1/verify/batch', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ requests })
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.message || `Batch API error (${res.status})`);
  }

  const data = await res.json();

  for (const result of data.results) {
    const contractorId = parseInt(result.id);
    const original = contractors.find(c => c.id === contractorId);

    if (result.status === 'error') {
      await db.query(`
        UPDATE contractors
        SET license_error_code = $1,
            license_verified_at = NOW(),
            license_next_verify_at = CURRENT_DATE + INTERVAL '7 days'
        WHERE id = $2
      `, [result.error_code, contractorId]);
      continue;
    }

    const previousStatus = original.license_status;
    const newStatus = result.license_status;
    const nextVerify = calculateNextVerifyDate(
      result.expiration_date, newStatus
    );

    await db.query(`
      UPDATE contractors
      SET license_status = $1,
          license_expiration_date = $2,
          license_classifications = $3,
          license_has_discipline = $4,
          license_error_code = NULL,
          license_verified_at = NOW(),
          license_next_verify_at = $5
      WHERE id = $6
    `, [
      newStatus,
      result.expiration_date,
      JSON.stringify(result.classifications || []),
      (result.disciplinary_actions || []).length > 0,
      nextVerify,
      contractorId
    ]);

    // Insert history record
    await db.query(`
      INSERT INTO contractor_license_history
        (contractor_id, license_number, license_state, license_status,
         expiration_date, classifications, has_disciplinary_actions, raw_response)
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
    `, [
      contractorId, result.license_number, result.state,
      newStatus, result.expiration_date,
      JSON.stringify(result.classifications || []),
      (result.disciplinary_actions || []).length > 0,
      JSON.stringify(result)
    ]);

    // Fire alerts on status change
    if (previousStatus !== newStatus) {
      await fireStatusChangeAlert(contractorId, previousStatus, newStatus);
    }

    // Fire expiration warnings
    const daysUntilExpiry = getDaysUntil(result.expiration_date);
    for (const threshold of [90, 30, 7]) {
      if (daysUntilExpiry <= threshold && daysUntilExpiry > threshold - 7) {
        await fireExpirationWarning(contractorId, result.expiration_date, threshold);
        break;
      }
    }
  }
}

function getDaysUntil(dateStr) {
  const ms = new Date(dateStr) - new Date();
  return Math.floor(ms / (1000 * 60 * 60 * 24));
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

runExpirationCheck().catch(err => {
  console.error('[expiration-check] Fatal error:', err);
  process.exit(1);
});

Alert Channels

Email Alerts

Email is the right channel for contractor-facing notifications (the contractor whose license is expiring needs to know) and for compliance team digests (a daily summary of all changes in the past 24 hours). Use your transactional email provider (SendGrid, Postmark, etc.) and template the messages. For expiration warnings going to contractors, the message should include:

Slack Webhook Alerts

For internal compliance team notifications, Slack is faster and more visible than email. Create a dedicated #license-alerts channel and post there for any status change or imminent expiration:

async function sendSlackAlert(message) {
  const webhookUrl = process.env.SLACK_COMPLIANCE_WEBHOOK;
  if (!webhookUrl) return;

  const res = await fetch(webhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: message,
      unfurl_links: false
    })
  });

  if (!res.ok) {
    console.error('Slack alert failed:', res.status);
  }
}

// Usage in fireStatusChangeAlert:
async function fireStatusChangeAlert(contractorId, prev, next) {
  const msg = [
    `:warning: License status change detected`,
    `Contractor ID: ${contractorId}`,
    `Status: ${prev} -> ${next}`,
    `Verified at: ${new Date().toISOString()}`
  ].join('\n');

  await sendSlackAlert(msg);
}

What to Do When a License Expires

Your platform needs an explicit policy for the post-expiration state, and that policy needs to be enforced automatically - not left to manual review. A practical three-stage approach:

Stage 1: 30-Day Warning (license still Active)

No platform-side restriction yet. Send the contractor an email warning. Note the expiration date in their profile UI so they can see it. Log the warning in the compliance history.

Stage 2: Expiration Day (license status flips)

Pause new job matching for this contractor. They can complete currently-active jobs (because suspending mid-project creates more problems than it solves) but do not receive new leads or job offers. Notify the contractor immediately and explain the pathway to reinstatement.

Stage 3: 30 Days Post-Expiration (still not renewed)

Suspend the account fully. At this point the contractor has had 60+ days of notice and has chosen not to renew. Their profile should be hidden from job matching and their status should show as "License Expired" to any platform users who might try to contact them directly.

Grace period caveat: Some states (California being a notable exception) allow a grace period during which the contractor can still renew without penalty even after the expiration date. The license status on the state board may still show "Inactive" rather than "Cancelled" during this window. Treat Inactive status the same as Expired for platform purposes - you do not know whether the contractor will renew, and working with an unlicensed contractor carries the same risk either way.

Edge Case: License Renewed Before Your Check Fires

The most common edge case that breaks naive expiration monitoring: a contractor renews their license, but your system has not re-verified them yet. Their record still shows the old expiration date. They contact support saying their profile is restricted even though they renewed. You check the state board manually and find they did renew 3 days ago.

This is a data freshness problem, not a logic error. The fix is two-part:

First, give contractors a self-service "re-verify my license" button in their profile. This triggers an on-demand verification call (single record lookup, force_refresh=true) and updates their record immediately. Most platforms that build this reduce their license-related support tickets by 80%.

Second, when a license enters the 14-day warning window, increase your verification frequency to every 2 days (which the calculateNextVerifyDate function above already handles). This way the renewal is detected within 2 days rather than waiting for the next weekly check.

Edge Case: License Suspended Mid-Term

A suspension is more disruptive than an expiration because it is unexpected. Expirations have a known date - you can build the warning timeline around them. Suspensions can happen at any time: a complaint is filed, a citation is not paid, the contractor's qualifier relationship ends.

The way you catch mid-term suspensions is through regular re-verification sweeps. Monthly re-verification for Active contractors with distant expiration dates means a suspension could go undetected for up to 30 days. For high-value contractor relationships or high-liability work categories (electrical, roofing, structural), consider weekly re-verification regardless of expiration date - the extra API calls are cheap compared to the liability exposure.

When a suspension is detected, the action should be immediate and automatic: pause new job matching, flag the account for compliance review, and notify both the contractor and your compliance team. Do not wait for a human to notice the status change in a report. See our post on why manual license checks fail at scale for more on why the human-review bottleneck is the critical failure point in most compliance programs.

Building a Compliance Dashboard

Once the cron and alert system is running, you need a way to see portfolio-level license health at a glance. A simple compliance dashboard query gives you the numbers that matter:

-- Portfolio-level license health summary
SELECT
  license_status,
  COUNT(*) AS contractor_count,
  COUNT(*) FILTER (
    WHERE license_expiration_date <= CURRENT_DATE + INTERVAL '30 days'
      AND license_expiration_date > CURRENT_DATE
  ) AS expiring_30_days,
  COUNT(*) FILTER (
    WHERE license_expiration_date <= CURRENT_DATE
  ) AS already_expired,
  COUNT(*) FILTER (
    WHERE license_has_discipline = TRUE
  ) AS has_discipline
FROM contractors
WHERE license_number IS NOT NULL
GROUP BY license_status
ORDER BY contractor_count DESC;

Surface this on an internal admin dashboard that your compliance team can check daily. At minimum, the dashboard should show:

The last item - cron health monitoring - is easy to forget and critical not to. A compliance system that fails silently is worse than no compliance system because it creates a false sense of security. Monitor your cron jobs with a dead man's switch: if the job does not run within 25 hours, fire an alert to your engineering channel.

Connecting Expiration Monitoring to Onboarding

The expiration monitoring system is the long-running complement to the real-time onboarding check. When you verify a contractor during signup (as described in our guide on integrating license verification into onboarding), you should write the API response to both your contractors table and the license history table using the same schema shown above. That way from the first day a contractor is on your platform, they are already in the expiration monitoring loop - no separate enrollment step required.

The flow is: onboarding verification writes to DB with initial license_next_verify_at value calculated from the response, nightly cron picks it up from that point forward. One schema, one cron, continuous coverage from day one.

Summary

Contractor license expiration monitoring comes down to three things done consistently: store structured license data including expiration dates when you verify, run a scheduled job that re-checks based on proximity to expiration, and fire alerts immediately when anything changes. The edge cases - renewals that happen before your check, mid-term suspensions, grace period ambiguity - are all handled by increasing verification frequency as risk increases and giving contractors a self-service path to trigger re-verification after renewal.

The database schema, cron code, and alert patterns in this guide are production-ready starting points. The ContractorVerify API provides the data layer: consistent JSON responses for all 50 state boards, 24-hour cache with force-refresh for real-time needs, and structured fields that map directly to the schema columns above.

Build this once and your compliance coverage runs itself - no spreadsheets, no manual CSLB lookups, no support tickets from contractors wondering why they are restricted three weeks after they renewed.