Marketplaces often verify a listing VIN after a seller saves a draft: an inbound webhook from the listing service enqueues a decode job, the worker calls NHTSA (or your proxy), and the result soft-flags make mismatches or invalid check digits. That pipeline is useful and easy to get wrong. A forged webhook can burn your rate budget, spam sellers, or write attacker-controlled fields into listing metadata.
This post covers safe webhook patterns for triggering VIN verify jobs -- auth, idempotency, payload hygiene, and queue boundaries -- not the decode algorithm itself.
Threat model in one paragraph
Assume the webhook URL will leak (logs, browser extensions, a misconfigured staging env). Attackers can replay old bodies, flood you with random 17-character strings, or send VINs that collide with popular inventory. Your job is to authenticate the sender, accept each logical event once, validate the VIN shape before enqueue, and never trust webhook JSON as the final decode result.
Authenticate before you enqueue
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhookSignature(
rawBody: Buffer,
signatureHeader: string,
secret: string,
): boolean {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const provided = signatureHeader.replace(/^sha256=/i, "").trim();
if (provided.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
}
Verify against the raw body bytes, not a re-serialized object. Reject missing or invalid signatures with 401 and do not enqueue. Rotate secrets without downtime by accepting two secrets during a window.
Prefer signed timestamps with a short skew window to blunt naive replays:
export function signatureFresh(tsHeader: string, skewSec = 300): boolean {
const ts = Number(tsHeader);
if (!Number.isFinite(ts)) return false;
const age = Math.abs(Date.now() / 1000 - ts);
return age <= skewSec;
}
Idempotency keys beat "hope"
Listing platforms retry webhooks. Without idempotency you double-charge NHTSA and send two "VIN mismatch" emails.
export type VerifyJob = {
idempotencyKey: string;
listingId: string;
vinRaw: string;
receivedAt: number;
};
export function jobFromWebhook(evt: {
id: string;
listing_id: string;
vin: string;
}): VerifyJob {
return {
idempotencyKey: `listing-vin-verify:${evt.id}`,
listingId: evt.listing_id,
vinRaw: evt.vin,
receivedAt: Date.now(),
};
}
Store processed idempotency keys with a TTL longer than the provider's retry window. On duplicate, return 200 with the prior outcome -- do not re-run the decode.
Validate and normalize before the queue
const VIN_CHARSET = /^[A-HJ-NPR-Z0-9]{17}$/;
export function normalizeVin(raw: string): string | null {
const cleaned = raw
.normalize("NFKC")
.replace(/[\u200B-\u200D\uFEFF\u00AD]/g, "")
.replace(/[\s\-._]/g, "")
.toUpperCase();
return VIN_CHARSET.test(cleaned) ? cleaned : null;
}
export function acceptVerifyWebhook(rawBody: Buffer, headers: {
signature: string;
timestamp: string;
}, secret: string, evt: { id: string; listing_id: string; vin: string }):
| { ok: true; job: VerifyJob; vin: string }
| { ok: false; status: number; error: string } {
if (!verifyWebhookSignature(rawBody, headers.signature, secret)) {
return { ok: false, status: 401, error: "bad signature" };
}
if (!signatureFresh(headers.timestamp)) {
return { ok: false, status: 401, error: "stale timestamp" };
}
const vin = normalizeVin(evt.vin);
if (!vin) {
return { ok: false, status: 400, error: "invalid vin shape" };
}
if (!evt.listing_id || !evt.id) {
return { ok: false, status: 400, error: "missing ids" };
}
return { ok: true, job: jobFromWebhook(evt), vin };
}
Return 400 for garbage VINs without enqueueing. Returning 200 for invalid shapes can hide seller typos; returning 500 invites infinite retries. Prefer 400 for client error shapes and 200 only after durable enqueue or idempotent replay.
Queue boundary: webhook thin, worker fat
The HTTP handler should:
- Verify signature and freshness
- Parse a minimal schema (
id,listing_id,vin) - Normalize VIN
- Enqueue with idempotency key
- Respond quickly
The worker should:
- Claim the job once
- Call your decode service (with budgets, coalescing, and caching)
- Write verify status to your own store keyed by
listing_id - Emit seller-facing notifications from your status, not from raw webhook JSON
Never let the webhook body supply make, model, or "already verified" booleans that skip the worker. Those fields are attacker-controlled if auth ever slips.
Rate and abuse controls
Even authenticated partners can spam. Cap verifies per listing and per seller:
export function allowVerify(listingId: string, countInWindow: number): boolean {
const MAX = 10; // e.g. per hour
return countInWindow < MAX;
}
Combine with global queue concurrency limits so a buggy partner cannot monopolize NHTSA budget. Log counts by partner id, not by cleartext VIN, when policies require redaction.
Soft outcomes, hard auth
Verification results should soft-flag mismatches for human review, not auto-ban on a single decode. Auth on the webhook stays hard: fail closed on signature, fail closed on schema, fail open only on upstream decode ambiguity after the job is trusted to run.
Takeaway
Marketplace webhooks that trigger VIN verify jobs need HMAC (or equivalent) auth on raw bodies, freshness checks, idempotency keys, VIN normalization before enqueue, and a thin handler / fat worker split. Treat webhook JSON as a hint to start work, never as the decode itself. That keeps rate budgets intact and listing metadata honest when retries and forged traffic arrive.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)