Build a Trademark Clearance Report Generator with Signa's API

Build a trademark clearance report generator using Signa's Search API. Score risk across 147M+ records, enrich matches, and output structured reports for attorney review.
18 min read

A professional trademark clearance report costs $500 to $2,000 per mark and takes three to five business days. At scale, that cost structure breaks. If you're evaluating ten name candidates across three Nice classes, you're looking at $5,000 to $20,000 and weeks of waiting before you've even filed anything.

Meanwhile, 17-20% of USPTO applications receive office actions citing likelihood of confusion with an existing mark. The clearance step exists to avoid exactly that outcome, and the forced rebrand that follows (which can run $15,000 to $200,000+). The problem isn't the concept. It's the delivery mechanism: manual searches, PDF reports stitched together by hand, slow turnaround.

This tutorial builds a script that takes a mark and a set of Nice classes, runs a multi-strategy search against 147M+ trademark records, scores each match by risk (including public company detection), enriches the high-risk hits with full trademark detail, and outputs a structured clearance report. The kind of report an attorney can actually use.

If you've already built a brand name availability checker, this is the next step. A name checker answers "is this taken?" A clearance report answers "what does the risk look like, and how confident should you be?"

What a Trademark Clearance Report Actually Contains

A name check returns a binary signal: conflicts exist or they don't. A clearance report returns a trademark risk assessment with supporting evidence. The difference matters because attorneys don't make filing decisions based on hit counts. They make decisions based on structured analysis of what those hits mean.

A useful clearance report includes:

  • Risk tiers. Matches grouped into high, medium, and low risk with clear criteria for each tier.
  • Match detail cards. For each conflict: the mark text, owner, registration status, Nice classes, the matching strategy that surfaced it, and a similarity score.
  • Public company flags. Whether the owner is publicly traded (ticker symbol available). Public companies have in-house IP counsel and enforce aggressively.
  • Jurisdiction coverage. Which offices were searched, and which weren't. An attorney needs to know the boundaries of the analysis.
  • Methodology. What strategies were used, what filters were applied, how risk scores were computed. Reproducibility builds trust.

Nice classes (the international system that divides goods and services into 45 categories) are central to clearance analysis. An identical mark in an unrelated class is usually not a threat. "DOVE" coexists as soap (Class 3) and chocolate (Class 30) because consumers don't confuse the two product categories. But related classes carry real risk: Class 9 (software) and Class 42 (SaaS) overlap in practice, even though they're technically separate.

Why automate this? Trademark clearance automation makes sense because the underlying data retrieval and scoring are mechanical. The legal judgment that follows is not. Automation handles the first part, freeing attorneys to focus on the second. For a broader look at the clearance process itself, see the trademark clearance search guide.

The Search Layer

The foundation of any clearance report is a thorough search. "Thorough" here means multiple matching strategies in a single pass: exact (character-for-character), phonetic (sound-alike via Double Metaphone), and fuzzy (edit-distance). Running all three catches the variations that trigger likelihood of confusion refusals, not just identical marks.

Here's the search call with all three strategies, filtered to active marks in the target classes:

curl -X POST https://api.signa.so/v1/trademarks/search \
  -H "Authorization: Bearer sig_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "mark": {
      "text": "NIMBUS",
      "strategies": ["exact", "phonetic", "fuzzy"]
    },
    "filters": {
      "nice_classes": [9, 42],
      "status": ["registered", "pending"]
    },
    "include": ["aggregations"]
  }'

The TypeScript SDK equivalent:

import Signa from '@signa-so/sdk';

const signa = new Signa({ apiKey: 'sig_live_...' });

const results = await signa.trademarks.search({
  mark: { text: 'NIMBUS', strategies: ['exact', 'phonetic', 'fuzzy'] },
  filters: {
    nice_classes: [9, 42],
    status: ['registered', 'pending'],
  },
  include: ['aggregations'],
});

The include: ['aggregations'] parameter is important for clearance work. Aggregations return a count breakdown by office, status, and Nice class, which forms the basis of the report's coverage section.

A single result looks like this:

{
  "id": "tm_7Kp3mNqR5vXw",
  "mark_text": "NIMBUSS",
  "office": "USPTO",
  "serial_number": "97482613",
  "filing_date": "2022-06-10",
  "registration_date": "2023-04-18",
  "status": "registered",
  "nice_classes": [9],
  "owner": {
    "id": "own_4Lx8nRtY3mKp",
    "name": "Nimbuss Cloud Inc.",
    "ticker": null
  },
  "similarity": {
    "strategy": "fuzzy",
    "score": 0.87
  }
}

Every search result includes a similarity object with two fields: strategy (which matching strategy surfaced this result) and score (a float from 0 to 1.0 where 1.0 is an exact match). These are the raw inputs for risk scoring. An exact match at 1.0 is a different threat level than a fuzzy match at 0.6.

The owner.ticker field is present when the owner is a publicly traded company. This is a recent addition to the API and matters for clearance: a public company with a registered trademark and in-house IP counsel will enforce it. A dormant LLC with an abandoned mark probably won't.

The aggregation response gives you the landscape:

{
  "aggregations": {
    "by_office": { "USPTO": 18, "EUIPO": 3, "CIPO": 2 },
    "by_status": { "registered": 15, "pending": 8 },
    "by_nice_class": { "9": 14, "42": 9 }
  }
}

For a deeper look at how the search endpoint works, including pagination and the prefix strategy, see how to automate trademark search. For a broader overview of what trademark records contain, see the developer's guide to trademark data.

Scoring Risk

Search results are ranked by similarity, but similarity isn't risk. A phonetic match in the same Nice class with an active registration owned by a Fortune 500 is a fundamentally different threat than a fuzzy match in an unrelated class owned by an abandoned LLC. The scoring function translates search results into risk tiers.

Four dimensions matter:

Similarity strength. The similarity.score (0 to 1.0) and similarity.strategy from each result. An exact match at 1.0 scores highest. A phonetic match (the mark sounds like yours) scores next, because sound similarity is a primary factor in confusion analysis. Fuzzy matches (close spelling) score lower.

Nice class proximity. Same class as your target is high risk. A related class (like Class 9 and Class 42, which overlap for software products) is medium risk. An unrelated class is low risk. This mapping requires a lookup table of class relationships. (Pro tip: you can use Signa's LLM classification endpoint to identify related classes dynamically. More on that below.)

Status weighting. A registered mark is a confirmed right. A pending application is a potential right. An abandoned or expired mark is generally not a threat, though recent abandonments can still be cited by examiners.

Public company factor. If owner.ticker is present, the owner is publicly traded. Public companies almost always have in-house IP teams or outside counsel on retainer. They file oppositions. They send cease-and-desist letters. A match against a public company's registered mark is materially riskier than the same match against a small business.

Here's the scoring function:

interface ScoredMatch {
  id: string;
  mark_text: string;
  office: string;
  status: string;
  nice_classes: number[];
  owner: { id: string; name: string; ticker: string | null };
  filing_date: string;
  registration_date?: string;
  similarity_strategy: string;
  similarity_score: number;
  risk_score: number;
  risk_tier: 'high' | 'medium' | 'low';
  is_public_company: boolean;
}

const STRATEGY_WEIGHTS: Record<string, number> = {
  exact: 1.0,
  phonetic: 0.85,
  fuzzy: 0.6,
  prefix: 0.4,
};

const STATUS_WEIGHTS: Record<string, number> = {
  registered: 1.0,
  pending: 0.8,
  opposed: 0.7,
  expired: 0.2,
  abandoned: 0.1,
  cancelled: 0.1,
};

const RELATED_CLASSES: Record<number, number[]> = {
  9: [42, 38, 35],
  42: [9, 35, 38],
  25: [18, 35],
  35: [9, 42, 25],
  41: [9, 42],
};

function scoreMatch(
  result: any,
  targetClasses: number[]
): ScoredMatch {
  const strategy: string = result.similarity.strategy;
  const simScore: number = result.similarity.score;
  const isPublic = result.owner?.ticker != null;

  // 1. Similarity strength: strategy weight x similarity score
  const strategyWeight = STRATEGY_WEIGHTS[strategy] ?? 0.3;
  const matchScore = strategyWeight * simScore;

  // 2. Nice class proximity
  const resultClasses: number[] = result.nice_classes;
  let classScore = 0.2;
  for (const tc of targetClasses) {
    if (resultClasses.includes(tc)) {
      classScore = 1.0;
      break;
    }
    const related = RELATED_CLASSES[tc] ?? [];
    if (resultClasses.some((rc: number) => related.includes(rc))) {
      classScore = Math.max(classScore, 0.6);
    }
  }

  // 3. Status weight
  const statusScore = STATUS_WEIGHTS[result.status] ?? 0.3;

  // 4. Public company boost
  const publicBoost = isPublic ? 0.15 : 0;

  // Composite: weighted sum, capped at 100
  const riskScore = Math.min(
    100,
    Math.round(
      (matchScore * 0.40 + classScore * 0.30 + statusScore * 0.15 + publicBoost) * 100
    )
  );

  let riskTier: 'high' | 'medium' | 'low';
  if (riskScore >= 70) riskTier = 'high';
  else if (riskScore >= 40) riskTier = 'medium';
  else riskTier = 'low';

  return {
    id: result.id,
    mark_text: result.mark_text,
    office: result.office,
    status: result.status,
    nice_classes: result.nice_classes,
    owner: result.owner,
    filing_date: result.filing_date,
    registration_date: result.registration_date,
    similarity_strategy: strategy,
    similarity_score: simScore,
    risk_score: riskScore,
    risk_tier: riskTier,
    is_public_company: isPublic,
  };
}

function scoreAndTier(results: any[], targetClasses: number[]): {
  high: ScoredMatch[];
  medium: ScoredMatch[];
  low: ScoredMatch[];
} {
  const scored = results.map((r) => scoreMatch(r, targetClasses));
  return {
    high: scored.filter((s) => s.risk_tier === 'high'),
    medium: scored.filter((s) => s.risk_tier === 'medium'),
    low: scored.filter((s) => s.risk_tier === 'low'),
  };
}

A few things to note about this scoring approach. The weights are tunable. The values above are reasonable starting points, but you might adjust them based on your risk tolerance or your attorney's preferences. The public company boost is a flat 0.15 (on a 0-1 scale) because enforcement likelihood is qualitatively different when the owner has an IP department. The class relationship map covers common overlaps but isn't comprehensive.

Important caveat: Risk scores are heuristic inputs for attorney review. They are not legal opinions and should not be treated as such. The DuPont factors that courts use to evaluate confusion involve 13 criteria, many of which (commercial impression, trade channel overlap, consumer sophistication) require human judgment. Consult a trademark attorney for legal guidance specific to your situation.

Example clearance report: risk tier distribution for NIMBUS in Classes 9 & 42

The hardcoded RELATED_CLASSES map above covers common overlaps, but it's not exhaustive. Signa's LLM classification endpoint can identify related classes dynamically based on your actual goods/services description:

const suggestion = await signa.classifications.suggest({
  description: 'cloud-based project management software for engineering teams',
});

// Returns ranked Nice class suggestions with confidence scores
// suggestion.classes: [
//   { class_number: 42, confidence: 'high', rationale: 'SaaS platforms fall under class 42' },
//   { class_number: 9, confidence: 'high', rationale: 'Downloadable software falls under class 9' },
//   { class_number: 35, confidence: 'medium', rationale: 'Business management tools may fall under class 35' }
// ]

const relatedClasses = suggestion.classes.map(c => c.class_number);

This costs 1 unit per request and uses Claude Haiku 4.5 under the hood. Instead of maintaining a static lookup table, you can call this once at the start of your clearance workflow with the applicant's goods/services description and use the returned classes for both the search filter and the risk scoring. The confidence levels (high, medium, low) map naturally to class proximity weights.

Enriching High-Risk Matches

Search results include summary-level data: mark text, status, Nice classes, owner name, and similarity score. For high-risk and medium-risk matches, you want the full record. The detail endpoint returns the complete trademark record with nested fields for status, classes, prosecution history, and owner data.

async function enrichMatches(
  matches: ScoredMatch[]
): Promise<(ScoredMatch & { detail: any })[]> {
  const enriched = [];

  for (const match of matches) {
    const detail = await signa.trademarks.retrieve(match.id);
    enriched.push({
      ...match,
      detail: {
        mark_type: detail.mark?.type,
        status_office_code: detail.status?.office_code,
        classes: detail.classes,
        prosecution_history: detail.prosecution_history,
        proceedings: detail.proceedings,
        attorneys: detail.attorneys,
        owner_entity_type: detail.owner?.entity_type,
        owner_jurisdiction: detail.owner?.jurisdiction,
        owner_address: detail.owner?.address,
        owner_ticker: detail.owner?.ticker,
        owner_lei: detail.owner?.lei,
        renewal_date: detail.renewal_date,
      },
    });
  }

  return enriched;
}

A few fields worth paying attention to in the enriched data:

  • prosecution_history is a timeline of events: filing, examination, publication, registration, renewals, and any opposition or cancellation actions. A mark with opposition proceedings in its history signals an owner who enforces actively.
  • classes is an array of objects with nice_class (the number) and description (the actual goods/services text). Two marks in Class 9 might cover completely different products. "Computer software for accounting" and "downloadable mobile games" are both Class 9 but rarely conflict. Comparing at the description level is where the real signal is.
  • proceedings lists any opposition or cancellation actions involving this mark. Multiple proceedings suggest an aggressive enforcement posture.
  • owner.ticker and owner.lei (Legal Entity Identifier) connect the owner to public company data. A ticker means in-house IP counsel, enforcement budgets, and a history of protecting the portfolio. The LEI links to the GLEIF corporate hierarchy, which helps identify parent/subsidiary relationships.
  • attorneys shows the law firms on record. Large IP boutiques (Fish & Richardson, Finnegan, Kilpatrick Townsend) handle enforcement for major portfolios. The firm name is a soft signal of how vigorously the mark will be defended.

For large result sets, consider batching enrichment calls or limiting to the top N matches per tier. The search endpoint is rate-limited at 200 requests per minute, and detail lookups at 1,000 per minute. For a typical trademark clearance report with 5-15 high-risk matches, you'll stay well within limits.

Generating the Trademark Clearance Report

With scored and enriched matches in hand, the final step is assembling them into a structured report. The report should be self-contained: someone reading it should understand what was searched, how results were scored, and what the findings mean, without needing to re-run the analysis.

interface ClearanceReport {
  meta: {
    mark: string;
    target_classes: number[];
    searched_at: string;
    offices_covered: string[];
    strategies: string[];
    total_results: number;
  };
  summary: {
    risk_level: 'high' | 'medium' | 'low';
    high_risk_count: number;
    medium_risk_count: number;
    low_risk_count: number;
    public_company_conflicts: number;
    recommendation: string;
  };
  tiers: {
    high: (ScoredMatch & { detail?: any })[];
    medium: (ScoredMatch & { detail?: any })[];
    low: ScoredMatch[];
  };
  methodology: {
    scoring_weights: {
      similarity_strength: number;
      class_proximity: number;
      status: number;
      public_company_boost: number;
    };
    tier_thresholds: { high: number; medium: number };
    strategies_used: string[];
    filters_applied: Record<string, any>;
  };
  coverage: {
    offices_searched: string[];
    classes_searched: number[];
    limitations: string[];
  };
}

function generateReport(
  mark: string,
  targetClasses: number[],
  searchResponse: any,
  tiered: { high: ScoredMatch[]; medium: ScoredMatch[]; low: ScoredMatch[] },
  enrichedHigh: (ScoredMatch & { detail: any })[],
  enrichedMedium: (ScoredMatch & { detail: any })[]
): ClearanceReport {
  const officesCovered = Object.keys(
    searchResponse.aggregations?.by_office ?? {}
  );

  const allEnriched = [...enrichedHigh, ...enrichedMedium];
  const publicConflicts = allEnriched.filter(m => m.is_public_company).length;

  let riskLevel: 'high' | 'medium' | 'low';
  if (enrichedHigh.length > 0) riskLevel = 'high';
  else if (enrichedMedium.length > 0) riskLevel = 'medium';
  else riskLevel = 'low';

  const recommendation =
    riskLevel === 'high'
      ? `${enrichedHigh.length} high-risk conflict(s) found${publicConflicts > 0 ? ` (${publicConflicts} owned by public companies)` : ''}. Attorney review strongly recommended before filing.`
      : riskLevel === 'medium'
        ? `No high-risk conflicts, but ${enrichedMedium.length} medium-risk match(es) warrant review.`
        : 'No significant conflicts identified. Proceed with standard filing diligence.';

  return {
    meta: {
      mark,
      target_classes: targetClasses,
      searched_at: new Date().toISOString(),
      offices_covered: officesCovered,
      strategies: ['exact', 'phonetic', 'fuzzy'],
      total_results: searchResponse.total,
    },
    summary: {
      risk_level: riskLevel,
      high_risk_count: enrichedHigh.length,
      medium_risk_count: enrichedMedium.length,
      low_risk_count: tiered.low.length,
      public_company_conflicts: publicConflicts,
      recommendation,
    },
    tiers: {
      high: enrichedHigh,
      medium: enrichedMedium,
      low: tiered.low,
    },
    methodology: {
      scoring_weights: {
        similarity_strength: 0.40,
        class_proximity: 0.30,
        status: 0.15,
        public_company_boost: 0.15,
      },
      tier_thresholds: { high: 70, medium: 40 },
      strategies_used: ['exact', 'phonetic', 'fuzzy'],
      filters_applied: {
        nice_classes: targetClasses,
        status: ['registered', 'pending'],
      },
    },
    coverage: {
      offices_searched: officesCovered,
      classes_searched: targetClasses,
      limitations: [
        'Common law (unregistered) marks are not covered.',
        'Design marks and logos are not evaluated.',
        'Foreign-language equivalents are not searched.',
        'Risk scores are heuristic and do not constitute legal advice.',
      ],
    },
  };
}

The public_company_conflicts count in the summary is a signal attorneys pay attention to. A clearance report with three high-risk conflicts, two of which are owned by Fortune 500 companies, reads very differently from three conflicts owned by dormant single-member LLCs.

The report outputs as JSON, which makes it straightforward to template into HTML or PDF for attorney review. The methodology and coverage sections are there because attorneys will ask. "How did you find these results?" and "What didn't you search?" are the first two questions any competent reviewer asks about a clearance report.

The limitations array is critical. Being explicit about what the analysis does not cover builds more trust than overpromising on coverage. Common law marks (unregistered trademarks that carry rights through use in commerce), design marks, and foreign-language equivalents are all outside the scope of a text-based search.

Adding LLM-assisted risk narrative

For high-risk matches, you can generate a human-readable risk narrative by passing the match data to an LLM. This turns a JSON score into the kind of plain-language summary an attorney expects in a clearance opinion:

async function generateRiskNarrative(
  proposedMark: string,
  match: ScoredMatch & { detail: any }
): Promise<string> {
  const prompt = `You are a trademark clearance analyst. Given the proposed mark "${proposedMark}" and the following existing registration, write a 2-3 sentence risk assessment:

Mark: ${match.mark_text}
Office: ${match.office}
Status: ${match.status}
Nice Classes: ${match.nice_classes.join(', ')}
Owner: ${match.owner.name}${match.is_public_company ? ' (publicly traded: ' + match.owner.ticker + ')' : ''}
Similarity: ${match.similarity_strategy} match at ${match.similarity_score}
Goods/Services: ${match.detail.classes?.map((c: any) => c.description).join('; ')}

Focus on: likelihood of confusion factors, the owner's enforcement posture, and whether the goods/services actually overlap.`;

  // Call your preferred LLM API here
  const narrative = await llm.complete(prompt);
  return narrative;
}

This is optional but adds significant value. The JSON report gives attorneys the data. The narrative gives them a starting point for analysis. Pair Signa's structured trademark data with an LLM's ability to synthesize, and you get something closer to what a junior associate produces in their first pass.

Putting It Together

Here's the full orchestrator function that ties the pipeline together:

async function runClearanceReport(
  mark: string,
  targetClasses: number[]
): Promise<ClearanceReport> {
  // 1. Search
  console.log(`Searching for "${mark}" in classes ${targetClasses.join(', ')}...`);
  const searchResponse = await signa.trademarks.search({
    mark: { text: mark, strategies: ['exact', 'phonetic', 'fuzzy'] },
    filters: {
      nice_classes: targetClasses,
      status: ['registered', 'pending'],
    },
    include: ['aggregations'],
    limit: 100,
  });

  console.log(`Found ${searchResponse.total} results.`);

  if (!searchResponse.data.length) {
    return generateReport(mark, targetClasses, searchResponse,
      { high: [], medium: [], low: [] }, [], []);
  }

  // 2. Score and tier
  const tiered = scoreAndTier(searchResponse.data, targetClasses);
  console.log(
    `Risk tiers: High: ${tiered.high.length}, Medium: ${tiered.medium.length}, Low: ${tiered.low.length}`
  );

  // 3. Enrich high and medium risk matches
  console.log('Enriching high-risk matches...');
  const enrichedHigh = await enrichMatches(tiered.high);

  console.log('Enriching medium-risk matches...');
  const enrichedMedium = await enrichMatches(tiered.medium);

  // 4. Generate report
  const report = generateReport(
    mark, targetClasses, searchResponse,
    tiered, enrichedHigh, enrichedMedium
  );

  return report;
}

// Run it
const report = await runClearanceReport('NIMBUS', [9, 42]);
console.log(JSON.stringify(report, null, 2));

Running this against "NIMBUS" in Classes 9 and 42 produces output like:

{
  "meta": {
    "mark": "NIMBUS",
    "target_classes": [9, 42],
    "searched_at": "2026-06-08T14:32:00.000Z",
    "offices_covered": ["USPTO", "EUIPO", "WIPO", "CIPO"],
    "strategies": ["exact", "phonetic", "fuzzy"],
    "total_results": 23
  },
  "summary": {
    "risk_level": "high",
    "high_risk_count": 3,
    "medium_risk_count": 5,
    "low_risk_count": 15,
    "public_company_conflicts": 1,
    "recommendation": "3 high-risk conflict(s) found (1 owned by public companies). Attorney review strongly recommended before filing."
  }
}

Three high-risk conflicts out of 23 total results, one owned by a publicly traded company. The summary tells you this mark needs attorney review before filing. The full report (with the tiers section expanded) gives the attorney exactly what they need: the specific conflicting marks, their owners, registration status, similarity scores, public company flags, and the scoring methodology that surfaced them.

What this doesn't cover

This report is a structured starting point, not a comprehensive legal clearance. Several categories of risk fall outside automated text-based search:

  • Common law marks. Unregistered trademarks that carry rights through use in commerce. These don't appear in any trademark register.
  • Design marks and logos. Visual similarity requires image analysis, not text matching.
  • Foreign-language equivalents. "SOL" (Spanish for "sun") could conflict with "SUN" under the doctrine of foreign equivalents, but text-based search won't catch it.
  • Legal judgment. The 13 DuPont factors include subjective criteria (commercial impression, consumer sophistication, trade channel overlap) that require human assessment.

These limitations are documented in the report's coverage.limitations field. An attorney reading the report knows exactly what was evaluated and what wasn't.

What's next

Signa's Clearance API (POST /v1/clearances) will wrap this entire workflow into a managed endpoint with expanded coverage, built-in risk scoring, and multi-jurisdiction deep analysis. Check the docs for availability.

Until then, the building blocks are live. The Search API covers 147M+ records across 200+ offices with sub-300ms response times. The detail endpoint provides full prosecution history, owner data (including public company identification via ticker and LEI), and proceedings. The LLM classification endpoint identifies related Nice classes dynamically. The scoring and report generation logic above turns those primitives into something an attorney can work with.

Get a free API key at signa.so and start with the API documentation.