zyfy
Official Node.js client library for the Zyfy UK data enrichment API.
Two products in one package:
- Vehicle Intelligence — DVLA + DVSA MOT history, ULEZ, tax status, odometer trend, buy recommendation
- Postcode Intelligence — broadband, flood risk, crime, property prices, deprivation, air quality, MP data, and more
Get your API key at zyfy.uk/signup. Full API docs at zyfy.uk/docs.
Installation
npm install @zyfy-uk/zyfy
Requires Node.js 20 or later.
Quickstart
import { Zyfy } from '@zyfy-uk/zyfy'
const client = new Zyfy({ apiKey: 'ea_live_...' })
// Vehicle lookup
const vehicle = await client.vehicle.lookup('AB12CDE')
console.log(vehicle.summary?.buyRecommendation) // "good" | "consider" | "caution" | "avoid"
console.log(vehicle.signals?.motExpiryDate)
console.log(vehicle.quota.remaining) // requests left this month
// Postcode lookup
const postcode = await client.postcode.lookup('SW1A 2AA')
console.log(postcode.summary?.liveabilityLevel) // "low" | "medium" | "high"
console.log(postcode.signals?.crime?.rateBand)
console.log(postcode.quota.remaining)
The API key can also be set via the ZYFY_API_KEY environment variable — if it is, you can construct the client with no arguments:
const client = new Zyfy()
Configuration reference
All options are passed to the Zyfy constructor. All are optional if ZYFY_API_KEY is set.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
string |
ZYFY_API_KEY env var |
Your Zyfy API key. |
maxEnrichmentRetries |
number |
10 |
Auto-retries when enrichmentPending: true. Each retry waits the Retry-After seconds from the response. Set to 0 to disable. |
timeoutMs |
number |
10000 |
Per-request timeout in milliseconds. Surfaces as NetworkError on expiry. |
baseUrl |
string |
https://zyfy.uk/v1 |
Override for local development or testing. |
debug |
boolean |
false |
Logs requests and responses to stderr. The API key is always redacted. |
Full reference
client.vehicle.lookup(registration, options?)
Look up a single UK vehicle by registration mark. Returns DVLA details, DVSA MOT history, emissions, and computed intelligence.
Full signal reference: zyfy.uk/docs/vehicle.html
| Parameter | Type | Description |
|---|---|---|
registration |
string |
UK registration mark. Spaces and case are normalised automatically. |
options.maxEnrichmentRetries |
number |
Per-call override of maxEnrichmentRetries. |
Returns: Promise<VehicleResult & { quota: Quota }>
client.vehicle.bulkLookup(registrations, options?)
Synchronous bulk vehicle lookup. Runs sequentially on the server. Up to your tier's bulk cap per call.
Returns: Promise<BulkVehicleResult & { quota: Quota }>
Use isVehicleBulkError(item) to distinguish error items from successful results:
const { results } = await client.vehicle.bulkLookup(['AB12CDE', 'NOTREAL'])
for (const item of results) {
if (isVehicleBulkError(item)) {
console.log(item.registration, item.error) // "not_found" | "invalid_format"
} else {
console.log(item.registration, item.summary?.buyRecommendation)
}
}
client.vehicle.submitBulk(registrations)
Submit an async bulk job. Returns immediately with a jobId. Poll with getJob().
Returns: Promise<BulkJobSubmitted & { quota: Quota }>
client.vehicle.getJob(jobId)
Poll the status of an async bulk job. Check status === "complete" before reading results.
Returns: Promise<BulkJobStatus<BulkVehicleItem> & { quota: Quota }>
status values: "queued" | "processing" | "complete" | "expired"
client.vehicle.deleteJob(jobId)
Delete a bulk job and its results. Jobs expire automatically after 24 hours.
Returns: Promise<{ deleted: string; quota: Quota }>
client.postcode.lookup(postcode, options?)
Look up a single UK postcode. Returns geographic classification, broadband, flood risk, property prices, crime, deprivation, air quality, housing, political, and demographic signals.
Northern Ireland postcodes (BT prefix) are not supported. Throws ValidationError with code: "unsupported_region".
Full signal reference: zyfy.uk/docs/postcode.html
Returns: Promise<PostcodeResult & { quota: Quota }>
client.postcode.nearest(lat, lon, options?)
Find the nearest postcode to a set of WGS84 coordinates. Coordinates must be within the UK bounding box. The response includes a queryPointDistanceMetres field.
| Parameter | Type | Default | Description |
|---|---|---|---|
lat |
number |
— | WGS84 latitude. |
lon |
number |
— | WGS84 longitude. |
options.radius |
number |
1000 |
Search radius in metres. Throws NotFoundError if no postcode centroid is found within the radius. |
options.maxEnrichmentRetries |
number |
— | Per-call override of maxEnrichmentRetries. |
Returns: Promise<PostcodeResult & { quota: Quota }>
client.postcode.bulkLookup(postcodes, options?)
Synchronous bulk postcode lookup.
Returns: Promise<BulkPostcodeResult & { quota: Quota }>
Use isPostcodeBulkError(item) to distinguish errors:
const { results } = await client.postcode.bulkLookup(['SW1A 2AA', 'BT1 1AA'])
for (const item of results) {
if (isPostcodeBulkError(item)) {
console.log(item.postcode, item.error) // "not_found" | "unsupported_region"
} else {
console.log(item.postcode, item.summary?.liveabilityLevel)
}
}
client.postcode.submitBulk(postcodes) / getJob(jobId) / deleteJob(jobId)
Same async bulk pattern as vehicle. See vehicle docs above.
Error handling
All errors extend ZyfyError. Import the specific classes for instanceof checks.
import {
Zyfy,
QuotaExhaustedError,
RateLimitError,
ValidationError,
NotFoundError,
AuthenticationError,
ApiError,
NetworkError,
} from '@zyfy-uk/zyfy'
try {
const result = await client.vehicle.lookup('AB12CDE')
} catch (err) {
if (err instanceof QuotaExhaustedError) {
// Monthly quota exhausted. err.resets is an ISO 8601 UTC string indicating
// when the quota rolls over — null if no monthly reset applies.
if (err.resets) {
const resetsAt = new Date(err.resets)
const hoursUntilReset = Math.round(err.retryAfter / 3600)
console.error(`Quota exhausted. Resets at ${resetsAt.toUTCString()} (~${hoursUntilReset}h)`)
} else {
console.error('Quota exhausted. Contact support to increase your limit.')
}
} else if (err instanceof RateLimitError) {
// Per-minute rate limit exceeded. Back off by err.retryAfter seconds and retry.
console.warn(`Rate limited. Retrying in ${err.retryAfter}s`)
await new Promise(r => setTimeout(r, err.retryAfter * 1000))
// retry the call...
} else if (err instanceof ValidationError) {
// Input rejected by the server. err.code identifies the specific problem.
// Common codes: "unsupported_region" (BT postcodes), "invalid_format"
console.error(`Validation error: ${err.code}`)
} else if (err instanceof NotFoundError) {
console.error('Vehicle or postcode not found')
} else if (err instanceof AuthenticationError) {
console.error('Invalid or missing API key')
} else if (err instanceof ApiError) {
console.error(`Server error ${err.statusCode}`)
} else if (err instanceof NetworkError) {
console.error('Connection failed — check your network')
}
}
| Error class | HTTP status | When |
|---|---|---|
AuthenticationError |
401 | Invalid or missing API key |
NotFoundError |
404 | Vehicle or postcode not found |
ValidationError |
422 | Invalid input; check err.code (e.g. "unsupported_region", "invalid_format") |
RateLimitError |
429 | Per-minute rate limit exceeded; err.retryAfter seconds until safe to retry |
QuotaExhaustedError |
429 | Monthly quota exhausted; err.retryAfter seconds until reset, err.resets ISO 8601 datetime (null if not applicable) |
ApiError |
5xx | Server error; err.statusCode |
NetworkError |
— | Connection failure or request timeout |
All error classes expose rawBody: string with the full response body for debugging.
Quota
Every successful response includes a quota object populated from response headers:
const result = await client.vehicle.lookup('AB12CDE')
const { quota } = result
console.log(quota.limit) // monthly cap — number, or "unlimited"
console.log(quota.used) // requests consumed this month
console.log(quota.remaining) // requests left — number, or "unlimited"
console.log(quota.graceLimit) // buffer above limit before hard block (~10%); null if not applicable
console.log(quota.resets) // ISO 8601 UTC string of next reset; null if not applicable
remaining reaches zero at the quota limit and further requests are blocked. A small grace buffer (graceLimit) allows a few extra requests above the cap before the hard block kicks in.
resets is null when no monthly cap is in effect. Always null-check before formatting or displaying it.
To avoid hitting the limit unexpectedly in high-volume applications, read quota.remaining after each response and stop or slow down before it reaches zero.
Debug mode
Pass debug: true to log every request and response to stderr:
const client = new Zyfy({ apiKey: '...', debug: true })
Output looks like:
[zyfy] → GET https://zyfy.uk/v1/vehicle/AB12CDE (X-Api-Key: ea_live_***, attempt 1)
[zyfy] ← 200 (quota-remaining: 9958, retry-after: none)
The API key is always redacted — it will never appear in logs regardless of debug mode. Useful for diagnosing unexpected responses, quota consumption, or enrichment retry behaviour in development.
Enrichment retries
Vehicle lookups may return enrichmentPending: true when background enrichment is still running — typically the first time a registration is seen, or when the vehicle's data is being refreshed. When pending, signals, summary, and scores may be null or incomplete. Postcode lookups are always served from a pre-loaded dataset and never set enrichmentPending.
Automatic retries (default)
The client retries automatically up to maxEnrichmentRetries times (default: 10), waiting the number of seconds in the Retry-After response header (typically 5 seconds) between each attempt. The final result is returned once enrichment completes or retries are exhausted — no exception is thrown either way.
Override the retry limit per call:
const result = await client.vehicle.lookup('AB12CDE', { maxEnrichmentRetries: 3 })
Manual retry pattern
If you need control over retry timing — for example, in a queue-based system where you want to reenqueue rather than block the current thread — disable auto-retries and handle enrichmentPending yourself:
const client = new Zyfy({ apiKey: '...', maxEnrichmentRetries: 0 })
let result = await client.vehicle.lookup('AB12CDE')
if (result.enrichmentPending) {
// Partial data returned — signals/summary/scores may be null.
// The API will typically have the enriched result ready within 5 seconds.
// Option A: wait and re-query inline
await new Promise(r => setTimeout(r, 5_000))
result = await client.vehicle.lookup('AB12CDE')
// Option B: in a queue system, persist result.registration and re-process
// after a delay rather than blocking here.
}
// Use whatever is available — enrichmentPending may still be true
// if enrichment is unusually slow. The data returned is always valid.
if (result.enrichmentPending) {
console.log(`Partial data for ${result.registration} — enrichment still in progress`)
}
console.log(result.registration, result.summary?.buyRecommendation ?? 'pending')
If auto-retries exhaust while enrichmentPending is still true, the last partial response is returned without throwing. All returned fields are valid — only fields that depend on enrichment may be null.
TypeScript types
All response types are exported from the package. Full definitions:
Quota
Returned on every successful response. Populated from X-Quota-* response headers.
interface Quota {
limit: number | 'unlimited'
used: number
remaining: number | 'unlimited'
graceLimit: number | null
resets: string | null // ISO 8601; null if not applicable
}
VehicleResult
interface VehicleResult {
registration: string // uppercase, no spaces
make: string | null
model: string | null
vehicleType: string | null // "car" | "van" | "motorcycle" | "bus" | "hgv" | "motorhome" | "trailer" | "tractor" | "other"
colour: string | null
fuelType: string | null
engineCapacityCc: number | null
yearOfManufacture: number | null
monthOfFirstRegistration: string | null // YYYY-MM
vehicleAgeYears: number | null
summary: VehicleSummary | null
signals: VehicleSignals | null
scores: VehicleScores | null
fleetFailureProfile: FleetFailureProfile | null
fleetAdvisoryProfile: FleetAdvisoryProfile | null
sources: VehicleSources
schemaVersion: string
enrichmentPending: boolean
dataAsOf: string // ISO 8601
checkedAt: string // ISO 8601
quota: Quota
}
interface VehicleSummary {
buyRecommendation: 'good' | 'consider' | 'caution' | 'avoid' | null
vehicleRiskLevel: 'low' | 'medium' | 'high' | null
motRiskLevel: 'low' | 'medium' | 'high' | null
conditionBand: 'good' | 'fair' | 'poor' | 'bad' | null
maintenanceBand: 'good' | 'fair' | 'poor' | 'bad' | null
mileageAnomalyRisk: 'none' | 'low' | 'high' | null
colourChangeIndicated: boolean | null
aboveAverageAdvisories: boolean | null
motFailureDetailAvailable: boolean
}
interface VehicleSignals {
co2EmissionsGPerKm: number | null
euroEmissionStandard: string | null
ulezCompliant: boolean | null
markedForExport: boolean
hasOutstandingRecall: boolean | null
v5cLastIssued: string | null // YYYY-MM-DD
taxStatus: string | null
taxDueDate: string | null // YYYY-MM-DD
taxDaysRemaining: number | null
vedBand: string | null
vedAnnualCostGbp: number | null
motStatus: string | null
motExpiryDate: string | null // YYYY-MM-DD
motDaysRemaining: number | null
imminentMot: boolean
odometerTrend: 'consistent' | 'increasing' | 'decreasing' | 'erratic' | null
latestOdometerMiles: number | null
typicalAnnualMileageMiles: number | null
odometerVsFleetAverage: 'below_average' | 'average' | 'above_average' | null
motPassRate: number | null
totalMotTests: number
totalMotFailures: number
totalAdvisoryCount: number
totalFailureItemCount: number
latestAdvisoryCount: number
latestFailureItemCount: number
dangerousDefectEver: boolean
highFailureHistory: boolean
advisoryTrend: 'increasing' | 'stable' | 'decreasing' | null
advisoryMomentum: 'worsening' | 'stable' | 'improving' | null
daysSinceLastFailure: number | null
failuresLast24Months: number | null
advisoriesLast3Tests: number | null
trendWindowTests: number
firstMotDate: string | null // YYYY-MM-DD
lastMotDate: string | null // YYYY-MM-DD
lastMotResult: string | null
firstMotDue: string | null // YYYY-MM-DD
failureClusters: string[] | null
repeatFailureCount: number | null
advisoryClusters: string[] | null
ncapSafetyRating: NcapSafetyRating | null
drivetrainStressProfile: DrivetrainStressProfile | null
}
interface NcapSafetyRating {
overallStars: number | null // 0–5
adultOccupant: number | null // 0–100
childOccupant: number | null // 0–100
vulnerableRoadUsers: number | null // 0–100
safetyAssist: number | null // 0–100
testedYear: number | null
}
interface DrivetrainStressProfile {
likelyDrivingPattern: 'short_urban' | 'mixed' | 'long_distance' | null
dpfRisk: 'low' | 'elevated' | 'high' | null // diesel only
}
interface VehicleScores {
motRiskScore: number | null // 0–1, lower is better
conditionScore: number | null // 0–1, higher is better
conditionPercentile: number | null
maintenanceScore: number | null // 0–1, higher is better
maintenancePercentile: number | null
failureRateRatio: number | null // 1.0 = fleet average
advisoryRateRatio: number | null // 1.0 = fleet average
benchmarkSampleSize: number | null
avgAdvisoriesPerTestForMMY: number | null
avgFailuresPerTestForMMY: number | null
offRoadLikelihoodScore: number | null // 0–1, higher = more likely off-road
scoreConvention: string
}
interface FleetFailureProfile {
mileageBand: string
sampleSize: number
topFailures: FleetItem[]
}
interface FleetAdvisoryProfile {
mileageBand: string
sampleSize: number
topAdvisories: FleetItem[]
}
interface FleetItem {
category: string
rate: number
}
interface VehicleSources {
motHistory: string
mutableData: string
safetyRating: string | null
}
PostcodeResult
interface PostcodeResult {
postcode: string // formatted with space: "SW1A 2AA"
outwardCode: string
inwardCode: string | null // null for outward-code-only queries
latitude: number | null
longitude: number | null
eastings: number | null
northings: number | null
country: string | null
region: string | null
adminDistrict: string | null
adminCounty: string | null
adminWard: string | null
parish: string | null
parliamentaryConstituency: string | null
nhsTrust: string | null
lsoa: string | null
msoa: string | null
ruralUrbanClassification: string | null
summary: PostcodeSummary | null
signals: PostcodeSignals | null
scores: PostcodeScores | null
percentiles: PostcodePercentiles | null // Starter+ tiers only
geographyCodes: GeographyCodes
queryPointDistanceMetres: number | null // nearest endpoint only
sources: PostcodeSources
schemaVersion: string
dataAsOf: string // ISO 8601
checkedAt: string // ISO 8601
quota: Quota
}
interface PostcodeSummary {
propertyRiskLevel: 'low' | 'medium' | 'high' | null
liveabilityLevel: 'low' | 'medium' | 'high' | null
insuranceRiskLevel: 'low' | 'medium' | 'high' | null
investmentOutlook: 'weak' | 'fair' | 'good' | 'strong' | null
growthSignal: 'strong_positive' | 'positive' | 'neutral' | 'weak_negative' | 'strong_negative' | null
dataConfidence: 'low' | 'medium' | 'high'
areaTrajectory: 'improving' | 'stable' | 'declining' | 'mixed' | null
familySuitability: 'poor' | 'fair' | 'good' | 'excellent' | null
retirementSuitability: 'poor' | 'fair' | 'good' | 'excellent' | null
}
interface PostcodeSignals {
broadband: PostcodeBroadbandSignals | null
flood: PostcodeFloodSignals | null
property: PostcodePropertySignals | null
crime: PostcodeCrimeSignals | null
environment: PostcodeEnvironmentSignals | null
housing: PostcodeHousingSignals | null
political: PostcodePoliticalSignals | null
deprivation: PostcodeDeprivationSignals | null
demographics: PostcodeDemographicsSignals | null
}
interface PostcodeBroadbandSignals {
superfast: boolean | null
ultrafast: boolean | null
gigabit: boolean | null
}
interface PostcodeFloodSignals {
riversSea: 'high' | 'medium' | 'low' | 'very_low' | null
groundwater: 'high' | 'medium' | 'low' | 'very_low' | null
riversSeaTrend: 'worsening' | 'stable' | 'improving' | 'insufficient_data' | null
groundwaterTrend: 'worsening' | 'stable' | 'improving' | 'insufficient_data' | null
}
interface PostcodePropertySignals {
averagePrice: number | null // median price, last 12 months
priceLow: number | null // 25th percentile
priceHigh: number | null // 75th percentile
priceTrend: number | null // YoY % change, e.g. 5.2 = +5.2%
priceTrendPeriodMonths: number
transactionVolume: number | null // residential transactions, last 12 months
granularity: 'postcode' | 'sector' | 'district' | null
trendConfidence: 'high' | 'medium' | 'low' | null
}
interface PostcodeCrimeSignals {
rateBand: 'very_low' | 'low' | 'medium' | 'high' | 'very_high' | null
dataGranularity: PostcodeCrimeGranularity | null
categories: PostcodeCrimeCategories | null
}
interface PostcodeCrimeGranularity {
band: string | null // "lsoa" (England/Wales) | "datazone" (Scotland)
categories: string | null // "lsoa" (England/Wales) | "local_authority" (Scotland)
}
interface PostcodeCrimeCategories {
violence: PostcodeCrimeCategory | null
property: PostcodeCrimeCategory | null
vehicle: PostcodeCrimeCategory | null
antisocial: PostcodeCrimeCategory | null
drugs: PostcodeCrimeCategory | null
damage: PostcodeCrimeCategory | null
}
interface PostcodeCrimeCategory {
band: 'very_low' | 'low' | 'medium' | 'high' | 'very_high' | null
trend: 'increasing' | 'stable' | 'decreasing' | 'insufficient_data' | null
}
interface PostcodeEnvironmentSignals {
airQualityBand: 'very_low' | 'low' | 'moderate' | 'high' | null
airQualityTrend: 'worsening' | 'stable' | 'improving' | 'insufficient_data' | null
no2UgM3: number | null // annual mean NO2 µg/m³
pm25UgM3: number | null // annual mean PM2.5 µg/m³
radonPotential: 'very_low' | 'low' | 'medium' | 'high' | 'very_high' | null
greenSpaceProximityMetres: number | null
isNationalPark: boolean | null
isAonb: boolean | null
isGreenBelt: boolean | null
}
interface PostcodeHousingSignals {
epcAverageRating: string | null // A–G
councilTaxBand: CouncilTaxBandEstimate | null
dominantPropertyType: 'detached' | 'semi_detached' | 'terraced' | 'flat' | 'other' | null
}
interface CouncilTaxBandEstimate {
lower: string // A–I
upper: string // A–I; equals lower when a single band is determined
source: 'exact_nrs' | 'derived_hmlr' | 'derived_lsoa'
}
interface PostcodePoliticalSignals {
mpName: string | null
mpParty: string | null
mpPartyColour: string | null
}
interface PostcodeDeprivationSignals {
imdDecile: number | null // 1 = most deprived, 10 = least deprived; England only
imdTrend: 'improving' | 'stable' | 'declining' | 'insufficient_data' | null
}
interface PostcodeDemographicsSignals {
percOwnerOccupied: number | null
percPrivateRented: number | null
percNoCarVan: number | null
medianAge: number | null
percEconomicallyActive: number | null
}
interface PostcodeScores {
propertyRiskScore: number | null // 0–1, lower is better
liveabilityScore: number | null // 0–1, higher is better
investmentScore: number | null // 0–1, higher is better
affordabilityIndex: number | null // 0–1, higher is better
scoreConvention: string
}
interface PostcodePercentiles {
floodRivers: number | null
floodGroundwater: number | null
crimeRate: number | null
imd: number | null
propertyPrice: number | null
propertyPriceRegional: number | null
radon: number | null
airQuality: number | null
greenSpaceProximity: number | null
epc: number | null
}
interface GeographyCodes {
adminDistrict: string | null
adminCounty: string | null
adminWard: string | null
parliamentaryConstituency: string | null
lsoa: string | null
msoa: string | null
}
interface PostcodeSources {
geography: string
flood: string
crime: string
property: string
deprivation: string
broadband: string
environment: string
epc: string
greenSpace: string
demographics: string | null
}
Bulk types
interface BulkVehicleResult {
total: number
results: BulkVehicleItem[]
quota: Quota
}
interface BulkPostcodeResult {
total: number
results: BulkPostcodeItem[]
quota: Quota
}
// BulkVehicleItem is VehicleResult | VehicleBulkItemError
interface VehicleBulkItemError {
registration: string
error: 'not_found' | 'invalid_format'
}
// BulkPostcodeItem is PostcodeResult | PostcodeBulkItemError
interface PostcodeBulkItemError {
postcode: string
error: 'not_found' | 'unsupported_region'
}
interface BulkJobSubmitted {
jobId: string
status: 'queued'
total: number
pollUrl: string
quota: Quota
}
interface BulkJobStatus<T> {
jobId: string
status: 'queued' | 'processing' | 'complete' | 'expired'
total: number
done: number
createdAt: string
completedAt: string | null
expiresAt: string
results: T[] | null
quota: Quota
}
Examples
Runnable examples are in the examples/ directory.
# Set your key first
export ZYFY_API_KEY=ea_live_...
# Single lookups
VEHICLE_REG=AB12CDE npm run example:vehicle
POSTCODE="SW1A 2AA" npm run example:postcode
# Geographic queries
LAT=51.508 LON=-0.1281 npm run example:nearest
LAT=51.508 LON=-0.1281 RADIUS=500 npm run example:within
# Bulk lookups
VEHICLE_REGS=AB12CDE,XY34FGH npm run example:bulk-vehicle
POSTCODES="SW1A 2AA,M1 1AE" npm run example:bulk-postcode
# Error handling scenarios
npx tsx examples/errors.ts vehicle-invalid
npx tsx examples/errors.ts postcode-not-found
npx tsx examples/errors.ts postcode-ni
npx tsx examples/errors.ts bad-auth
Versioning
This library follows SemVer. See CHANGELOG.md for version history.
0.x.x releases may include breaking changes between minor versions. Stability guaranteed from 1.0.0.