Building a White-Label AI Compliance Scanner

We shipped this for a privacy-compliance martech platform: an agency wanted to sell website privacy-compliance monitoring under its own brand, at scale, without paying a human analyst to read every privacy policy by hand. The answer was a multi-tenant Django platform where a headless browser extracts the policy and GPT-4 grades it, with **0 human review** in the scanning loop.

## The problem with selling compliance monitoring by hand

Privacy-compliance monitoring is a good subscription business on paper. Every site with a cookie banner is a prospect, regulations keep shifting, and a scan that flags a missing disclosure or an out-of-date consent mechanism is easy to sell as recurring value.

The trouble is the analyst bottleneck. If a human has to open every site, read the privacy policy, cross-reference it against a checklist, and write up findings, the business caps out at however many sites one analyst can review in a day. An agency cannot resell that under its own brand at any scale worth having, because the cost structure scales linearly with customer count and the output is inconsistent between analysts.

The agency's ask was specific: a product they could put their logo on, sell to their own client list, and run without adding headcount for every hundred new sites. That meant the grading step itself had to be automated, not just the dashboard around it.

## The architecture: scan, grade, brand, bill

The platform has four moving pieces that have to work together per tenant, not just per platform.

A headless browser (Selenium) visits the target site, follows the link to the privacy policy, and extracts the policy text along with the cookie-consent implementation it finds on the page. A discovery crawler runs alongside it to find pages the initial scan might miss, subdomains and forms that collect data without a visible disclosure being the usual culprits.

The extracted policy text goes to GPT-4, which grades it against a compliance rubric and returns a structured status plus a specific, actionable list of fixes, not a vague summary. The cookie-consent module checks the actual mechanism against what the policy claims it does. Scans run on a cron, so a customer's compliance status is a living number, not a one-time report.

Everything above sits behind a tenant boundary. Each agency gets its own subdomain, its own branding, its own billing relationship with its own customers, and its own re-scan schedule, all as first-class rows in the data model rather than presentation-layer tricks.


<rect x="0" y="0" width="900" height="620" fill="#0C0C14"></rect>
<text x="450" y="34" fill="#F4F2FF" font-family="monospace" font-size="16" text-anchor="middle">privacy-compliance martech platform: scan-to-bill flow</text>

<rect x="30" y="60" width="220" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"></rect>
<text x="140" y="85" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">Discovery crawler</text>
<text x="140" y="103" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">finds pages + subdomains</text>

<rect x="30" y="150" width="220" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"></rect>
<text x="140" y="175" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">Selenium headless browser</text>
<text x="140" y="193" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">extracts policy + consent code</text>

<line x1="140" y1="120" x2="140" y2="150" stroke="#8B7CF6" stroke-width="2"></line>

<rect x="340" y="150" width="220" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"></rect>
<text x="450" y="175" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">GPT-4 grading step</text>
<text x="450" y="193" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">status enum + fix list</text>

<line x1="250" y1="180" x2="340" y2="180" stroke="#8B7CF6" stroke-width="2"></line>

<rect x="340" y="60" width="220" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"></rect>
<text x="450" y="85" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">Cookie-consent module</text>
<text x="450" y="103" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">checks mechanism vs claim</text>

<line x1="450" y1="120" x2="450" y2="150" stroke="#8B7CF6" stroke-width="2"></line>

<rect x="650" y="150" width="220" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"></rect>
<text x="760" y="175" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">Django backend</text>
<text x="760" y="193" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">tenant-scoped scan record</text>

<line x1="560" y1="180" x2="650" y2="180" stroke="#8B7CF6" stroke-width="2"></line>

<rect x="30" y="260" width="220" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"></rect>
<text x="140" y="285" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">Cron scheduler</text>
<text x="140" y="303" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">triggers re-scans per tenant</text>

<line x1="140" y1="260" x2="140" y2="210" stroke="#8B7CF6" stroke-width="2"></line>
<line x1="140" y1="260" x2="450" y2="230" stroke="#8B7CF6" stroke-width="2"></line>

<rect x="340" y="260" width="220" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"></rect>
<text x="450" y="285" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">Automated outreach</text>
<text x="450" y="303" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">status change to customer</text>

<line x1="560" y1="290" x2="650" y2="210" stroke="#8B7CF6" stroke-width="2"></line>

<rect x="650" y="260" width="220" height="60" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"></rect>
<text x="760" y="285" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">Stripe subscription</text>
<text x="760" y="303" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">per-tenant billing plan</text>

<line x1="760" y1="210" x2="760" y2="260" stroke="#8B7CF6" stroke-width="2"></line>

<rect x="150" y="380" width="600" height="80" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"></rect>
<text x="450" y="410" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">Multi-tenant subdomain router</text>
<text x="450" y="430" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">agency-a.example.com -&gt; agency A brand + billing + customer list</text>
<text x="450" y="448" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">agency-b.example.com -&gt; agency B brand + billing + customer list</text>

<line x1="140" y1="320" x2="400" y2="380" stroke="#8B7CF6" stroke-width="2"></line>
<line x1="450" y1="320" x2="450" y2="380" stroke="#8B7CF6" stroke-width="2"></line>
<line x1="760" y1="320" x2="500" y2="380" stroke="#8B7CF6" stroke-width="2"></line>

<rect x="150" y="500" width="600" height="80" rx="6" fill="none" stroke="#8B7CF6" stroke-width="2"></rect>
<text x="450" y="530" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">Customer self-serve dashboard</text>
<text x="450" y="550" fill="#F4F2FF" font-family="monospace" font-size="13" text-anchor="middle">status + fixes, under the agency's own logo</text>

<line x1="450" y1="460" x2="450" y2="500" stroke="#8B7CF6" stroke-width="2"></line>


## The GPT-4 grading step

The hard problem is not calling an LLM. It is getting a consistent, structured answer out of it across thousands of policies written in wildly different styles, lengths, and legal conventions, and doing it without a human checking the output.

The grading prompt is built as a constrained task, not an open-ended question. The model receives the extracted policy text, the site's detected data-collection behaviors from the crawler, and a fixed rubric. It returns a status enum (something like `compliant`, `partial`, `non_compliant`) plus a list of specific fixes tied to what the rubric found missing, not free-text commentary that a downstream system would have to parse loosely.

A simplified illustration of that shape. The rubric, the prompt, and the tuning behind them are the client's product, so what follows is a generic pattern rather than what shipped:

```python
from enum import Enum
from pydantic import BaseModel
from openai import OpenAI

client = OpenAI()

class ComplianceStatus(str, Enum):
COMPLIANT = "compliant"
PARTIAL = "partial"
NON_COMPLIANT = "non_compliant"

class ComplianceFix(BaseModel):
issue: str
rule_reference: str
fix: str

class ComplianceGrade(BaseModel):
status: ComplianceStatus
fixes: list[ComplianceFix]
summary: str

GRADING_PROMPT = """
You are grading a website privacy policy against a fixed compliance rubric.
Return only findings supported by the policy text and detected site behavior
below. Do not infer intent. If a required disclosure is present, do not flag it.

RUBRIC:
{rubric}

DETECTED SITE BEHAVIOR (from crawler + cookie-consent scan):
{detected_behavior}

POLICY TEXT:
{policy_text}
"""

def grade_policy(policy_text: str, detected_behavior: str, rubric: str) -> ComplianceGrade:
response = client.beta.chat.completions.parse(
model="gpt-4",
messages=[
{"role": "system", "content": "You return structured compliance grades only."},
{"role": "user", "content": GRADING_PROMPT.format(
rubric=rubric,
detected_behavior=detected_behavior,
policy_text=policy_text,
)},
],
response_format=ComplianceGrade,
)
return response.choices[0].message.parsed
```

The structured output is what lets the platform run with zero human review. A status enum and a typed fix list slot straight into the tenant's dashboard, the outreach email, and the historical record used to show a customer their compliance trend over time. There is no free-text report for an analyst to interpret before a customer sees it.

## What makes it actually white-label, not re-skinned

A lot of "white-label" software is one app with a logo swap in the CSS. That works until an agency's customer emails support and the reply comes from the platform's own domain, or until two agencies' customer lists show up in the same admin view because tenancy was never modeled as data, only as branding.

Genuine white-label status is a data-model decision made on day one, not a feature added later. Three things have to be first-class:

**Tenant isolation.** Every scan, every customer record, every billing event belongs to an agency (tenant), and the application layer enforces that boundary on every query, not just on the parts of the UI that show a logo.

**Per-tenant billing.** Each agency runs its own Stripe-connected billing relationship with its own customers, at its own prices, on its own trial terms. The platform is not one subscription with agencies as a reseller layer bolted on top; each agency's revenue relationship is a real, separate object in the system.

**Per-tenant branding, resolved by subdomain.** The routing layer resolves an agency's brand assets, domain, and customer scope from the subdomain before anything else runs, so `agency-a.example.com` and `agency-b.example.com` are functionally separate storefronts sharing one engine.

A simplified version of that routing pattern:

```typescript
interface TenantContext {
tenantId: string;
brandName: string;
logoUrl: string;
primaryDomain: string;
billingPlan: "trial" | "active" | "past_due";
}

async function resolveTenant(hostname: string): Promise<TenantContext> {
const subdomain = hostname.split(".")[0];
const tenant = await db.tenant.findUnique({
where: { subdomain },
select: {
id: true,
brandName: true,
logoUrl: true,
primaryDomain: true,
billingPlan: true,
},
});

if (!tenant) {
throw new UnknownTenantError(hostname);
}

return {
tenantId: tenant.id,
brandName: tenant.brandName,
logoUrl: tenant.logoUrl,
primaryDomain: tenant.primaryDomain,
billingPlan: tenant.billingPlan,
};
}

// every scan, invoice, and outreach email query is scoped by tenantId,
// resolved once at the edge and threaded through the request context
```

Every downstream query, the scan record, the customer's dashboard data, the outreach email template, the Stripe subscription object, carries that `tenantId`. Nothing in the request path is allowed to reach across tenants. That is the difference between a resold product and a shared product with a coat of paint on it.

## The trial-to-paid loop, running without a human

The cron is the part that makes this a subscription business rather than a one-time audit tool. Once a customer's site is onboarded under an agency, the platform re-scans it on a schedule, compares the new grade to the last one, and triggers automated outreach when the status changes, a new fix appears, or a previous issue is resolved.

That loop is what turns a free trial into a paid subscription without an agency's staff doing anything. A prospect signs up under an agency's branded page, gets an initial scan and grade for free, sees specific fixes tied to their actual policy, and converts to paid to get ongoing monitoring and the automated re-scan cadence. The agency never touches a single scan. Docker packages the whole stack so an agency's deployment is repeatable and the platform team can stand up infrastructure without a bespoke setup each time. All of it ships into the client's own repository and cloud account, with no license-back and nothing depending on a service we run, which is the ownership posture described on our [security page](/security).

This is the outcome as it shipped: agencies onboard under their own logo and domain, customers self-serve, monitoring runs on a cron, and trial-to-paid conversion is built into the platform rather than added afterward as a manual sales step.

## Why this shape works for an agency's economics

An agency reselling compliance monitoring is really running an AI automation business layered on top of a platform it does not have to build itself. The agency's margin depends entirely on the marginal cost of adding the next customer staying near zero. A human-review workflow breaks that math immediately, every new customer is a fixed cost against a recurring revenue line. A GPT-4 grading step with a structured output and a cron-driven re-scan breaks that dependency: the marginal cost of the next customer is compute, not headcount.

This is also why the build order mattered. Tenant isolation, per-tenant billing, and subdomain branding went into the data model before the grading prompt was tuned, because retrofitting multi-tenancy onto a single-tenant app later means touching every query in the codebase. Building it as a resellable product from the schema up is what let the agency put its brand on day one rather than after a migration project. A build with that many parallel pieces, crawler, grading step, billing, tenancy, is why work like this runs as a [pod](/pods) with a lead and a bench rather than a single contractor working through it in sequence. For a broader look at where AI automation earns its keep in a business versus where it does not, see our breakdown of [what to automate first](/blog/ai-automation-services) and how [automation agencies](/blog/ai-automation-agencies) typically structure this kind of resale relationship.

## The short version

A resellable compliance scanner is not a scanning script with a logo. It is a data model where tenant isolation, billing, and branding are first-class objects, an extraction pipeline (Selenium) feeding a structured grading step (GPT-4) that returns an enum and a fix list instead of prose, and a cron loop that turns a free scan into a paid subscription without a human touching a single result. Get the tenancy right in the schema before tuning the prompt, and the platform scales by adding compute, not analysts.