The Fundamental Problem: Asynchronous State Changes
License status on a state contractor board is not a static field. It changes in response to events that are outside your control and, in most cases, outside the contractor's control too. A license gets suspended when a complaint investigation concludes. It expires when the renewal window passes. It gets revoked following a disciplinary hearing. It gets renewed when the contractor submits their renewal application and the board processes it - which can take anywhere from same-day to several weeks depending on the state.
These events happen on the state board's schedule, not yours. California's CSLB processes complaint resolutions on business days during board hours. The Texas Department of Licensing and Regulation (TDLR) posts license renewals as they process them in their queue. Florida's DBPR handles disciplinary actions through an administrative hearing process that concludes at irregular intervals.
From your platform's perspective, the contractor's license status is a remote state that you need to track. You have two fundamental architectural choices for tracking remote state: poll for it on a schedule, or register to receive notifications when it changes. These are the polling and webhook patterns, and each has meaningful trade-offs that should drive which you implement first.
Polling Architecture: How It Works
Polling is the simpler mental model. At a defined interval, your system calls the ContractorVerify API for each contractor you're monitoring, compares the returned status against what you have stored locally, and processes any differences.
The typical implementation is a cron job that runs nightly. At 2am, the job pulls the list of active contractors from your database, batches them into groups of 50 (matching the API's batch endpoint limit), sends the verification requests, and writes the results back. Any contractor whose status field differs from the last known value gets flagged for review or triggers an automated action.
// Nightly polling cron job (Node.js / pg)
const cron = require('node-cron');
const { Pool } = require('pg');
const axios = require('axios');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function runNightlyVerification() {
const { rows: contractors } = await pool.query(
`SELECT id, license_number, license_state, last_known_status
FROM contractors
WHERE is_active = true
ORDER BY last_verified_at ASC NULLS FIRST`
);
// Batch into groups of 50
for (let i = 0; i < contractors.length; i += 50) {
const batch = contractors.slice(i, i + 50);
const response = await axios.post(
'https://api.contractorverify.io/v1/verify/batch',
{
verifications: batch.map(c => ({
reference_id: c.id,
license_number: c.license_number,
state: c.license_state
}))
},
{ headers: { 'Authorization': `Bearer ${process.env.CV_API_KEY}` } }
);
for (const result of response.data.results) {
const contractor = batch.find(c => c.id === result.reference_id);
// Update last_verified_at regardless of status change
await pool.query(
`UPDATE contractors
SET last_verified_at = NOW(),
current_status = $1,
expiration_date = $2,
last_verification_id = $3
WHERE id = $4`,
[result.status, result.expiration_date, result.verification_id, result.reference_id]
);
// Process status changes
if (contractor.last_known_status !== result.status) {
await handleStatusChange(contractor, result);
}
}
// Respect rate limits between batch calls
await new Promise(r => setTimeout(r, 1500));
}
}
cron.schedule('0 2 * * *', runNightlyVerification);
For platforms with contractors across multiple renewal cycles, you can make the polling smarter by prioritizing contractors whose licenses expire soonest. A contractor whose license renews in 8 months doesn't need daily checks. A contractor whose license expires in 14 days needs daily checks. The ORDER BY last_verified_at ASC NULLS FIRST ensures that contractors who haven't been verified recently always get priority, but you can add a secondary sort on expiration date proximity for a more intelligent schedule.
Polling Advantages: Why It's the Right Default
Polling has three properties that make it the correct starting architecture for most platforms building license monitoring for the first time.
First, it's stateless on your side. The polling job doesn't need to maintain a persistent connection, register endpoint URLs, or manage delivery state. It runs, it queries, it finishes. If the job fails halfway through, you restart it and it picks up where it left off (as long as you're updating last_verified_at per contractor). The failure domain is small and well-understood.
Second, it works with any infrastructure. You don't need a publicly routable endpoint. The job can run on a private server, a Lambda function on a VPC with no inbound rules, a container that has no public IP, a Heroku worker dyno. Polling is a client-only pattern - your system initiates all connections, which simplifies firewall rules, security group configurations, and deployment environments.
Third, polling is easy to reason about and debug. When something goes wrong, the debugging question is simple: "Did the job run? What did it return for this contractor?" You have a full log of every API response. There's no question about whether a webhook was delivered, whether the signature was valid, or whether the receiver was available at the time of delivery.
Polling Disadvantages: The Stale Window Problem
The fundamental weakness of polling is the stale window - the gap between when a license status changes on the state board and when your nightly job picks it up. With a nightly cron, that window is up to 24 hours.
For most license status changes, a 24-hour detection window is acceptable. License expirations are predictable - you know 60 days in advance that a license is approaching its expiration date, and you can send renewal reminders. Renewals that complete overnight show up in the next morning's run. Disciplinary actions that result in suspension typically follow a months-long investigation and hearing process - a 24-hour detection lag for the final suspension notice is not operationally significant.
There is one scenario where a 24-hour window creates real risk: emergency suspensions. Some states can issue an emergency suspension order - effective immediately - when there's evidence of an imminent public safety threat. An emergency suspension posted at 10am on a Tuesday won't show in your system until 2am Wednesday. In the interim, a contractor you believe to be compliant is operating with a suspended license.
The second disadvantage is API call volume on unchanged records. In a 10,000-contractor platform with a nightly poll, you're making 10,000 API calls per night. On any given night, perhaps 5-10 contractors will have had a status change. You're making 9,990+ API calls to confirm that nothing changed. At scale, this creates meaningful API cost even when the per-call price is low.
The ContractorVerify API mitigates this with an If-Modified-Since style parameter: including only_if_changed_since in your batch request returns abbreviated responses for records where nothing has changed since the specified timestamp, reducing response payload size and enabling more aggressive caching.
Webhook Architecture: Push-Based Status Changes
Webhooks invert the polling model. Instead of your system asking "has this license changed?", ContractorVerify's monitoring infrastructure asks your system "here's a change - do something with it." When a license status change is detected on a state board, ContractorVerify fires an HTTP POST to your registered webhook endpoint with the event payload.
To use webhooks, you register an endpoint URL in your ContractorVerify dashboard and subscribe to the events you want to receive. You also configure a signing secret - ContractorVerify uses this to generate an HMAC-SHA256 signature for every delivery, which your receiver uses to verify that the payload came from ContractorVerify and wasn't tampered with in transit.
Webhook Payload Structure
ContractorVerify fires four event types related to license status changes. Understanding what each event contains is important for building the right response logic in your receiver.
The license.status_changed event is the catch-all for any transition between status values. The license.expired event fires when a license passes its expiration date without renewal - this is distinct from a suspension in that the contractor may still be able to renew during a grace period. The license.renewed event fires when a previously expired or expiring-soon license is renewed by the board. The license.suspended event fires specifically for suspension actions, which represent active disciplinary or administrative holds rather than simple expiration.
// Webhook payload - license.suspended event
{
"event": "license.suspended",
"event_id": "evt_7bKpQ9xN2m",
"timestamp": "2026-03-23T14:52:31Z",
"api_version": "2026-01-01",
"data": {
"license_number": "1023456",
"state": "CA",
"holder_name": "Apex Mechanical Inc",
"previous_status": "active",
"current_status": "suspended",
"suspension_reason": "DISCIPLINARY_ACTION",
"suspension_effective_date": "2026-03-23",
"expiration_date": "2026-08-31",
"verification_id": "ver_9xKpQm2n",
"classifications": ["B", "C-20"],
"board_source_url": "https://www.cslb.ca.gov/..."
},
"retry_count": 0
}
The event_id field is the key to idempotent processing. ContractorVerify may deliver the same event more than once if your endpoint returns an error or times out. Your receiver must check whether the event_id has already been processed before taking action. Store processed event IDs in a database table and check against it before executing any business logic.
Building a Webhook Receiver in Node.js
The receiver has three jobs: verify the signature, return a 200 immediately, and process the event asynchronously. The fast 200 response is non-negotiable - ContractorVerify marks a delivery as failed if it doesn't receive a 2xx response within 10 seconds. Any business logic that might take longer than that (database writes, sending emails, calling other APIs) must be offloaded to a queue.
const express = require('express');
const crypto = require('crypto');
const { Queue } = require('bullmq');
const app = express();
const webhookQueue = new Queue('webhook-events', { connection: redisConnection });
// Use raw body for signature verification - must come before json() middleware
app.use('/webhooks/contractorverify', express.raw({ type: 'application/json' }));
app.use(express.json());
function verifySignature(rawBody, signature, secret) {
const expectedSig = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const receivedSig = signature.replace('sha256=', '');
// Use timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(expectedSig, 'hex'),
Buffer.from(receivedSig, 'hex')
);
}
app.post('/webhooks/contractorverify', async (req, res) => {
const signature = req.headers['x-contractorverify-signature'];
if (!signature) {
return res.status(400).json({ error: 'Missing signature header' });
}
const isValid = verifySignature(
req.body,
signature,
process.env.CV_WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse body after signature verification
const event = JSON.parse(req.body);
// Acknowledge receipt immediately - processing happens async
res.status(200).json({ received: true });
// Enqueue for async processing
await webhookQueue.add('process-event', event, {
jobId: event.event_id, // BullMQ deduplicates by jobId
attempts: 3,
backoff: { type: 'exponential', delay: 5000 }
});
});
The worker that processes jobs from the queue handles the actual business logic: updating the contractor's status in your database, triggering compliance holds, sending alerts to project managers, and logging the event to your audit trail.
// Webhook event processor (BullMQ worker)
const { Worker } = require('bullmq');
const worker = new Worker('webhook-events', async job => {
const event = job.data;
// Idempotency check
const existing = await pool.query(
'SELECT 1 FROM processed_webhook_events WHERE event_id = $1',
[event.event_id]
);
if (existing.rows.length > 0) {
console.log(`Skipping duplicate event ${event.event_id}`);
return;
}
const { license_number, state, current_status, previous_status } = event.data;
// Update contractor record
await pool.query(
`UPDATE contractors
SET current_status = $1,
status_changed_at = NOW(),
last_verification_id = $2
WHERE license_number = $3 AND license_state = $4`,
[current_status, event.data.verification_id, license_number, state]
);
// Trigger compliance actions based on event type
if (event.event === 'license.suspended' || event.event === 'license.status_changed') {
if (['suspended', 'revoked'].includes(current_status)) {
await triggerComplianceHold(license_number, state, event);
await notifyProjectManagers(license_number, state, event);
}
}
if (event.event === 'license.renewed') {
await clearComplianceHold(license_number, state);
await notifyProjectManagers(license_number, state, event);
}
// Mark event as processed
await pool.query(
'INSERT INTO processed_webhook_events (event_id, processed_at) VALUES ($1, NOW())',
[event.event_id]
);
}, { connection: redisConnection });
Webhook Failure Handling: Retries, Dead Letters, and Fallback
Webhooks introduce a failure mode that polling doesn't have: your endpoint might be unavailable when ContractorVerify tries to deliver an event. If your server is deploying, restarting, or experiencing a network partition, deliveries during that window are lost unless the sender retries.
ContractorVerify retries failed deliveries on an exponential backoff schedule: 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours. After 5 failed attempts, the event moves to a dead letter state - it's available for manual replay from the ContractorVerify dashboard for 7 days, but will not auto-retry further.
Your failure handling strategy should account for three scenarios:
Transient Downtime (Under 24 Hours)
If your webhook endpoint is down for less than 24 hours, ContractorVerify's retry schedule will cover you. The critical event (a suspension, for example) will be delivered once your endpoint recovers. You don't need to do anything special for this case - it's handled by the retry policy.
Extended Downtime or Dead Letter Events
For outages longer than 24 hours, or for events that hit the retry limit and moved to dead letter state, you need a recovery path. The ContractorVerify dashboard allows manual replay of dead letter events - but this requires a human to notice and intervene. A better approach is a reconciliation job that runs weekly: it queries the ContractorVerify API for all contractors whose status was last confirmed more than N days ago and re-verifies them. This is a polling sweep that acts as a safety net under the webhook system.
Event Ordering Guarantees
Webhooks don't guarantee delivery order. In theory, a license.renewed event could be delivered before the license.expired event that preceded it (if the expired event was delayed by retries). Your processor must handle this gracefully. The safest approach is to always include the full current state in the event payload (ContractorVerify does this via the current_status field) and use the timestamp field to resolve conflicts: if you receive an event with a timestamp older than the timestamp of the last state transition you recorded, ignore it.
// In the webhook processor, before updating contractor record:
const { rows: [contractor] } = await pool.query(
'SELECT status_changed_at FROM contractors WHERE license_number = $1',
[license_number]
);
const eventTimestamp = new Date(event.timestamp);
if (contractor && contractor.status_changed_at > eventTimestamp) {
console.log(`Ignoring out-of-order event ${event.event_id}: event ts ${event.timestamp} < last change ${contractor.status_changed_at}`);
// Still mark as processed to prevent future redelivery attempts
await markEventProcessed(event.event_id);
return;
}
The Hybrid Approach: Polling as Heartbeat, Webhooks for Critical Events
The production architecture for a serious license monitoring platform uses both patterns with clearly defined roles.
Webhooks handle time-sensitive events. license.suspended and license.revoked events need to be acted on as quickly as possible. These are the events where a 24-hour polling window creates meaningful risk. Webhooks deliver these within minutes of detection. Your receiver processes them immediately and triggers compliance holds and notifications.
Polling handles everything else. The nightly sweep re-verifies all active contractors, catches renewals that may not have fired a webhook (state board data quality varies), and acts as the reconciliation layer that catches any events that webhooks missed. The polling sweep also builds the historical record that demonstrates ongoing due diligence - every contractor verified every night, with timestamps, stored in your database.
| Dimension | Polling Only | Webhooks Only | Hybrid |
|---|---|---|---|
| Detection latency for suspension | Up to 24 hours | Minutes | Minutes (webhook) with 24h fallback (poll) |
| Infrastructure complexity | Low - no public endpoint required | Medium - public endpoint + queue required | Medium-High |
| Failure handling | Simple - retry the job | Complex - retry policy, dead letters, reconciliation | Complex, but polling provides safety net |
| API call volume | High - all contractors every night | Low - only changed records | Moderate - polling + webhook calls |
| Right scale | Under 5,000 contractors | 10,000+ contractors, low-latency requirement | Any scale where suspension latency matters |
Infrastructure Considerations for Webhook Receivers
The webhook receiver needs to meet three infrastructure requirements that polling doesn't have.
First, it must be publicly routable. ContractorVerify's servers need to be able to reach your endpoint over HTTPS. This means the receiver cannot live on a private VPC with no inbound rules, behind a NAT gateway with no public IP, or on localhost. If your primary application server is private, you'll need a dedicated public-facing endpoint - this can be as simple as an API Gateway in front of a Lambda function that writes to an SQS queue for processing by your private workers.
Second, it must be highly available. Webhook delivery happens at ContractorVerify's schedule, not yours. If your receiver is down during a maintenance window, you're relying on retry logic to recover missed events. Design the receiver for 99.9%+ availability - this generally means a managed service (Lambda, Cloud Run, App Engine) rather than a single EC2 instance or a process running on your app server.
Third, it must respond within the timeout window. ContractorVerify expects a 2xx response within 10 seconds. Your receiver should do nothing except verify the signature and enqueue the event before returning 200. Keep the receiver thin - all processing happens in workers.
For Small Platforms: Start With Polling
If you're monitoring fewer than 2,000 contractors and don't have a specific SLA requirement for suspension detection latency, start with polling. Build the nightly sweep, validate that it works correctly, and let it run for a month before evaluating whether you need webhook infrastructure.
The complexity cost of webhooks - public endpoint, queue, idempotency logic, dead letter handling, reconciliation sweeps - is real. Take on that complexity when you have a demonstrated need for lower-latency detection, not speculatively on day one.
When you're ready to add webhooks, the existing polling infrastructure becomes your fallback rather than your primary detection mechanism. Nothing is wasted - the polling sweep continues to run as the heartbeat that catches edge cases and provides the reconciliation layer.
For a detailed implementation of the polling-based expiration monitoring pattern, see the license expiration monitoring guide. For background on the data freshness trade-offs between real-time API calls and cached verification results, see the data freshness and caching guide.
> Automate Your License Verification_
ContractorVerify supports both polling and webhook architectures. The batch verification endpoint handles nightly sweeps up to 50 contractors per request. Webhook subscriptions are available for license.status_changed, license.expired, license.renewed, and license.suspended events - with HMAC-SHA256 signature verification and 5-attempt retry logic built in.