DEV Community

Vin Lookup
Vin Lookup

Posted on

Safe Patterns for Marketplace Webhooks That Trigger VIN Verify Jobs

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));
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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(),
  };
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Verify signature and freshness
  2. Parse a minimal schema (id, listing_id, vin)
  3. Normalize VIN
  4. Enqueue with idempotency key
  5. Respond quickly

The worker should:

  1. Claim the job once
  2. Call your decode service (with budgets, coalescing, and caching)
  3. Write verify status to your own store keyed by listing_id
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

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)