Privacy Notices, Explicit Consent and Cookies: Recording Consent, Cookie Banners, Consent Mode and Commercial Message Permissions (İYS)
A privacy notice is given for every processing activity, while explicit consent is obtained separately and only where processing relies on it; consent must be provable, versioned and revocable. On Bookstore we build an append-only ConsentRecord, an Angular banner with equal buttons following the Authority's cookie guide, Google Consent Mode with a denied default, Playwright tests that verify them, and reporting commercial e-mail permissions to İYS within three business days.
Version note: The code in this post was built and run with .NET 10, ASP.NET Core 10, Entity Framework Core 10.0.15 (SQLite), xUnit 2.9, Angular 22.2 (TypeScript 6.0) and Playwright 1.65. Legal wording was checked against kvkk.gov.tr, mevzuat.gov.tr and iys.org.tr, and Google Consent Mode against developers.google.com, on 15 November 2026.
This series is a developer's view, not legal advice. Which cookies need explicit consent, the wording of the privacy notice and how commercial message permission is collected are decided by your legal counsel; this post shows what those decisions turn into in code.
Guess First
The first cookie banner of Bookstore, the online bookstore used as the shared example across this site's series, looked like this: a line at the bottom of the page saying "By continuing to use the site you accept cookies" and a single "OK" button; the analytics script sits in index.html and loads with the page. Measured against the Authority's cookie guide, in how many places is this design wrong? The Playwright tests at the end of the post give the answer.
A Privacy Notice Is Not Explicit Consent
KVKK, Turkey's Law No. 6698, sets two separate duties, and code often mixes them up. The duty to inform (Article 10) is the notice given when data is collected, whatever the legal basis: who the controller is, the purpose, who the data is transferred to, how it is collected and on what legal basis, and the data subject's rights. Explicit consent is only one of the legal bases; Article 3(1)(a) defines it as consent that is about a specific subject, based on information and given of free will. Processing an address for an order rests on the contract and needs no explicit consent, but the notice is still required.
The procedure is in the Communiqué on the Procedures and Principles for Fulfilling the Duty to Inform (Official Gazette No. 30356 of 10 March 2018, amended on 28 April 2019). What turns into code from Article 5 of the Communiqué: the notice is given for every processing activity and does not depend on the data subject asking; if the purpose changes it is given again before processing; the controller carries the burden of proving it was given; and when processing relies on explicit consent, giving the notice and obtaining consent are done separately. For a developer this means the privacy notice must have a version, every consent record must keep which version was shown, and the consent box must not be buried inside the notice text. The GDPR takes a similar line: Article 7(1) requires the controller to be able to demonstrate consent, and Article 7(3) requires that consent can be withdrawn at any time and that withdrawing is as easy as giving it.
ConsentRecord: Version, Time and Withdrawal of Consent
In Bookstore every decision is a new row; rows are never updated. Withdrawal is a decision too, so it is a new row as well. The answer to "which text did this person see on 13 November, what did they agree to and when did they change their mind" is the table itself:
// Append-only: every decision (grant or withdrawal) is a new row; the current state is the latest row per purpose.
[DataCategory("consent")]
public sealed class ConsentRecord
{
[NoDataClassification]
public long Id { get; set; }
// A signed-in customer, or an anonymous visitor known only by the random id in the consent cookie.
[PersonalData(InformationType = "Customer ID")]
public int? CustomerId { get; set; }
[PersonalData(InformationType = "Device ID")]
public Guid? VisitorId { get; set; }
[NoDataClassification]
public required string Purpose { get; set; }
[NoDataClassification]
public bool Granted { get; set; }
// Which privacy notice the person saw and which consent text they answered.
[NoDataClassification]
public required string NoticeVersion { get; set; }
[NoDataClassification]
public required string Source { get; set; }
[NoDataClassification]
public DateTime RecordedAtUtc { get; set; }
}
The current state is the latest row per purpose; no row at all means "no consent":
// The latest row per purpose wins; no row means no consent.
public async Task<bool> HasConsentAsync(int customerId, string purpose, CancellationToken ct = default) =>
await db.Set<ConsentRecord>()
.Where(r => r.CustomerId == customerId && r.Purpose == purpose)
.OrderByDescending(r => r.RecordedAtUtc).ThenByDescending(r => r.Id)
.Select(r => (bool?)r.Granted)
.FirstOrDefaultAsync(ct) ?? false;
The recording API rejects a decision that comes with an old notice version with 409 Conflict; when the privacy notice changes, the banner asks again. We do not store the IP address or the user agent: for proof it is enough to know which text was answered, when and through which channel, and anything more goes against the minimisation principle from post 02. After the Playwright tests the table looked like this (one visitor rejected everything, the other said yes to analytics and then withdrew):
Id CustomerId Visitor Purpose Granted NoticeVersion Source RecordedAtUtc
-- ---------- -------- --------- ------- ------------- ------------- --------------------------
1 02A27BCD analytics 0 2026-11 cookie-banner 2026-11-15 06:56:36.432576
2 02A27BCD marketing 0 2026-11 cookie-banner 2026-11-15 06:56:36.432576
3 1A14CDF3 analytics 1 2026-11 cookie-banner 2026-11-15 06:56:36.715276
4 1A14CDF3 marketing 0 2026-11 cookie-banner 2026-11-15 06:56:36.715276
5 1A14CDF3 analytics 0 2026-11 cookie-banner 2026-11-15 06:56:36.913096
6 1A14CDF3 marketing 0 2026-11 cookie-banner 2026-11-15 06:56:36.913096
Consent records went into the inventory too (the consent activity). How long they are kept, and in particular whether they are kept as proof after the "delete my account" flow from post 04, is a question for the legal team; for now the inventory says "Ask legal".
What the Authority's Cookie Guide Says
The current PDF of the Authority's Guide on Cookie Practices on its website is KVKK Publications No. 69, dated July 2025. The guide describes itself as a document of practical recommendations that guides controllers running websites; it is not binding like a regulation, but it shows how the Authority looks at cookies. The points that matter to a developer:
- It lists four types by purpose: strictly necessary, functional, performance-analytics and advertising/marketing cookies.
- For cookies that can rely on a legal basis other than explicit consent it gives two criteria: Criterion A (used only to carry a communication over an electronic communications network) and Criterion B (strictly necessary for a service the user explicitly asked for). Session, cart, security and the cookie that remembers the consent choice are Criterion B examples.
- It says first-party analytics cookies can be treated under Criterion B under certain conditions (anonymous statistics only, no cross-site tracking, a reasonable lifetime, no sharing with third parties), while behavioural advertising cookies need explicit consent.
- Explicit consent must be obtained before cookies are set and through an active action; merely entering the site is not consent. Cookies that need explicit consent must be off in the panel when it first opens.
- It gives presenting "accept", "reject" and "preferences" buttons equally in colour, size and font as an example of good practice. So that consent can be withdrawn, it suggests the panel stay reachable through a small icon or band in a corner of the page, and adds that asking on every visit leads to consent fatigue.
- It says cookie walls that make consent a condition of the service can undermine free will, and recommends that the notice state each cookie's name, purpose, lifetime and whether it is first-party or third-party.
Bookstore's analytics is a third-party tool and sends data abroad (the subject of post 07), so we did not put it under the first-party analytics exception and made it depend on consent. That is an engineering choice, not a legal assessment. Bookstore's cookies as they go into the privacy notice:
| Cookie | Type | Party | Lifetime | Basis |
|---|---|---|---|---|
bookstore_consent | Necessary (consent choice) | First party | 180 days (example) | Criterion B, no consent needed |
| Session and cart | Necessary | First party | Until the session ends | Criterion B |
| Analytics | Performance-analytics | Third party | Provider's setting | Explicit consent |
| Advertising | Marketing | Third party | Provider's setting | Explicit consent |
The Banner in Angular and Google Consent Mode
Google Consent Mode is an API that tells Google tags what the visitor decided; it is not a banner itself, and collecting the decision is your job. Google's documentation asks for the default state to be set on every page, before the tag loads and before any config or event command. In Bookstore this is a small script in the head of index.html that runs before Angular:
<script>
// Runs before the app and before any tag: everything is denied until the visitor decides.
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500
});
</script>
wait_for_update is in milliseconds: the time tags give an asynchronously loaded consent tool to call update. Google describes two implementations. In basic mode the tags do not load at all until the visitor interacts with the banner, and no data goes to Google. In advanced mode the tags load with the page and send cookieless measurement pings while consent is denied. Bookstore uses basic mode: it is the closest to the guide's "consent before cookies" line and the easiest to test. Where advanced mode's cookieless pings stand under KVKK is a question for the legal team. The sample contains no real Google tag or measurement ID; a local stub (/analytics-stub.js) is loaded in its place and writes a fake analytics cookie.
The Angular service that saves the decision writes the cookie, sends update to the tag, deletes analytics cookies on withdrawal and records the decision with the API:
async save(choices: ConsentChoices): Promise<void> {
const visitorId = this.stored()?.visitorId ?? crypto.randomUUID();
const value: StoredConsent = { ...choices, notice: NOTICE_VERSION, visitorId };
writeCookie(value);
this.stored.set(value);
updateGoogleConsent(choices);
if (!choices.analytics) {
// Withdrawal also removes what analytics already stored.
ANALYTICS_COOKIES.forEach(name => (document.cookie = `${name}=; path=/; max-age=0`));
}
await firstValueFrom(this.http.post('/api/consents', { visitorId, noticeVersion: NOTICE_VERSION, choices }));
}
AnalyticsLoader, which loads the script, watches the choices().analytics signal with an effect and adds the script element only when it is true. The banner's three buttons share one CSS class; the preference boxes start from the current decision, or from false when there is none. After a decision a small "Cookie settings" button stays in the bottom-left corner. The texts in the sample are in English; a real site ships them in Turkish together with the privacy notice.

Verifying with Playwright
We collected the banner's legal expectations in four tests. They start the API and the Angular dev server through Playwright's webServer setting and run in Playwright's own headless Chromium. The first covers the most important point of the opening question:
test('nothing optional runs before the visitor decides', async ({ page, context }) => {
const scripts: string[] = [];
page.on('request', r => r.resourceType() === 'script' && scripts.push(new URL(r.url()).pathname));
await page.goto('/');
await expect(page.getByRole('dialog', { name: 'Cookie consent' })).toBeVisible();
expect(scripts).not.toContain('/analytics-stub.js');
expect(await analyticsLoaded(page)).toBe(false);
expect((await context.cookies()).map(c => c.name)).toEqual([]);
expect(await consentUpdates(page)).toEqual([['default', 'denied']]);
});
The other three check that the buttons have the same width, height, font, colour and background; that rejecting keeps analytics off, records the decision with 204 and sends a denied update to the tag; and that the preference boxes start unticked, withdrawing through the corner button deletes the analytics cookie and the script does not come back after a reload:
cd web
npx playwright test --reporter=list
✓ 1 e2e/consent.spec.ts:7:1 › nothing optional runs before the visitor decides (128ms)
✓ 2 e2e/consent.spec.ts:19:1 › accept, reject and preferences look the same (113ms)
✓ 3 e2e/consent.spec.ts:32:1 › reject keeps analytics off and records the choice (200ms)
✓ 4 e2e/consent.spec.ts:45:1 › preference boxes start unticked and withdrawal works from the corner button (455ms)
4 passed (4.0s)
To see that the test really catches the problem, I removed the condition in AnalyticsLoader so the script always loads; the first test failed (array shortened):
✘ 1 e2e/consent.spec.ts:7:1 › nothing optional runs before the visitor decides (117ms)
Error: expect(received).not.toContain(expected) // indexOf
Expected value: not "/analytics-stub.js"
Received array: ["/@vite/client", "/main.js", ..., "/@ng/component", "/analytics-stub.js"]
The answer to the opening question: "by continuing you accept" is not an active action; a single "OK" button offers no way to refuse; the script loads before consent; and there is no way to withdraw. Four problems, and the first two hit the guide's explicit consent section directly.
Commercial Electronic Messages and İYS
Campaign e-mails fall under a separate regime: the Regulation on Commercial Communication and Commercial Electronic Messages, based on Law No. 6563 (Official Gazette No. 29417 of 15 July 2015; the İYS provisions came with the amendment in Official Gazette No. 30998 of 4 January 2020). The provisions that turn into code: no commercial electronic message may be sent to a recipient without consent recorded in İYS (Article 5(3)); order, delivery and payment notices need no consent, but they may not promote goods or services (Article 6(2)); the positive statement in a consent text may not be pre-selected, and consent may not be made a condition of the goods or service (Articles 7(8) and 7(9)); consents collected outside İYS are recorded in İYS within three business days, and those not recorded are invalid (Articles 7(11) and 7(12)); rejections are also reported to İYS within three business days, and sending stops within three business days (Articles 9(6) and 10(1)). The İYS frequently asked questions page says Saturdays, Sundays and official holidays do not count as business days.
// Order and delivery notices need no consent; a campaign e-mail does.
public async Task<bool> CanSendAsync(int customerId, MessageKind kind, CancellationToken ct = default) =>
kind == MessageKind.Transactional || await consents.HasConsentAsync(customerId, ConsentCatalog.CommercialEmail, ct);
// Consent and rejection both go to İYS; the e-mail stops at once, not after the sync.
public async Task SetEmailPermissionAsync(int customerId, bool granted, string source, CancellationToken ct = default)
{
Customer customer = await db.Customers.SingleAsync(c => c.Id == customerId, ct);
DateTime now = clock.GetUtcNow().UtcDateTime;
db.Add(new ConsentRecord
{
CustomerId = customerId,
Purpose = ConsentCatalog.CommercialEmail,
Granted = granted,
NoticeVersion = ConsentCatalog.NoticeVersion,
Source = source,
RecordedAtUtc = now,
});
db.Add(new IysSyncItem
{
Address = customer.Email,
Channel = "EPOSTA",
Granted = granted,
RecordedAtUtc = now,
DueDate = BusinessDays.Add(DateOnly.FromDateTime(now), 3, holidays.Days),
});
await db.SaveChangesAsync(ct);
}
IysSyncItem is an outbox row: it is written in the same transaction as the consent record, a separate job sends it to İYS, and unsent rows past their DueDate should raise an alert. In the test a rejection given on a Friday falls on Thursday when Tuesday counts as a holiday. The list of official holidays changes every year, so it comes from configuration. On the technical side of İYS, its pages say: permissions can be added one by one or in bulk on İYS Web, and through technical integration via the İYS API; the API accepts calls only from registered IP addresses; the subRequestId returned by the bulk permission service means the permission was passed to İYS, not that it was added, so its status must be queried separately; and service providers with fewer than 250,000 contact addresses can integrate only through an integrator they authorise. The İYS API was not called for this post; the client that consumes the outbox is left empty behind an interface.
KVKK explicit consent and İYS consent are two separate legal regimes. Ask your legal team whether a single consent box can satisfy both; in code, design them so they can be kept as two separate purposes.
Production Notes
The inline script in index.html clashes with a Content Security Policy; allow it with a nonce or a hash. The banner's cookie should be written with Secure and SameSite=Lax; the sample adds Secure only on HTTPS because it runs on local HTTP. Cookie flags are covered in detail in the A07 post on cookie flags in the Web Security (OWASP Top 10) series. When the privacy notice changes, NOTICE_VERSION must change; if the frontend and the API disagree, the recording API returns 409, which is a deployment error, so monitor it. If you have several subdomains, decide early which domain the consent cookie is written on. Finally, the provider side: keep the analytics and advertising tools' own retention, IP settings and data sharing options in line with the inventory.
Common Pitfalls
The most common one is loading the analytics script before consent; trusting the tag manager because "consent mode is on" and running the default command after the tag gives the same result. The second is pre-ticked boxes and "by continuing you accept" text. The third is having no way to withdraw consent in the interface, or only through an e-mail address at the bottom of the privacy policy. The fourth is keeping consent only in a browser cookie and leaving no record on the server; the burden of proof is on the controller. The fifth is designing the consent table as a single row that gets updated and losing the history. The sixth is adding a campaign block to an order notification template: at that moment the message becomes a commercial electronic message and needs İYS consent.
Sources for This Post
Accessed on 15 November 2026.
- Law No. 6698, Articles 3 and 10:
mevzuat.gov.tr/mevzuatmetin/1.5.6698.pdf; Communiqué on the Duty to Inform:mevzuat.gov.tr, legislation no. 24454 - KVKK, Guide on Cookie Practices, KVKK Publications No. 69, July 2025:
kvkk.gov.tr/SharedFolderServer/CMSFiles/fb193dbb-b159-4221-8a7b-3addc083d33f.pdf - Regulation on Commercial Communication and Commercial Electronic Messages:
mevzuat.gov.tr, legislation no. 20914; İYS frequently asked questions and corporate services:iys.org.tr - Google tag consent mode:
developers.google.com/tag-platform/security/guides/consentanddevelopers.google.com/tag-platform/security/concepts/consent-mode; GDPR Article 7:eur-lex.europa.eu/eli/reg/2016/679/oj
A Small Experiment
★☆☆ Set NOTICE_VERSION to 2026-12 in the frontend, leave the API as it is and press "Reject all" on the banner. See the API return 409; then add a Playwright test that catches this case.
★★☆ Add a "Functional" category to the banner (for example a cookie that remembers recently viewed books). Design a check that breaks the build until the category is added to the backend catalogue, the notice table and the Playwright tests.
★★★ Write a job that processes IysSyncItem rows: it sends them through an interface, sets SentAtUtc on success, writes an alert log for rows past their DueDate and never sends the same row twice. Test the business-day boundary with a fake implementation of the interface.
We now have consent, but analytics data does not go from Bookstore's server; it goes straight from the visitor's browser to a provider abroad. In the last post of the series we look at the cross-border transfer regime, cloud regions, prompts sent to LLM providers, the five-business-day notification of standard contracts and getting ready for the 72-hour breach notification.