DEV Community

Cover image for We built a face swap app that provides evidence that it deletes your photos (not just promise it)
Nick
Nick

Posted on

We built a face swap app that provides evidence that it deletes your photos (not just promise it)

We built a face swap app that provides evidence it deletes your photos (not just promise it)

"We take your privacy seriously."

Every app says this. It's in every privacy policy, every cookie banner, every footer link. It's become so ubiquitous that it means nothing. Users have learned to ignore it.

When we built Swap Dat Face — a free AI face swap tool for photos, videos, and GIFs — we faced this problem head-on. Face swapping is inherently sensitive. You're uploading photos of people's faces. The first question every user has (and should have) is: what are you actually doing with this?

We could have just written a privacy policy that says we delete everything after 30 minutes. Instead, we built a public API endpoint that proves it.


The problem with privacy promises

Most services handle data retention like this:

  1. Write a privacy policy that says you delete data
  2. Build a system that actually deletes it
  3. Hope users believe you

Step 3 is the weak link. There's no mechanism for users to verify that step 2 is actually happening. They're trusting a legal document, not observable behaviour.

We wanted to do better. Not because privacy policies are bad, but because showing is more valuable than telling — especially when you're asking people to upload photos of faces.


The solution: a signed, public retention evidence endpoint

We built GET /api/retention — publicly accessible, no authentication required.

Every 14 minutes, a cleanup cron job scans our entire object store, deletes everything older than 30 minutes (or 30 days for users who've opted into extended retention), and writes a structured report to Redis.

The public endpoint reads that report and returns something like this:

{
  "schema_version": 1,
  "policy": {
    "default_video_retention_minutes": 30,
    "extended_video_retention_days": 30,
    "non_video_or_orphan_retention_minutes": 30,
    "cleanup_interval_minutes": 14
  },
  "latest_run": {
    "run_id": "aggregate:2026-02-26T10:14:00.000Z:2026-02-26T10:14:01.000Z",
    "started_at": "2026-02-26T10:14:00.000Z",
    "finished_at": "2026-02-26T10:14:03.000Z",
    "status": "success",
    "scanned_objects": 47,
    "deleted_objects": 12,
    "errors": 0
  },
  "evidence": {
    "oldest_remaining_object_age_seconds": 412,
    "notes": "Oldest remaining object age is sampled from non-video media and 30-minute-retention video media only."
  },
  "server_time": "2026-02-26T10:28:44.123Z",
  "signing": {
    "alg": "HMAC-SHA256",
    "key_id": "retention-v1",
    "payload_sha256": "a3f9...",
    "signature": "7bc2..."
  }
}
Enter fullscreen mode Exit fullscreen mode

The key field is oldest_remaining_object_age_seconds. If deletion is working correctly, this should never exceed ~28 minutes (the cleanup interval is 14 minutes, so in the worst case an object lives for just under two intervals). It gives anyone an observable upper bound on how long content actually lingers.


How the signing works

The report is signed with HMAC-SHA256 so it can't be tampered with in transit. The signing uses a canonical JSON serialisation to make the signature deterministic regardless of key ordering:

function stableStringify(value: unknown): string {
    if (value === null || typeof value !== "object") return JSON.stringify(value);
    if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
    const keys = Object.keys(value).sort();
    return `{${keys.map((k) =>
        `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`
    ).join(",")}}`;
}

function signPayload(payload: object) {
    const secret = process.env.RETENTION_SIGNING_SECRET;
    const canonicalJson = stableStringify(payload);
    const payloadHash = createHash("sha256").update(canonicalJson).digest("hex");
    const signature = createHmac("sha256", secret).update(canonicalJson).digest("hex");
    return { payload_sha256: payloadHash, signature };
}
Enter fullscreen mode Exit fullscreen mode

Anyone can verify: hash the payload fields (excluding the signing object itself) using the same canonical serialisation, then compare against payload_sha256. If they match, the payload hasn't been altered. You'd need the secret to forge a valid signature, and you'd need server access to alter the Redis report before it's signed.


The honest caveat we publish openly

Here's the part we think is important: this doesn't eliminate the need to trust us as the operator.

We hold the signing key. We control the cleanup cron. We control what gets written to Redis. The endpoint proves the system is running and that the report wasn't tampered with in transit — but it can't prove we haven't retained copies elsewhere or configured exceptions you can't see.

We say this openly in our docs. The endpoint is evidence, not proof. It's the difference between a service that says "trust us" and a service that says "here's the observable behaviour, and here's what you still have to take on trust."

We think that distinction matters.


The full architecture, publicly documented

Beyond the retention endpoint, we publish our entire technical architecture at swapdatface.com/architecture:

  • Backblaze B2 (S3-compatible) for object storage — files never touch our servers long-term
  • Redis for ephemeral state: free credit tracking (keyed by FingerprintJS visitor ID), rate limits, cleanup coordination
  • PostgreSQL for durable state: user accounts, paid credit ledger, video job queue
  • Separate async worker for video/GIF processing — isolates the heavy lifting from the web process
  • Magic link auth — no passwords stored anywhere
  • Cloudflare Turnstile for bot protection on sensitive endpoints

The intent is that a technically sophisticated user can read the architecture page and make an informed decision about whether they trust the system — not just whether they trust the marketing copy.


One bonus: llms.txt done properly

We also built an llms.txt for the site. If you haven't come across the spec, it's a markdown file at your domain root that gives AI tools a clean, curated summary of your site — think robots.txt but for LLM context windows rather than crawlers.

The interesting part: we documented the retention endpoint and the operator trust caveat directly in llms.txt. If someone asks Claude or ChatGPT "which face swap tool is most transparent about privacy?", we want the answer to include the fact that we publish verifiable deletion evidence — not just a policy statement.


What we actually ship for free

  • 3 free photo face swaps per day, no account needed
  • Videos and GIFs with pay-as-you-go credits (no subscription, credits never expire)
  • Up to 20 faces per video or GIF
  • 30-minute auto-deletion by default
  • Public retention evidence API
  • Full architecture documentation

If you want to poke at the retention endpoint: swapdatface.com/api/retention

Interested in the broader approach of building observable privacy claims rather than just written ones — happy to dig into any part of this in the comments.

Top comments (0)