Wybber Privacy Policy

Last updated: 2026-10-03

This policy describes what Wybber actually does with your data, based on the code as it exists today; every technical claim below is backed by the underlying code file/table (see the "Evidence" columns). This document does not substitute a lawyer's final legal review. The Turkish version of this policy is the primary text; this English version is meant to be identical in meaning.

Data controller: Wybber (contact: support@wybber.com). See the KVKK Disclosure Notice (Settings → Legal) for the controller's registered legal-entity details.


1. Summary — the architectural principle and its known exceptions

Wybber's design principle is "personal data never leaves the device; the server only ever receives an irreversible SHA-256 hash". Code review, however, surfaces known exceptions to this principle, and this policy does not hide them:

  1. At registration, gender, gender of interest, city, and age band are sent to and stored on the server in plain text (not hashed) — required for candidate filtering (backend/app/models/__init__.py, User table; backend/app/api/auth.py::RegisterRequest). "Gender of interest" can indicate who you want to date (and therefore, indirectly, sexual orientation) — see §9 and the Explicit Consent Notice. Registration also sends district and country to the server in plain text and stores them on the users table, but the matching engine never reads either field, and they are no longer returned via GET /auth/me either (S96) — both columns are marked as drop-candidates for a future removal migration.
  2. A location update now only sends your city to the server — raw latitude/longitude is no longer persisted at all. Previously, POST /api/location/update also wrote your real GPS coordinates into the location_snapshots table; that behaviour was removed (backend/app/api/location.py::update_location) because no code anywhere read those fields (matching only uses users.city) and the mobile app never called this endpoint in the first place. The endpoint may still accept lat/lng/accuracy fields, but no longer writes them anywhere. The location_snapshots table itself has not yet been dropped (see §3, §5) — that is left to a follow-up cleanup migration.
  3. Purchase receipts (App Store/Google Play) are sent to Apple/Google for verification — this is the platforms' own mandatory in-app purchase infrastructure.
  4. An irreversible hash identifying your device, and the date that device was first seen, is now sent to the server at every registration — see §1.1 for detail and what this means.
  5. You can now send your own province (required) and, optionally, your district to the server as an id number; an optional GPS shortcut exists, but your raw coordinate never reaches the server — see §1.2 for detail.

Outside of these exceptions: your profile (name, bio, photos), preference answers, chats, face-verification data, and match scoring stay on your device.

1.1 Device records and accounts on the same phone

Two separate facts, both true: a new account cannot see the matches and chats of an older account on the same phone — that is a structural consequence of how the app is built. Separately, chat content itself is never on the server in the first place — chats travel end-to-end encrypted directly device-to-device (see §2). These are independent statements: one describes what your new account cannot see, the other describes what the server never saw to begin with.

The first time you open the app and register, the server records an irreversible hash identifying your device (not your name, phone number, or advertising id) together with the date that device was first seen (day only, no hour/minute). The purpose is to make abuse harder: it is part of a mechanism intended to make it harder for the same device to claim more than one trial period, or the same invite code to be reused repeatedly. Previously, this was only sent when you entered an invite code or started a trial; it is now sent from the moment of registration, the first time you open the app — we are stating this change explicitly here.

Because this hash is recorded for every account opened on the same phone, if we query the server we can technically detect that two accounts came from the same phone. We say this plainly: this is not a technical impossibility. But we only use it to limit trial/invite-code abuse — we do not use it to merge your profile, matches, or chats with another account, and blocking one account is not linked to any other account. This is a deliberate choice: different people sharing the same phone (couples, roommates, family members, the new owner of a second-hand phone) is a real and common situation; assuming "same device = same person" would be wrong and could invisibly punish an innocent user who would never learn why.

This device record is not deleted when you delete your account — it is retained for 90 days from the last account registered on that device (not from your last use). Its purpose is to prevent getting a fresh trial by deleting and reinstalling. As an honest limit on the statement "I deleted my account, all my data is instantly and completely gone": our production database has regularly scheduled backups (short-lived local backups spanning a few days, and longer-lived remote backups spanning close to a month); your deletion request is applied to the production database immediately, but a backup copy taken before your deletion request could theoretically continue to exist until that backup is overwritten by its own normal cycle.

1.2 Province/district location — with optional GPS support

From Settings → "My Location" and "Search Location", you can set your own province (required) and, optionally, your district; you can also set the province/district you want to search for among match candidates (an entirely optional preference — leaving it blank means "no preference"). Both fields are sent to the server not as free text, but as an id number from a predefined, public province/district list.

GPS is an optional shortcut, never a requirement. If you tap "Use my location", the app asks for your device's location permission; if granted, your device reads your GPS coordinate and resolves it entirely on its own, against a province/district list bundled with the device (requiring no network connection), to a province/district name by finding the nearest match. This coordinate is never sent to the server and never stored anywhere — only the resolved province/district name is suggested on screen to pre-fill your selection; nothing is saved until you confirm it. We deliberately do not do this through Apple's or Google's own location-resolution service — those services would require sending your coordinate to that company; we do this resolution entirely on-device, without ever going through a third party. If location permission is denied, location services are off, it times out, or no confident match is found, manual selection always works the same way — GPS is a convenience, not a requirement.

Sharing your district is a separate, optional preference (default: off). Stating your province is enough for matching; if you also want your district to be usable for matching, you need to separately and explicitly turn this on — if you don't, only your province is shared. Even if you turn this on, if your district isn't populous enough (e.g., a small district), or if too few other users of your same age range and gender currently exist in that district, the system automatically falls back to province-level only and tells you so honestly — your preference isn't ignored, it's just that you won't be matched with a group small enough to single you out. This second check isn't static — which districts currently qualify as populous enough is recalculated every time, based on the actual number of users currently active in the app; nothing is precomputed or cached anywhere.

The matching engine uses this as one more criterion added alongside criteria like gender and age band, when both you and the other party have stated your own province/district and your mutual search criteria match. This is a genuinely new matching criterion that never existed before; the registration form's old free-text city/district fields (see item 1 above) are entirely separate from this and are still not read by matching.

These two fields (province/district id numbers) are also, like gender/age band above, deliberately sent unhashed — since the number of provinces/districts is fixed and public information (81 provinces, ~970 districts in Turkey), a hash here provides no real protection, only a false sense of security; the real protection comes from the population/live-user-count-based restrictions described above.

Scope note: The above principles and §2-12 below describe account holders ("users") who use Wybber as a dating app. People who apply to or are accepted into the Wybber Influencer Program are an entirely separate data-subject group, on a separate lawful basis — see §13.


2. Data that stays on your device and never reaches the server

DataWhere it livesEvidence
Profile name, bio, photosDevice (AsyncStorage), ProfileService.tsNo call site in the mobile codebase sends these fields to the backend (grep "/profiles/me" → 0 results); see §2.1 below
Preference questionnaire answers (relationship goal, smoking/alcohol, etc.)Device, PreferenceProfileServiceagents/agent-represented-matching.md
Human-to-human chat messagesDevice-to-device, end-to-end encrypted (E2EE) P2P channel; the server never sees the contentbackend/app/models/__init__.py::Conversation — only participant identifiers, timestamps; no message-content column
Face-verification image/embeddingEntirely on-device (Apple Vision / Google ML Kit)No call site was found that uploads a photo/embedding to the backend
Match score, preference weights, Wybber negotiation transcriptDeviceThe outcome sent to the backend is only no_match | match
Category answers you chose to share during the Wybber negotiationTo the peer's device, end-to-end encrypted — never to the serverSee §7 below

2.1 An unused-but-existing endpoint — transparency note

The backend has an endpoint that can accept and persist a profile name/bio/photo URLs. However, no screen in the mobile app calls this endpoint (zero call sites confirmed in the codebase) — profile data is, in practice, kept only on-device today. The purpose/future of this endpoint has not yet been clarified; unused endpoints that can accept personal data have previously turned into real security findings in this project and were removed. The same class of risk exists here — this endpoint is planned to be removed before launch, or explicitly documented as future-use.


3. Data sent to the server (backend), table by table

Table/endpointContentPurposeEvidence
usersDID (self-certifying public identifier — not secret), SHA-256 hash of the DID, plan, gender, gender of interest, city, age band (plain text, actually used by matching) plus district, country (plain text, stored but never read by matching — drop-candidate, S96), negotiation_consent_enabled flagRegistration, match-candidate filteringbackend/app/models/__init__.py::User
location_snapshotsThe coordinate columns (lat/lng) have been REMOVED from the database entirely (2026-09-26, migration 036) — there is no coordinate field left for a row to hold; the table remains only as a "a location update happened at this time" timestamp. The location-update endpoint structurally rejects a raw coordinate (extra="forbid"; a request carrying one is rejected with an error, not silently discarded)None (unused table)backend/app/api/location.py::LocationUpdateRequest, backend/app/models/__init__.py::LocationSnapshot
match_outcomesOne-sided match declaration between two hashed identitiesMutual-match detectionbackend/app/models/__init__.py::MatchOutcome
mutual_matchesConfirmed reciprocal match (two hashes + timestamps)Chat authorizationbackend/app/models/__init__.py::MutualMatch
nudge_logDirection-less pair hash (pair_key), the moment the most recent reminder notification for that pair was sent, and how many have been sent for that pair — no record of who nudged whom, no message content, no count of unanswered messages, and none of the conversation's own timestampsEnforcing the two anti-harassment caps on the "you have a conversation waiting for a reply" reminder (1 per pair per 24h, at most 3 over the life of the pair) — see §3.1backend/app/models/__init__.py::NudgeLog, backend/app/api/matching.py::submit_nudge
pair_cooldownsDirection-less pair hash + cooldown expiryAbuse/persistence preventionbackend/app/models/__init__.py::PairCooldown
push_tokensExpo push token + platformNotification deliverybackend/app/models/__init__.py::PushToken
payments, iap_transactionsPlan/product, amount, status, SHA-256 hash of the receipt (the raw receipt is never stored)Purchase verification, accountingbackend/app/models/__init__.py::Payment, ::IAPTransaction
abuse_reportsReporter/reported DID, category (harassment/scam/fake profile/spam/inappropriate content/other), evidence hash (no free-text description is collected)Moderationbackend/app/models/__init__.py::AbuseReport
Signaling (WebRTC offer/answer/ICE)Both parties' DIDs in raw form (not hashed) and the connection's purpose (negotiation/chat) — held in the server's process memory, not a persistent database table. The real-time path the app uses, and the only one now live (/signaling/ws), relays the SDP/ICE content directly between the two parties without persisting it anywhere. A separate REST endpoint family that used to persist raw SDP+ICE in a process-local dictionary (signaling_sessions) — POST /signaling/offer/POST /signaling/answer, GET+DELETE /signaling/session/{id} — which no client ever called, was removed on 2026-09-29 (commit 5412eb83): an earlier version of this policy described that family as live. See §3.2 for the full breakdown, retention, and what happens on account deletion.P2P connection setupbackend/app/api/signaling.py
TURN relay serverRelays media/data traffic when a direct connection can't be established; sees your IP address while relaying (inherent to how TURN works — it does not see message content, traffic is E2EE). This is not the only place your IP address passes through — the ICE candidates exchanged during signaling also carry it, see the row above and §3.2.ConnectivitySelf-hosted coturn
telemetry table / TelemetryServiceDevice id, session id, metric name/value, app/OS version, (optional) SHA-256 hash of your account (did_hash)Product analytics; did_hash is used only for account deletion (§6)backend/app/models/__init__.py (Telemetry) — see §5/§8, account-deletion coverage and honest DP disclosure
device_attributions, revenue_sharesSHA-256 hash of your (the user's) device identifier (see the note below for its source), influencer code, revenue share — carries neither your name, your DID, nor your account id; but for as long as your account exists these rows are reachable via the device hash on your users row, so the row is also your own pseudonymous personal data. We are not claiming "this is not personal data" — for why it stays in place on account deletion see §6 item 5Influencer attribution systembackend/app/models/__init__.py::DeviceAttribution, ::RevenueShare
trial_grantsSHA-256 hash of your device identifier, the reason your trial length was set (no-code/code), the trial length and dates, your account's internal database id, and — only for trials started via an influencer code — the internal id of the influencer who owns that code"One trial per device" anti-abuse record; freezes the reason (was an influencer code present or not) behind the 7- vs 14-day trial lengthbackend/app/models/__init__.py::TrialGrant — see the note below
Device record (registration only)An irreversible hash identifying your device + the date that device was first seen (day only)Making per-device trial/invite-code abuse harder — see §1.1backend/app/api/auth.py::RegisterRequest
Province/district location endpointsYour own province/district location (id number) and your search province/district criteria (id number, an optional preference)Added as one more criterion to match-candidate filtering — see §1.2backend/app/api/users.py::update_my_home_location, update_my_search_location, backend/app/core/location_privacy.py

What is never sent to the backend (code-verified): preference weights/thresholds, which categories were asked, the Wybber negotiation transcript, your photo preference/decision, the photo itself, or your match score/band.

For your raw profile we have to make a separate and weaker statement — and we are not hiding the difference: for the items above there is no endpoint on the server that could receive such data at all. For the raw profile (name, bio, age, photo URLs, interests) such an endpoint does exist and works — no app screen calls it today, so in practice no profile data reaches the server, but what prevents it is the app's behaviour, not a server-side constraint. That distinction, and the plan to remove the endpoint before launch, are described in the note at the end of §2. For contrast, the raw coordinate is the opposite case: there the server structurally rejects a request carrying a coordinate (see the location_snapshots row in the §3 table), so that sentence is not a promise but an enforced constraint.

Note: the device_attributions/revenue_shares row above describes the record that links you (the user) to an influencer without carrying your name or your DID — pseudonymous, not identity-free (see the row itself and §6 item 5). The influencer's own identity data (name, email, IBAN, tax id) lives in entirely separate tables and is never read from this one — see §13.

Note — source of the device-identifier hash: This hash is usually derived from your device's own operating-system identifier (IDFV/Android ID). In the rare case where the device cannot supply one (e.g., on iOS during the brief window after a restart before the device has been unlocked for the first time, or on certain modified/custom Android builds), the hash is instead derived from a random value the app itself generates. We also keep, per device, an indicator of which of the two it came from — but this record does not always exist: for hashes cached before this distinction started being tracked, we genuinely do not know the source, and we say so plainly.

Note — trial_grants and the influencer link: for a 14-day trial started via an influencer code, the trial_grants row carries both your account and the influencer who owns that code on the same row. This is necessary, not incidental: the server cannot decide between a 7-day and a 14-day trial without knowing whether the device came from an influencer's code. This link only ever exists server-side, via an internal database query; it is never shown in any in-app screen (matching, profile, settings) to you or to any counterpart, and the influencer themselves can never see you (see §13.1, "Not collected"). For whether this link survives account deletion, see §6.

3.1 The "you have a conversation waiting for a reply" reminder — the one place conversation state leaves the device

Your chat messages are end-to-end encrypted and never reach the server (see §2). The direct consequence is this: the server cannot know on its own that a conversation has gone unanswered. For this reminder to work at all, the device that sent the message sends the server a signal (carrying the other party's SHA-256 hash), and the server sends the other party a fixed-text notification.

We say this plainly: this endpoint teaches the server something it never knew before — *"there is an unanswered conversation between these two hashes."* Every identifier involved is still an irreversible SHA-256 hash, so the "the server only ever receives a hash" principle is not technically violated; but your conversation's state leaves your device for the first time. That is a new metadata surface, not a re-use of an existing data flow, which is why it gets its own section.

How deliberately narrow that surface is kept:

Today's state — an honest measurement: the previous version of this policy said that the endpoint was live on the server but that no code path in the app called it, and promised that this paragraph would be updated once a sender shipped. That day has arrived: the sending side is now live in the app, so this signal really is being sent and rows really are being written to this table. Everything above remains true as written. Here is when, and under which rules, your device sends this signal, as measured:

3.2 How signaling (WebRTC) data is held server-side

We detail the "Signaling" row from the §3 table above here.

The path in use, and the only one now live — the /signaling/ws WebSocket. Your app sends the SDP offer/answer and ICE candidates over this live connection; the server relays them directly to the other party's open socket and never writes that content (SDP/ICE) anywhere persistent. To make the connection possible at all, the server still keeps a few small pieces of information in process memory (RAM — not a database table): which raw DID currently has an open socket (active_connections), the purpose the two DIDs agreed on for this connection (negotiation/chat, for at most 10 minutes, backend/app/api/signaling.py::_CONNECTION_PURPOSE_TTL_SECONDS), and request counters used to prevent abuse. All of these are keyed by raw DID — not a hashed identity. ICE candidates inherently carry an IP address as part of how connection setup works; on this path the IP is never written anywhere persistent, but it does pass through the server process at the moment of relay — so the TURN relay server is not the only component your IP address passes through (compare: the TURN row in the §3 table, §4).

CHANGE — 2026-09-29 (commit 5412eb83): the separate REST endpoint family this section previously described has been removed. That earlier version described POST /signaling/offer / POST /signaling/answer, together with GET/DELETE /signaling/session/{id}, as live on the server though no screen in the app called them: if called, they persisted both parties' raw DIDs, the raw SDP text, and raw ICE candidates carrying an IP address in a separate process-local dictionary (signaling_sessions), for as long as the server process kept running — the dictionary was cleaned up only opportunistically, dropping entries older than 1 hour once it exceeded 10,000 entries. That family — the four endpoints, the signaling_sessions dictionary itself, two cleanup functions, and the SignalingOffer/SignalingAnswer/SignalingResponse models — has been removed entirely (backend/tests/test_s1770_signaling_offer_family_removed.py guards against it coming back): no client code (mobile, web, admin panel) ever called it, the data it stored was the densest raw-PII surface in this file, and GET /signaling/session/{id} was a 404-vs-403 existence oracle for a non-member. We record this honestly rather than erasing it quietly: this endpoint family was disclosed to users as live in this policy only hours earlier — the same principle from §2.1 applies here: rather than deleting a removed endpoint's history without a trace, we record what changed and when. What the two paragraphs above used to describe — raw DID+SDP+IP persisted in process memory — is now no longer possible; the rest of this subsection describes only the behaviour of the still-live /signaling/ws path.

Retention — as measured, no unconditional TTL:

When you delete your account: if your DID has an open signaling socket, it is closed within the same request (backend/app/api/signaling.py::disconnect_did), and the entries for your DID in the connection-purpose cache and the request counters are scanned and removed, also within the same request (backend/app/api/signaling.py::purge_in_memory_state_for_did) — both are called from the account-deletion flow (see §6). These steps are plain Python with no database operation; if either fails, it does not stop the rest of your account data from being deleted — a separate, independent safety net.


4. Third parties


5. Retention periods

DataPeriod / status
match_outcomes (unreciprocated)Auto-deleted after 30 days
mutual_matchesDeleted after 90 days of activity (chat) inactivity
nudge_log (§3.1)Deleted 90 days after the most recent reminder notification sent for that pair. In addition: however the mutual match it belongs to ends (unmatch, block, account deletion, or mutual_matches's own 90-day period above), that pair's row is deleted too — this record cannot outlive the match it belongs to. Account deletion removes these rows immediately, within the same request (see §6); and even if that immediate removal failed for a technical reason, your mutual match is deleted in the same request, so the row is removed by that same "cannot outlive the match" rule.
pair_cooldownsno_match: 30 days, incomplete: 7 days, max 3 retries per 90 days
location_snapshotsAny existing rows are deleted, along with the rest of your account data, on account deletion (see §6). No separate cleanup was needed for raw coordinates that older rows might have held: removing the coordinate columns from the database (migration 036) destroyed every old value in them along with the columns — there is no coordinate left to clean up.
Signaling in-memory structures (§3.2)Not a database table — server process memory. The signaling_sessions dictionary that used to hold raw SDP+ICE persistently, and the REST endpoint family that fed it, were removed on 2026-09-29 (commit 5412eb83) — that dictionary no longer exists. For what remains (the connection-purpose cache, the open-socket record, request counters), there is no unconditional TTL: the connection-purpose cache is treated as expired after 10 minutes but is actually only removed on the next lookup; the open-socket record is kept only until that socket closes. On account deletion, all entries for your DID are removed, and any open socket of yours is closed, within the same request (see §3.2, §6).
telemetry tableNo automatic retention period (TTL) is defined yet. This table is now covered by the account-deletion flow (§6) — rows may carry an optional did_hash (SHA-256 hash of your account) field; rows carrying it are deleted when you delete your account. Rows that do not carry it (written before 2026-09-19, or by a client that still omits the field) cannot be linked to any account and are therefore not deleted — an honest limitation, not a hidden gap (see §8).
payments, iap_transactions, abuse_reportsNot deleted on account deletion; the DID link is irreversibly anonymized (see §6). Retained indefinitely for financial/moderation purposes.
trial_grantsRetained indefinitely — the record is deliberately a permanent anti-abuse history. On account deletion the row itself is not deleted; only its identity link to your account and (if any) the influencer link is severed (see §6).
Device record (§1.1, §3)Not affected by account deletion — retained for 90 days from the last account registered on that device, not from last use (see §6).
Your province/district location (your own location + your search criteria, §1.2)Kept as a single row, overwritten — no separate location history is ever kept (each update replaces the previous one). Deleted along with the rest of your account data on account deletion (see §6).
device_attributions (§3)No deletion period is defined — it is a device-scoped attribution/anti-abuse record. Account deletion does not affect this row (see §6 item 5).
revenue_shares (§3)10 years under Turkey's Tax Procedure Law (VUK) Art. 253 and Commercial Code (TTK) Art. 82 — a legal retention obligation; §13.4 is the single source for the period. Account deletion does not affect this row, and the row falls outside the scope of a deletion request (see §6 item 5).
apple_notification_events, google_notification_events (§6 item 5)No deletion period is defined — duplicate-processing prevention plus a billing audit trail; they carry no account column at all. Account deletion does not affect these rows (see §6 item 5).
account_suspensions (§6 item 5, §11)No deletion period is defined — it is the audit trail of an administrative account suspension/reinstatement, and the only evidence that §11's "accounts are closed" promise was applied. Account deletion does not affect this row (see §6 item 5).
Everything else account-relatedDeleted immediately and permanently on account-deletion request (see §6)

6. Account deletion and data portability

You can delete your account from the Settings screen. This calls the account-deletion endpoint, which performs the following, atomically, in a single request:

  1. Deleted outright: your account record (including your own province/district location and your search criteria, §1.2), profile, blocks, push token, your location-update timestamps (location_snapshots — these rows no longer hold any coordinate, see §3), location preference, usage ledger (usage_ledger), chat metadata (conversations), one-sided match declarations (match_outcomes), confirmed mutual matches (mutual_matches), retry-cooldown records (pair_cooldowns), the reminder-notification records of pairs you are a party to (nudge_log, §3.1), telemetry records linked to your account (telemetry — only rows carrying a did_hash matching your account, see §5).
  2. Retained, but with the identity link irreversibly severed: your payment/purchase records (financial audit trail), any abuse reports filed by or against you (moderation-history integrity), and your trial anti-abuse record (trial_grants) — your DID is converted into an irreversible pseudonym; the record can no longer be linked back to you. The trial_grants row itself is not deleted (deleting it would free up your device's trial slot and let a new account claim a fresh trial); instead, your account identity and any influencer-code link recorded on that row are irreversibly cleared — only the hash of your device identifier, the indicator of that hash's source (where known), and the trial's dates/length remain, as an anti-abuse trace.
  3. Revoked: the session token (JWT) used for this request is blacklisted immediately; any open signaling connection you had is closed.
  4. Out of scope, a separate record: your device record (§1.1) is not part of this action — it never carried an account identity in the first place, so the idea of "severing the identity link" does not apply to it. It continues to be retained until its own period elapses (90 days from the last account registered on that device) — a deliberate barrier against resetting your trial by deleting and reinstalling.
  5. Out of scope, four further groups of records that are never touched: account deletion does not touch the rows below at all — it neither deletes them nor severs an identity link, because none of them has an account-identity column to sever (account_suspensions is the one exception to that shape, explained in its own bullet below: there, did_sha256 is the row's own account reference rather than a separate link column, so there is nothing to sever without destroying the row's purpose). We name them here because this is where a user making a deletion request asks that question:

- device_attributions — the hash of your device identifier, which influencer code was used, the platform, and the install date. It stays for exactly the same reason as the device record in item 4: deleting the row would free up the fact that "this device has already redeemed a referral code", turning account deletion into an attribution exploit. No deletion period is defined for this row. - revenue_shares — the hash of your device identifier, the amount of the purchase, and the commission an influencer earned from that purchase. The reason it is not deleted is a legal retention obligation: 10 years under Turkey's Tax Procedure Law (VUK) Art. 253 and Commercial Code (TTK) Art. 82 (§13.4 is the single source for the period). During that period the row cannot be fully deleted and it falls outside the scope of any "delete my account/data" request — the same commitment §13.4 already makes in writing to influencers. Because the same row also carries the commercial data of a third party (the influencer who earned the commission), it is likewise excluded from your data copy (the export below); that second reason has no clock on it — when the 10 years lapse, the row still does not become disclosable to you. We say this honestly: the row is also your own pseudonymous personal data — we are *not* claiming "this is not personal data". Its retention and non-disclosure are justified by the legal retention obligation and the third party's rights, not by any claim that it falls outside personal data (see the same row in the §3 table). - apple_notification_events / google_notification_events — receipts recording that an Apple/Google subscription notification was processed. They carry only the store provider's own notification and transaction identifiers; there is no account column at all. They exist to prevent the same notification being processed twice (idempotency) and as a billing audit trail. Their only possible link to you runs through the payment/purchase record whose identity link is severed in item 2. No deletion period is defined for these rows either. - account_suspensions — the audit record of an administrative suspension or reinstatement of an account: the SHA-256 hash of your account (did_sha256 — this is the row's own account reference, not a separate link column), the action taken (suspend/reinstate), a reason code chosen from a closed list (e.g. "found to be under 18" — see §11 — or "abuse confirmed"), the internal handle of the Wybber operator who performed the action (actor_label), and when it happened. Why it is not deleted: this record is the only evidence that §11's promise ("accounts found to belong to someone under 18 are closed") — and enforcement sanctions generally — were actually applied. Letting a suspended account erase this record by deleting itself would remove the auditability of a sanction that was actually applied and make it look as though it never happened; this is the same "account deletion cannot be used to undo an already-applied safety/accounting measure" principle already stated above for abuse_reports (item 2) and the other rows in this same item 5. We say this honestly: the actor_label field cannot contain an email address, whitespace, or free text, but it cannot exclude a handle derived from an operator's personal name (e.g. mehmet.yilmaz) — so we do not describe this row as "anonymous": it may be pseudonymous, and when it is, it is personal data belonging to both you and the Wybber operator who performed the action. No deletion period is defined for this row (see §5). Legal basis: KVKK Art. 5/2-f (data controller's legitimate interest) and GDPR Art. 6(1)(f) (legitimate interest); its continued retention against an account-deletion request falls under KVKK Art. 7 and GDPR Art. 17(3)(e) (the establishment, exercise, or defence of a legal claim) — protecting this record against the possibility of a dispute over, or an appeal against, the suspension decision is a legitimate interest.

The same honest limit, for device_attributions/revenue_shares in items 4 and 5: these rows carry the hash of your device identifier rather than your account identity. If you create a new account on the same physical device, the device hash your new account records may be the same value these rows carry — the device hash is deliberately not kept unique across accounts, so that a second legitimate account on the same device remains possible. So these rows lose their link to your *account*, but they do not lose their link to your *device*; anti-abuse and accounting purposes require exactly that. We do not claim "these rows can no longer be linked to anyone".

account_suspensions is OUTSIDE this limit — we say the difference plainly: unlike the two rows above, this record does not carry a device hash at all; it carries the hash of your account identity (did_sha256) directly, and that hash is not transformed in any way when your account is deleted — unlike your payment/abuse records in item 2, the hash on this row is not converted into a pseudonym; it stays as it is. We have to state this more precisely, because an earlier version of this paragraph got it wrong: while your account exists, this hash is not unique to this row — did_sha256 is your account's primary pseudonym and it also appears, with the same value, on several other tables (including your feedback, your one-sided match declarations, your mutual matches, telemetry, your usage ledger, and age-verification risk signals). On account deletion, every one of those is either permanently deleted or converted into a separate, irreversible pseudonym (as in item 2) — which is why, after deletion, this row is the only one left carrying that exact hash. But that does not mean the hash is "used nowhere else in the system" while your account is alive — it is the opposite. And the scenario where the same DID resolves to a new account is far less exotic than "unlikely" suggests: your DID is deterministically derived from your BIP39 recovery phrase, so restoring your account from the same recovery words reproduces the same DID, and therefore the same did_sha256, and this old record would again resolve to that restored account. That is not an edge case — it is the designed outcome of the app's own recovery-phrase restore feature. We also say, honestly, what that match does not do: it does not automatically re-suspend the restored account — the only code path that writes this table runs solely on an administrative action, and the only path that reads it is admin-gated; nothing sets an account's active status by consulting this record.

And this limit also narrows item 2 — we have to say so plainly: a revenue_shares row does not merely keep your device hash; it also points at the corresponding purchase record (the row holds both a link to that purchase's internal database id and the transaction number issued by the store). Item 2 says the identity link on your payment/purchase records is severed, and that is true: that record's own account identity is converted into an irreversible pseudonym and the device hash on it is cleared as well. But for a purchase that produced a commission, that same purchase record remains reachable indirectly, via the revenue_shares row: device hash → revenue-share row → purchase record. At the end of that two-step path, details of the purchase (for example which product was bought) can become visible again.

We keep the distinction exactly as it is, because these are not the same thing: the record surfaced at the end of that path is not account-linkable — it does not name you or your account, because the account identity on it really has been irreversibly transformed. It does remain device-linkable: the path can be re-established through an account created later on the same physical device. The scope is limited too — this path exists only for purchases attributed to an influencer code, i.e. those that produced a commission; a purchase that produced no commission has no revenue_shares row and therefore no such path.

Why we describe this rather than presenting it as a defect to be fixed: the only "fix" would be to delete the revenue_shares row or sever the links inside it — and doing that would breach the legal retention obligation written in §13.4 (VUK Art. 253 / TTK Art. 82). So the choice here is not between "close the leak" and "obey the law"; it is between telling you about a limit created by a record we are legally required to keep, and not telling you. We are telling you.

What happens on your phone. Everything above concerns records on our server. Separately, the app removes data from your phone — and it does so only after the server has confirmed the deletion (or confirmed that the account was already gone). If the request cannot reach the server or is rejected, nothing on your phone is touched and your account stays exactly as it was.

*Removed from your phone:*

*Deliberately kept on your phone:* the on-device AI model files — the base model the app downloads (the same for every user), the approved adapters that ship with the app (the same for every user), the speed-measurement results the device-performance screen writes (timings and memory figures for this phone, plus the model's output on a fixed built-in sample text), and the database that catalogues those model files. None of these contain your profile, messages or photos. They are exempt from deletion only because they hold nothing derived from you; if that ever stops being true, the exemption ends.

*What this does not mean — the limits, stated plainly:*

  1. This is ordinary deletion, not overwriting. The app removes files and deletes keychain items; it does not first write over the contents of the files. Storage blocks can physically remain on the phone until the phone reuses them, so we cannot rule out specialist forensic recovery from a phone that is in someone else's hands. We make no claim beyond what is described above.
  2. iPhone: one key cannot be removed by deleting the app. The app's local encryption key lives in the system keychain. The app deletes it and then checks that it is gone; if it is still there, the app tells you. But on iPhone, deleting an app does not remove keychain items, and no app code runs while an app is being deleted — so deleting and reinstalling Wybber does not remove that key. If the app tells you it could not remove it, the only reliable remedy on iPhone is to erase the iPhone itself (Settings › General › Transfer or Reset iPhone › Erase All Content and Settings). On Android, deleting the app clears the app's private storage and its keystore entry together. The key only opens the encrypted data stored on this phone, so once that data is removed it has nothing left to open there.
  3. Copies we cannot reach are not deleted: device backups made before the deletion (iCloud, your computer, Google backup); the original photos in your photo library; anything you saved or shared outside the app, such as a data export you stored in Files or sent to another app; and content that was already delivered, end-to-end encrypted, to another person's phone — we cannot recall it.
  4. If a step fails, the app says so. You are told that your account was deleted but some data could not be removed from the phone, and you can retry. We do not report a clean deletion when we know part of it failed.
  5. This section is written from the app's source code and from the source code of the libraries it uses to create these files. A file-level inspection of a real phone after deletion has not yet been carried out; we do not claim that it has.

The same removal from the phone also runs when you restore a *different* identity onto a phone with a recovery phrase: the previous account's data is then removed from that phone only — its account on the server is not deleted — and if part of that removal fails, the person now holding the phone is told that data from the previous account may still be on it.

This action is irreversible.

Data portability (KVKK Art. 11 / GDPR Art. 20): Settings → Privacy → "My data" lets you download, end-to-end, a machine-readable (JSON) copy of your account data. The export bundles two parts into one file:

  1. The meta-only server-side copy (JWT-authenticated, rate limited to 1 request/hour per DID): your own users row (DID, plan, gender/gender-of-interest/city/age-band, negotiation_consent_enabled, pref_version, your own province/district location and your search criteria, §1.2), your push tokens, your usage ledger, your chat metadata (timestamps and the other party's hashed identity only), your blocks, your one-sided match declarations, your mutual matches, your retry-cooldown records, your reminder-notification records (nudge_log, §3.1 — the direction-less pair hash, the moment of the last notification, and the count), your payment/purchase records, your location data, and the abuse reports you filed (abuse_reports; abuse_reports_filed_by_me in the export — the hashed identity of the reported person, category, evidence hash, status and timestamp). Peer identifiers in this export are also always hashed, never a raw DID. Reports filed about you are NOT included in the export: the abuse_reports table does contain real rows (written by POST /community-safety/report) and some of them may concern you, but a report about you is the reporter's data — handing it to you would risk identifying the reporter to the person they reported, especially in harassment reports (GDPR Art. 15(4): the right of access must not adversely affect the rights and freedoms of others). This is a deliberate limit on your access right and we want you to know that something is being withheld. For the reports you filed, the username of the operator who reviewed the report and the action taken against the reported person (third parties' data) are also not exported.
  2. The on-device copy — the same export file also includes data the server never sees: your profile, your preference profile, your consent flags, your list of blocked/muted peers, and current chat session metadata (peer identity, status, timestamps, message/unread counts). Message content itself is never exported — it is never persisted to disk in the first place.

If the server request fails (network error, rate limit, or an expired session), the entire export is reported as failed — it never silently ships only the device half under a misleading "your data is ready" success message.


7. Wybber negotiation (optional feature)

The "Wybber negotiation" toggle in Settings → My Wybber is off by default and can be turned off at any time (doing so immediately cancels any in-progress session). While on:

This description is written to match, in meaning, the current toggle description in the app's Settings screen. This feature used to be called "night agent negotiation"; it now runs during the day, gated by a daily quota.


8. Telemetry — the honest state

The app collects general usage telemetry (crash/error, feature-usage counters). The architectural principle states that "all telemetry is protected with Differential Privacy (DP, ε≤1.0)" — but this claim is currently NOT TRUE for the general telemetry pipeline: it adds no noise, carries millisecond-precision timestamps and a device id, and sends signed batches. As a result, event-level telemetry specific to the Wybber-negotiation feature is currently entirely disabled (unconditionally off) — no negotiation event reaches the server until a real DP-protected pipeline is built. General telemetry (§3, §5) still does not have this DP protection; however, rows linked to your account (carrying a did_hash) are now covered by account deletion (see §5, §6) — rows that do not carry that field (written before 2026-09-19, or by a client that still omits it) remain out of scope because they cannot be linked to any account. This policy will be updated when that changes.


9. Special category (sensitive) data


10. Security


11. Children's privacy

Wybber is not directed at users under 18. The youngest age-band option in the registration flow is 18-22. Accounts found to belong to a person under 18 are closed.

The administrative audit record of that closure (which account, when, on what grounds, and the internal handle of the Wybber operator who closed it) continues to be retained even if you later delete your account — see §5 and §6 item 5 for its content, why it is retained, and its legal basis. A closed account no longer appears as a match candidate, cannot establish a new P2P connection, and cannot access chat history; however, the moment this restriction takes effect can lag until the currently valid session token expires (some endpoints re-check account status on every request, where the restriction applies immediately; endpoints that rely on the token alone do not enforce it until the token expires) — the upper bound of that lag varies by deployment and is not stated here as a single number (do not confuse this with the immediate token blacklisting on account deletion described in §6 item 3 — that is a separate mechanism, and the same guarantee does not yet exist for account closure/suspension; this remains an open technical finding).


12. Contact and changes

For questions about this policy, contact support@wybber.com. Material changes will be announced via an in-app notice.


13. Influencer / business-partner data (a separate data-subject group)

This section covers only people who apply to or are accepted into the Wybber Influencer Program — it does not affect dating-app users who never use that program. §1-12 above describe the data of account holders ("users") who use Wybber as a dating app; this section covers a separate data-subject group (influencer/partner applicants) and a separate legal relationship (a business partnership — not consent).

The two groups' data is not directly merged — but one link exists, described honestly below. Code review confirmed that none of the influencer's own tables (influencers, influencer_applications, influencer_payout_details, influencer_magic_links) carry a user-account (DID) foreign key into them — this is still true. However, for accounts that start a 14-day trial via an influencer code, the trial_grants table described in §3 is a third, separate table that records a link between your account and the influencer who owns that code. This link is deliberate and technically necessary: the server cannot decide whether to grant you a 7-day or a 14-day trial without knowing whether your device is tied to an influencer's code — which, by nature, references that influencer. The link's reach is limited: (1) it only ever exists server-side, via an internal database query — it never appears in any API response (matching, profile, config) shown to you or a counterpart; (2) the influencer's own portal can never see you (the §13.1 "Not collected" list is unchanged); (3) the link is severed when you delete your account — see §6. The account-deletion endpoint (§6, DELETE /api/users/me) still never touches the four influencer tables listed above; it only clears the account/influencer link inside trial_grants (see docs/security/2026-09-24-influencer-web-review.md S184, docs/security/2026-09-26-trial-review.md S254).

13.1 What is collected

DataWhen/where it's collectedEvidence
Name, emailProgram application (POST /influencer/apply) or account creation by Wybber staffbackend/app/models/__init__.py::InfluencerApplication, Influencer
Social-media handle(s) (free text, e.g. "@handle (Instagram)")Application formInfluencerApplication.platform_handles
Self-reported audience/follower sizeApplication formInfluencerApplication.audience_size
Application message (free text, up to 4000 characters, optional)Application formInfluencerApplication.message
IBAN, account-holder name, tax id, countryOnce accepted, via your own payout-details form (PUT /influencer/me/payout-details)InfluencerPayoutDetails
Revenue-share records (which purchase, how much, currency, paid/pending status)Automatic, only after a real, verified Apple/Google purchaseRevenueShare
Login link (magic link) — a short-lived, single-use authentication tokenYour login request (POST /influencer/auth/request-link)InfluencerMagicLink
Secret dashboard access key (legacy login method) — the database stores only its SHA-256 digest, never the key itselfGenerated once when your account is created; the key itself appears exactly once, in the account-creation response, and is never written anywhereInfluencer.dashboard_token_hash

Not collected: dating-app users' profiles, chats, location, preference answers, or identity — the influencer portal never has access to these; your own stats screen only ever shows aggregate download/earnings counts.

How the dashboard access key is stored (2026-10-03, S3320): this key is no longer held in the database in plaintext — only its SHA-256 digest is stored, and at login the digest of the key you present is compared against that record in constant time. The practical consequence is in your favour: reading your database row, or obtaining a backup of the database, no longer yields a usable login credential — before this change it did. The emptied plaintext column (influencers.dashboard_token) has not yet been removed from the schema; the migration (052) converted every row's value to its digest and then permanently erased the plaintext (NULL), and no code reads it any more — dropping the column from the schema is a separate, later step. Evidence: backend/app/api/influencer.py::_require_influencer_access, backend/migrations/versions/052_influencer_dashboard_token_hash.py.

Deployment status — stated honestly (2026-10-03): the change above has landed in the code, but as of the moment this line was written it has not been verified as having run in production (docs/infra/deploy-log.md records migration 052 as "not deployed"). The migration runs automatically the next time the production backend starts; until then, old plaintext values may still be present in the production database. The revocation promises in the rest of this section (generated exactly once, permanently deleted on deactivation and on erasure) hold today independently of that deployment.

This data is processed not on the explicit-consent basis (KVKK Art. 6 / GDPR Art. 9) used for user data above, but on:

IBAN and tax id are not special-category data under KVKK Art. 6; there is no separate explicit-consent form for this section — the lawful basis is contract performance and legal obligation.

13.3 Who sees it

As of today: only Wybber's own staff, via admin-secret-gated endpoints. No third-party processor receives this data. No real email-sending provider (e.g. SES, Postmark) is integrated yet — your login link is currently hand-delivered by Wybber staff through an out-of-band channel; this section will be updated, and that provider named as a processor here, once a real one is integrated.

Your IBAN and tax id are stored encrypted at rest at the application level (AES-256-GCM) (fixed 2026-09-25 — S180, backend/app/core/field_encryption.py: a random 96-bit nonce per row, an "additional authenticated data" field that binds the ciphertext to a specific table/column/influencer, and a version tag that allows future key rotation; the app refuses to start in production if the encryption key is missing or a placeholder). The plaintext iban/tax_id columns were removed entirely from the database (migration 031) — today the database holds only the encrypted value and the last-4-digits used for masking. The admin view used by Wybber staff never decrypts these fields; it always shows them masked to their last 4 characters — only your own login (GET /influencer/me/payout-details) sees the full value.

Two limits this encryption does NOT cover, stated honestly: (1) column-level encryption is not a protection against someone with root/admin access to the database server itself — the application process decrypts with the key on every read; (2) production's off-site encrypted backup step is still not enabled as of today (docs/infra/backup-runbook.md) — the local pg_dump backups on the server are unencrypted, though the IBAN/tax-id columns remain encrypted inside those local backups too (since they are encrypted at the application level before being written), so this second limit applies only to the rest of the database (non-influencer tables).

13.4 Retention periods

DataPeriod / status
Rejected application (InfluencerApplication.status = "rejected")Permanently deleted within 6 months of the rejection decision. Implementation status (2026-09-25, S184): this is now actually enforced in code — backend/scripts/s184_retention_purge.py permanently deletes rejected applications with a reviewed_at older than 6 months (covered by backend/tests/test_s184_influencer_retention.py). Running this on a regular schedule in production depends on that script being installed as a scheduled (cron) job — see docs/infra/s184-retention-runbook.md. Earlier deletion on request is always available (§13.5).
Deactivated influencer account (Influencer.is_active = false)Name/email/social-handle fields are deleted or anonymized after 24 months of inactivity following deactivation; payment/revenue records are retained separately and longer under the row below. Implementation status (2026-09-25, S184): implemented in code — the same automated job anonymizes the identity fields of any account whose Influencer.deactivated_at (stamped the moment the account is deactivated) is more than 24 months old; it never touches InfluencerPayoutDetails/RevenueShare rows. Regular production runs depend on the same cron setup noted above.
Payout details and revenue-share records (InfluencerPayoutDetails, RevenueShare)Retained for 10 years from the start of the calendar year following the relevant fiscal year, under Turkey's Tax Procedure Law (VUK) Art. 253 and Commercial Code (TTK) Art. 82. This is a legal retention obligation and falls outside the scope of any "delete my account/data" request — the record cannot be fully deleted during this period, though identity fields can be minimized as far as possible on request.
Login link (InfluencerMagicLink)The plaintext token is permanently nulled in the database the moment it is consumed or expires (default 15 minutes) — implemented behavior. Implementation status (2026-09-25, S184): the row itself (hash + timestamps only) is now permanently deleted by the same automated job, 90 days after consumption/expiry.
Secret dashboard access key (Influencer.dashboard_token_hash)Valid as long as your account is active. Implementation status (2026-09-25, S184; mechanism update 2026-10-03, S3320): the moment your account is deactivated (PATCH /admin/influencers/{id} with is_active=false), this record is permanently deleted (nulled) from the database — no waiting period or scheduled job needed, it happens synchronously with the deactivation request. The same deletion is applied by the erasure paths in §13.5. What is deleted is no longer the key itself but its digest, the only record against which it could ever be verified; once that record is gone the key is permanently invalid and cannot be restored — Wybber never holds the key itself.

13.5 Your rights and how to request deletion

You can contact support@wybber.com to:

Implementation status (2026-09-25, S184): if your account is active, you can call POST /influencer/me/erase with your own session token to immediately anonymize your identity fields (name, email, your linked application's social-media handles/message) and deactivate your account — this is the influencer-side equivalent of the dating-user DELETE /api/users/me. If your account is already deactivated, or you only ever submitted an application (no Influencer account was created), that endpoint intentionally refuses to work (see the design note below) — in that case, contact support@wybber.com; Wybber staff will perform the same anonymization on your behalf (POST /admin/influencers/{id}/erase). Neither path touches the payment/tax records subject to the legal retention obligation in §13.4.

Design note: a deactivated account's session token is already rejected before it can reach POST /influencer/me/erase (or any other self-serve endpoint) — a deliberate choice to prevent a leaked/stale token from irreversibly erasing data for an account that is no longer active; the same rule applies to the payout-details endpoints.