Vibecode LnkFlow
track this build4 phases, 12 steps, beginner friendly0%A short link that 302s, logs the click, and injects UTMs is genuinely one sitting on Cloudflare Workers and KV, and the prompt below builds exactly that. What is not one sitting is the loop back out to the ad platforms. Meta and Google bid against the conversion events you send them, and since ATT the browser pixel misses a large share of purchases, which is the entire reason the Conversions API and offline conversion import exist. Closing that loop means persisting fbclid and gclid at redirect time, carrying that identity to a sale that happens days later on another device, posting a Purchase with a stable event_id so it deduplicates against the pixel, rebuilding fbc in Meta's fb.1.{timestamp}.{fbclid} form, importing offline conversions to Google on gclid, gbraid, or wbraid, and reversing all of it on refund. Every one of those fails silently. That is the expensive part: the dashboard still shows clicks while the bidder optimizes against nothing.
You are building a lean indie version of LnkFlow. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # LnkFlow · indie build A short link service whose hard problems are logging a click without slowing the redirect, joining a sale that happens days later on a different device back to the click that caused it, and getting that conversion out to an ad platform before the platform's import window closes, which is a deadline nobody warns you about. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Edge | A single edge worker on your own subdomain, with the slug map in key-value storage | the redirect has to be fast and it must not wait on a write | | Click log | A relational store at the edge, written after the response is handed back | a click nobody logged is a conversion nobody can attribute | | Identity | A first-party cookie on your own domain plus a server-side click record | the join happens on your side, not in somebody else's browser | | Attribution | An explicit join table with a deadline column, not a view over two tables | a conversion has an expiry and an expiry needs somewhere to live | | Upload | A queue with retries, deduplication keys and a dead letter state | silent failure here is the expensive one, because the dashboard still looks fine | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **An edge platform account with key-value and relational storage** · free tiers cover a personal volume comfortably - Why: The redirect needs to run close to the visitor and the click log needs somewhere durable to land. - Get it: Create the account, the key-value namespace and the database, and install the platform's command line tool. - Verify: A deployed worker returns a redirect from a slug you created - [ ] **A domain or subdomain you control** · the price of a domain - Why: A first-party cookie has to be set on a domain you own, and the whole identity design rests on that. - Get it: Point a record at the worker following the platform's instructions. - Verify: The short domain resolves and sets the cookie - [ ] **API access to at least one ad platform** · free to obtain; approval can take days - Why: Without an upload path the build stops at a dashboard, which is the part that was never worth paying for. - Get it: Create developer credentials in the platform's console and request whatever access level conversion upload requires. - Verify: A test conversion appears in the platform's own reporting - [ ] **The current import windows, written down with dates** · free - Why: The deadline is computed at click time from these numbers, so a wrong one silently expires conversions months later. - Get it: Read each platform's own documentation on offline conversion import and record the window and the date you read it. - Verify: Every platform in the configuration has a window and a checked-on date ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Certificates for domains you do not own. - A browser pixel on the destination site. - Identity resolution beyond what your application carries. - Per-creator commission reporting. - Purchase events delivered back to Meta CAPI with stable event_id deduplication against the browser pixel - Google offline conversion import keyed on gclid, gbraid, or wbraid - click to lead to sale attribution that survives a multi-day gap, with refund reversal - TLS on a customer's branded domain (Cloudflare for SaaS and 90-day DCV renewals, not one DNS record) - per-creator performance and commission reporting If one of those is essential to you, that is the reason to keep paying for LnkFlow, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · LnkFlow The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a branded redirector that keeps a click identity long enough to be worth reporting, and knows when it has run out of time to report it to replace LnkFlow. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Edge: A single edge worker on your own subdomain, with the slug map in key-value storage · the redirect has to be fast and it must not wait on a write - Click log: A relational store at the edge, written after the response is handed back · a click nobody logged is a conversion nobody can attribute - Identity: A first-party cookie on your own domain plus a server-side click record · the join happens on your side, not in somebody else's browser - Attribution: An explicit join table with a deadline column, not a view over two tables · a conversion has an expiry and an expiry needs somewhere to live - Upload: A queue with retries, deduplication keys and a dead letter state · silent failure here is the expensive one, because the dashboard still looks fine ### Data model (create this before Phase 1) - links(slug, destination, utm_json, created_at, archived_at, owner_note) - clicks(id, slug, at, country, user_agent_hash, referer, click_ids_json, visitor_id, import_deadline_at) · the deadline is computed at click time, not at conversion time - visitors(visitor_id, first_seen_at, last_seen_at) · a first-party identifier you set, nothing borrowed - conversions(id, at, kind, value_minor, currency, order_ref, visitor_id, matched_click_id, match_method, reversed_at) - uploads(id, conversion_id, platform, attempt, status, dedup_key, request_summary, response_code, response_excerpt, at) - expired(id, conversion_id, reason, noticed_at) · conversions that could never be uploaded, counted and shown rather than dropped - The deadline lives on the click, because it is measured from the click. Storing it on the conversion means computing it from a timestamp you may no longer have. ### Phase 1 · A redirect that does not wait for anything Build: An edge worker reading the slug from key-value storage and returning a redirect straight away, with the click write handed to the platform's after-response mechanism. Inject the link's own campaign parameters and pass any incoming ad click identifiers through to the destination untouched. Mark the response as not indexable and not cacheable. Trap: Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. Done when: The redirect response is produced before the click write is awaited, verified by review and by a test that stalls the write and still returns promptly. Incoming ad click identifiers arrive at the destination unmodified, tested with a request carrying several. An unknown slug returns a plain not-found rather than a redirect to a default, tested. The response carries headers preventing indexing and caching, checked against a live request. Do not build yet: No visitor identity, no conversions, no uploads. ### Phase 2 · A visitor identity you own Build: Set a first-party cookie on your redirect domain carrying a random visitor identifier, and store the same identifier on the click row. Record the ad click identifiers alongside it. Compute and store an import deadline on the click from the platform's published window, and do it at click time. Honour a do-not-track or consent signal by logging the click without the identifier. Trap: Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. Done when: The visitor identifier is random and independent of any ad click identifier, asserted in the suite. A click with no ad identifiers still produces a usable visitor record, tested. The import deadline is computed at click time from configured platform windows and stored on the click row, tested at the boundary. With consent withheld, the click is logged with counts but no visitor identifier, tested. Do not build yet: No conversion recording, no matching, no uploads. ### Phase 3 · Joining a sale to a click days later Build: An authenticated endpoint your application calls when a sale completes, carrying an order reference, a value and whatever identity it has. Match on the visitor identifier first, then on an ad click identifier if your application captured one, and record which method matched. Unmatched conversions are stored as unmatched rather than attached to the most recent click. Trap: Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. Done when: Every conversion stores a match method and an unmatched conversion is stored as unmatched rather than guessed, asserted in the suite. A conversion arriving nine days after the click on a different device still matches when the visitor identifier was carried by your own application, tested. The endpoint refuses unauthenticated calls and duplicate order references, tested. The unmatched count is displayed beside the matched count everywhere a total appears. Do not build yet: No uploads to any platform, no reversal, no exports. ### Phase 4 · The import window is a deadline, not a preference Build: A queue that uploads matched conversions with a deduplication key stable across retries, and a scheduler that prioritises conversions nearest their deadline. Read the current published import windows yourself and put them in configuration: when checked in September 2026, Google's guidance was that conversions uploaded more than 90 days after the associated last click are not imported, with a shorter 63 day window for enhanced conversions for leads. Trap: A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. Done when: A conversion whose deadline has passed is moved to expired with a reason and never uploaded, tested at the boundary with a configured window. The upload queue is ordered by nearest deadline, tested with a mixed backlog. Retries reuse the same deduplication key so a duplicate arrival is not a second conversion, tested. Expired counts appear on the dashboard beside matched and unmatched counts, and a non-zero count is visibly a problem. Do not build yet: No refund reversal yet, no export, no key rotation. ### Phase 5 · Refunds, exports and what you refuse to run Build: Record a refund as a reversal linked to the original conversion and upload it as an adjustment rather than deleting the row. Export links, clicks, conversions and uploads as CSV. Keep secrets out of the worker configuration file. Write down what this build does not do: certificates for domains you do not own, and any per-customer branded domain issuance. Trap: Deleting a conversion row on refund makes your own reports correct and leaves the platform holding a purchase that never stuck, which is the exact condition that trains a bidder towards buyers who refund. Keep the original, add a reversal, upload the adjustment, and reconcile the two counts in the export rather than in your head. Done when: A refund creates a reversal row, leaves the original intact and produces an adjustment upload, tested end to end against a stubbed platform. Export produces links, clicks, conversions, uploads and expired rows as CSV and reimports into an empty database with equal counts. No secret appears in any committed configuration file, asserted by a test that scans the repository. The README states plainly that certificates for other people's domains are out of scope and why. Do not build yet: Nothing after this. ### Out of scope (and why) - Certificates for domains you do not own, which is a hosted issuance and renewal problem rather than one DNS record, and is a real part of why agencies pay for this. - Browser-side pixel installation on the destination site, since the conversion endpoint is called by your own application where you already know the order reference. - Cross-platform identity resolution beyond what your own application can carry, because everything past that is inference presented as fact. - Per-creator commission reporting, which is a payments problem attached to an attribution problem and deserves its own build. ### README must contain - That the click write happens after the redirect is returned, and how to measure the redirect at the edge - That the import windows live in configuration with the date they were checked, and where to recheck them - That unmatched conversions are counted and never attributed to the most recent click - What the expired count means and why a non-zero value is a bug rather than a statistic - That refunds are reversals and never deletions, and how the two counts reconcile in the export ===== AGENTS.md ===== # Agent instructions · LnkFlow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A single edge worker on your own subdomain, with the slug map in key-value storage, A relational store at the edge, written after the response is handed back, A first-party cookie on your own domain plus a server-side click record, An explicit join table with a deadline column, not a view over two tables, A queue with retries, deduplication keys and a dead letter state. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. - Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. - Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. - A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. ===== BUILD_PLAN.md ===== # Build plan · LnkFlow A short link service whose hard problems are logging a click without slowing the redirect, joining a sale that happens days later on a different device back to the click that caused it, and getting that conversion out to an ad platform before the platform's import window closes, which is a deadline nobody warns you about. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A redirect that does not wait for anything A request to your domain resolves and redirects immediately, and the click is logged afterwards. ### Steps 1. Return the redirect before awaiting the write Latency here is paid by every single visitor. Files: `src/worker/redirect.ts`, `tests/redirect-latency.test.ts` 2. Pass ad click identifiers through unchanged Rewriting one of them is how attribution dies quietly. Files: `src/worker/params.ts` 3. Refuse unknown slugs rather than guessing A default destination turns a typo into traffic you cannot explain. Files: `src/worker/resolve.ts` ### Done when - [ ] The redirect does not wait on the click write - [ ] Ad click identifiers reach the destination intact - [ ] Unknown slugs return not-found - [ ] Responses are marked no-index and no-store ### Watch out - Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. ## Phase 2 · A visitor identity you own A click can be recognised again later from your own domain, without borrowing anybody else's identifier. ### Steps 1. Generate your own opaque visitor identifier Borrowing one couples your join to somebody else's format. Files: `src/worker/visitor.ts` 2. Store the import deadline on the click It is measured from the click and nothing else knows it. Files: `src/db/schema.sql`, `tests/deadline.test.ts` 3. Log a consent-withheld click without identity The count still matters even when the join cannot happen. Files: `src/worker/consent.ts` ### Done when - [ ] Visitor identifiers are independent of ad identifiers - [ ] Clicks without ad identifiers still work - [ ] Deadlines are computed and stored at click time - [ ] Consent-withheld clicks log counts only ### Watch out - Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. ## Phase 3 · Joining a sale to a click days later A conversion reported from your own application finds the click that caused it, or admits it cannot. ### Steps 1. Record the match method on every conversion An unlabelled match is indistinguishable from a guess. Files: `src/attribution/match.ts`, `tests/match.test.ts` 2. Store unmatched conversions as unmatched The count is the honest number and people need to see it. Files: `src/attribution/store.ts` 3. Reject duplicate order references A retried webhook should not double your revenue. Files: `src/api/conversion.ts` ### Done when - [ ] Match method is recorded on every conversion - [ ] Unmatched conversions are stored, not guessed - [ ] Duplicate order references are rejected - [ ] Unmatched counts appear beside matched ones ### Watch out - Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. ## Phase 4 · The import window is a deadline, not a preference Matched conversions reach the platform inside its window, and the ones that miss it are counted rather than lost. ### Steps 1. Put the published windows in config with a checked-on date These numbers change and a constant will not tell you. Files: `config/platforms.json`, `src/upload/windows.ts` 2. Order the queue by nearest deadline First in, first out is the wrong order when things expire. Files: `src/upload/queue.ts`, `tests/queue-order.test.ts` 3. Expire loudly, on the dashboard An expired conversion is money the bidder never learned about. Files: `src/web/dashboard.ts` ### Done when - [ ] Past-deadline conversions expire instead of uploading - [ ] The queue is ordered by nearest deadline - [ ] Retries reuse a stable deduplication key - [ ] Expired counts are visible beside the totals ### Watch out - A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. ## Not in this build - Certificates for domains you do not own. - A browser pixel on the destination site. - Identity resolution beyond what your application carries. - Per-creator commission reporting. ## After v1, if you want it - A weekly reconciliation against each platform's own reported conversion count, which is the only independent check that exists - A second ad platform behind the same queue, which is mostly a window number and a request shape - A link expiry and destination history, so a campaign that ends stops sending people to a dead page ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The domain the worker answers on and the cookie is set for. SHORT_DOMAIN=go.example.com # Required · secret. Authenticates your own application calling the conversion endpoint. CONVERSION_API_TOKEN=redacted # Required · secret. Ad platform credentials. Kept in the platform secret store, never in the worker config file. ADS_PLATFORM_SECRET=redacted # Required. Import windows and their checked-on dates. PLATFORM_CONFIG_PATH=./config/platforms.json # Required. How long a visitor identifier survives. Keeping it longer than the import window buys nothing. COOKIE_TTL_DAYS=90 # Required · secret. The only gate on the link editor and the dashboard. ADMIN_PASSWORD=redacted
You are building a lean indie version of LnkFlow. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # LnkFlow · indie build A short link service whose hard problems are logging a click without slowing the redirect, joining a sale that happens days later on a different device back to the click that caused it, and getting that conversion out to an ad platform before the platform's import window closes, which is a deadline nobody warns you about. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Edge | A single edge worker on your own subdomain, with the slug map in key-value storage | the redirect has to be fast and it must not wait on a write | | Click log | A relational store at the edge, written after the response is handed back | a click nobody logged is a conversion nobody can attribute | | Identity | A first-party cookie on your own domain plus a server-side click record | the join happens on your side, not in somebody else's browser | | Attribution | An explicit join table with a deadline column, not a view over two tables | a conversion has an expiry and an expiry needs somewhere to live | | Upload | A queue with retries, deduplication keys and a dead letter state | silent failure here is the expensive one, because the dashboard still looks fine | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **An edge platform account with key-value and relational storage** · free tiers cover a personal volume comfortably - Why: The redirect needs to run close to the visitor and the click log needs somewhere durable to land. - Get it: Create the account, the key-value namespace and the database, and install the platform's command line tool. - Verify: A deployed worker returns a redirect from a slug you created - [ ] **A domain or subdomain you control** · the price of a domain - Why: A first-party cookie has to be set on a domain you own, and the whole identity design rests on that. - Get it: Point a record at the worker following the platform's instructions. - Verify: The short domain resolves and sets the cookie - [ ] **API access to at least one ad platform** · free to obtain; approval can take days - Why: Without an upload path the build stops at a dashboard, which is the part that was never worth paying for. - Get it: Create developer credentials in the platform's console and request whatever access level conversion upload requires. - Verify: A test conversion appears in the platform's own reporting - [ ] **The current import windows, written down with dates** · free - Why: The deadline is computed at click time from these numbers, so a wrong one silently expires conversions months later. - Get it: Read each platform's own documentation on offline conversion import and record the window and the date you read it. - Verify: Every platform in the configuration has a window and a checked-on date ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Certificates for domains you do not own. - A browser pixel on the destination site. - Identity resolution beyond what your application carries. - Per-creator commission reporting. - Purchase events delivered back to Meta CAPI with stable event_id deduplication against the browser pixel - Google offline conversion import keyed on gclid, gbraid, or wbraid - click to lead to sale attribution that survives a multi-day gap, with refund reversal - TLS on a customer's branded domain (Cloudflare for SaaS and 90-day DCV renewals, not one DNS record) - per-creator performance and commission reporting If one of those is essential to you, that is the reason to keep paying for LnkFlow, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · LnkFlow The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a branded redirector that keeps a click identity long enough to be worth reporting, and knows when it has run out of time to report it to replace LnkFlow. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Edge: A single edge worker on your own subdomain, with the slug map in key-value storage · the redirect has to be fast and it must not wait on a write - Click log: A relational store at the edge, written after the response is handed back · a click nobody logged is a conversion nobody can attribute - Identity: A first-party cookie on your own domain plus a server-side click record · the join happens on your side, not in somebody else's browser - Attribution: An explicit join table with a deadline column, not a view over two tables · a conversion has an expiry and an expiry needs somewhere to live - Upload: A queue with retries, deduplication keys and a dead letter state · silent failure here is the expensive one, because the dashboard still looks fine ### Data model (create this before Phase 1) - links(slug, destination, utm_json, created_at, archived_at, owner_note) - clicks(id, slug, at, country, user_agent_hash, referer, click_ids_json, visitor_id, import_deadline_at) · the deadline is computed at click time, not at conversion time - visitors(visitor_id, first_seen_at, last_seen_at) · a first-party identifier you set, nothing borrowed - conversions(id, at, kind, value_minor, currency, order_ref, visitor_id, matched_click_id, match_method, reversed_at) - uploads(id, conversion_id, platform, attempt, status, dedup_key, request_summary, response_code, response_excerpt, at) - expired(id, conversion_id, reason, noticed_at) · conversions that could never be uploaded, counted and shown rather than dropped - The deadline lives on the click, because it is measured from the click. Storing it on the conversion means computing it from a timestamp you may no longer have. ### Phase 1 · A redirect that does not wait for anything Build: An edge worker reading the slug from key-value storage and returning a redirect straight away, with the click write handed to the platform's after-response mechanism. Inject the link's own campaign parameters and pass any incoming ad click identifiers through to the destination untouched. Mark the response as not indexable and not cacheable. Trap: Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. Done when: The redirect response is produced before the click write is awaited, verified by review and by a test that stalls the write and still returns promptly. Incoming ad click identifiers arrive at the destination unmodified, tested with a request carrying several. An unknown slug returns a plain not-found rather than a redirect to a default, tested. The response carries headers preventing indexing and caching, checked against a live request. Do not build yet: No visitor identity, no conversions, no uploads. ### Phase 2 · A visitor identity you own Build: Set a first-party cookie on your redirect domain carrying a random visitor identifier, and store the same identifier on the click row. Record the ad click identifiers alongside it. Compute and store an import deadline on the click from the platform's published window, and do it at click time. Honour a do-not-track or consent signal by logging the click without the identifier. Trap: Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. Done when: The visitor identifier is random and independent of any ad click identifier, asserted in the suite. A click with no ad identifiers still produces a usable visitor record, tested. The import deadline is computed at click time from configured platform windows and stored on the click row, tested at the boundary. With consent withheld, the click is logged with counts but no visitor identifier, tested. Do not build yet: No conversion recording, no matching, no uploads. ### Phase 3 · Joining a sale to a click days later Build: An authenticated endpoint your application calls when a sale completes, carrying an order reference, a value and whatever identity it has. Match on the visitor identifier first, then on an ad click identifier if your application captured one, and record which method matched. Unmatched conversions are stored as unmatched rather than attached to the most recent click. Trap: Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. Done when: Every conversion stores a match method and an unmatched conversion is stored as unmatched rather than guessed, asserted in the suite. A conversion arriving nine days after the click on a different device still matches when the visitor identifier was carried by your own application, tested. The endpoint refuses unauthenticated calls and duplicate order references, tested. The unmatched count is displayed beside the matched count everywhere a total appears. Do not build yet: No uploads to any platform, no reversal, no exports. ### Phase 4 · The import window is a deadline, not a preference Build: A queue that uploads matched conversions with a deduplication key stable across retries, and a scheduler that prioritises conversions nearest their deadline. Read the current published import windows yourself and put them in configuration: when checked in September 2026, Google's guidance was that conversions uploaded more than 90 days after the associated last click are not imported, with a shorter 63 day window for enhanced conversions for leads. Trap: A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. Done when: A conversion whose deadline has passed is moved to expired with a reason and never uploaded, tested at the boundary with a configured window. The upload queue is ordered by nearest deadline, tested with a mixed backlog. Retries reuse the same deduplication key so a duplicate arrival is not a second conversion, tested. Expired counts appear on the dashboard beside matched and unmatched counts, and a non-zero count is visibly a problem. Do not build yet: No refund reversal yet, no export, no key rotation. ### Phase 5 · Refunds, exports and what you refuse to run Build: Record a refund as a reversal linked to the original conversion and upload it as an adjustment rather than deleting the row. Export links, clicks, conversions and uploads as CSV. Keep secrets out of the worker configuration file. Write down what this build does not do: certificates for domains you do not own, and any per-customer branded domain issuance. Trap: Deleting a conversion row on refund makes your own reports correct and leaves the platform holding a purchase that never stuck, which is the exact condition that trains a bidder towards buyers who refund. Keep the original, add a reversal, upload the adjustment, and reconcile the two counts in the export rather than in your head. Done when: A refund creates a reversal row, leaves the original intact and produces an adjustment upload, tested end to end against a stubbed platform. Export produces links, clicks, conversions, uploads and expired rows as CSV and reimports into an empty database with equal counts. No secret appears in any committed configuration file, asserted by a test that scans the repository. The README states plainly that certificates for other people's domains are out of scope and why. Do not build yet: Nothing after this. ### Out of scope (and why) - Certificates for domains you do not own, which is a hosted issuance and renewal problem rather than one DNS record, and is a real part of why agencies pay for this. - Browser-side pixel installation on the destination site, since the conversion endpoint is called by your own application where you already know the order reference. - Cross-platform identity resolution beyond what your own application can carry, because everything past that is inference presented as fact. - Per-creator commission reporting, which is a payments problem attached to an attribution problem and deserves its own build. ### README must contain - That the click write happens after the redirect is returned, and how to measure the redirect at the edge - That the import windows live in configuration with the date they were checked, and where to recheck them - That unmatched conversions are counted and never attributed to the most recent click - What the expired count means and why a non-zero value is a bug rather than a statistic - That refunds are reversals and never deletions, and how the two counts reconcile in the export ===== AGENTS.md ===== # Agent instructions · LnkFlow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A single edge worker on your own subdomain, with the slug map in key-value storage, A relational store at the edge, written after the response is handed back, A first-party cookie on your own domain plus a server-side click record, An explicit join table with a deadline column, not a view over two tables, A queue with retries, deduplication keys and a dead letter state. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. - Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. - Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. - A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. ===== BUILD_PLAN.md ===== # Build plan · LnkFlow A short link service whose hard problems are logging a click without slowing the redirect, joining a sale that happens days later on a different device back to the click that caused it, and getting that conversion out to an ad platform before the platform's import window closes, which is a deadline nobody warns you about. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A redirect that does not wait for anything A request to your domain resolves and redirects immediately, and the click is logged afterwards. ### Steps 1. Return the redirect before awaiting the write Latency here is paid by every single visitor. Files: `src/worker/redirect.ts`, `tests/redirect-latency.test.ts` 2. Pass ad click identifiers through unchanged Rewriting one of them is how attribution dies quietly. Files: `src/worker/params.ts` 3. Refuse unknown slugs rather than guessing A default destination turns a typo into traffic you cannot explain. Files: `src/worker/resolve.ts` ### Done when - [ ] The redirect does not wait on the click write - [ ] Ad click identifiers reach the destination intact - [ ] Unknown slugs return not-found - [ ] Responses are marked no-index and no-store ### Watch out - Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. ## Phase 2 · A visitor identity you own A click can be recognised again later from your own domain, without borrowing anybody else's identifier. ### Steps 1. Generate your own opaque visitor identifier Borrowing one couples your join to somebody else's format. Files: `src/worker/visitor.ts` 2. Store the import deadline on the click It is measured from the click and nothing else knows it. Files: `src/db/schema.sql`, `tests/deadline.test.ts` 3. Log a consent-withheld click without identity The count still matters even when the join cannot happen. Files: `src/worker/consent.ts` ### Done when - [ ] Visitor identifiers are independent of ad identifiers - [ ] Clicks without ad identifiers still work - [ ] Deadlines are computed and stored at click time - [ ] Consent-withheld clicks log counts only ### Watch out - Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. ## Phase 3 · Joining a sale to a click days later A conversion reported from your own application finds the click that caused it, or admits it cannot. ### Steps 1. Record the match method on every conversion An unlabelled match is indistinguishable from a guess. Files: `src/attribution/match.ts`, `tests/match.test.ts` 2. Store unmatched conversions as unmatched The count is the honest number and people need to see it. Files: `src/attribution/store.ts` 3. Reject duplicate order references A retried webhook should not double your revenue. Files: `src/api/conversion.ts` ### Done when - [ ] Match method is recorded on every conversion - [ ] Unmatched conversions are stored, not guessed - [ ] Duplicate order references are rejected - [ ] Unmatched counts appear beside matched ones ### Watch out - Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. ## Phase 4 · The import window is a deadline, not a preference Matched conversions reach the platform inside its window, and the ones that miss it are counted rather than lost. ### Steps 1. Put the published windows in config with a checked-on date These numbers change and a constant will not tell you. Files: `config/platforms.json`, `src/upload/windows.ts` 2. Order the queue by nearest deadline First in, first out is the wrong order when things expire. Files: `src/upload/queue.ts`, `tests/queue-order.test.ts` 3. Expire loudly, on the dashboard An expired conversion is money the bidder never learned about. Files: `src/web/dashboard.ts` ### Done when - [ ] Past-deadline conversions expire instead of uploading - [ ] The queue is ordered by nearest deadline - [ ] Retries reuse a stable deduplication key - [ ] Expired counts are visible beside the totals ### Watch out - A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. ## Not in this build - Certificates for domains you do not own. - A browser pixel on the destination site. - Identity resolution beyond what your application carries. - Per-creator commission reporting. ## After v1, if you want it - A weekly reconciliation against each platform's own reported conversion count, which is the only independent check that exists - A second ad platform behind the same queue, which is mostly a window number and a request shape - A link expiry and destination history, so a campaign that ends stops sending people to a dead page ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The domain the worker answers on and the cookie is set for. SHORT_DOMAIN=go.example.com # Required · secret. Authenticates your own application calling the conversion endpoint. CONVERSION_API_TOKEN=redacted # Required · secret. Ad platform credentials. Kept in the platform secret store, never in the worker config file. ADS_PLATFORM_SECRET=redacted # Required. Import windows and their checked-on dates. PLATFORM_CONFIG_PATH=./config/platforms.json # Required. How long a visitor identifier survives. Keeping it longer than the import window buys nothing. COOKIE_TTL_DAYS=90 # Required · secret. The only gate on the link editor and the dashboard. ADMIN_PASSWORD=redacted
You are building a production product version of LnkFlow.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== PRODUCT.md =====
# LnkFlow · product brief
## Problem
A short link that 302s, logs the click, and injects UTMs is genuinely one sitting on Cloudflare Workers and KV, and the prompt below builds exactly that. What is not one sitting is the loop back out to the ad platforms. Meta and Google bid against the conversion events you send them, and since ATT the browser pixel misses a large share of purchases, which is the entire reason the Conversions API and offline conversion import exist. Closing that loop means persisting fbclid and gclid at redirect time, carrying that identity to a sale that happens days later on another device, posting a Purchase with a stable event_id so it deduplicates against the pixel, rebuilding fbc in Meta's fb.1.{timestamp}.{fbclid} form, importing offline conversions to Google on gclid, gbraid, or wbraid, and reversing all of it on refund. Every one of those fails silently. That is the expensive part: the dashboard still shows clicks while the bidder optimizes against nothing.
## Product outcome
A redirector whose clicks cost the visitor nothing in latency, whose attribution says out loud when it did not match, and which treats the platform's import window as a deadline with a counter rather than a detail in somebody's documentation.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- Cloudflare account (Workers, KV, D1)
- a domain you control
- wrangler CLI
## Explicit non-goals for v1
- Certificates for domains you do not own.
- A browser pixel on the destination site.
- Identity resolution beyond what your application carries.
- Per-creator commission reporting.
- Purchase events delivered back to Meta CAPI with stable event_id deduplication against the browser pixel
- Google offline conversion import keyed on gclid, gbraid, or wbraid
- click to lead to sale attribution that survives a multi-day gap, with refund reversal
- TLS on a customer's branded domain (Cloudflare for SaaS and 90-day DCV renewals, not one DNS record)
- per-creator performance and commission reporting
## Success criteria
- The redirect is confirmed not to wait on the click write
- Visitor identity is confirmed independent of ad click identifiers
- Unmatched conversions are confirmed stored as unmatched
- Past-deadline conversions are confirmed expired rather than uploaded
- A refund is confirmed to produce a reversal, not a deletion
===== BRIEF.md =====
# Build brief · LnkFlow
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a branded redirector that keeps a click identity long enough to be worth reporting, and knows when it has run out of time to report it to replace LnkFlow. Build it in phases, in the order below.
Do not write the whole thing in one pass. Finish a phase, run its "Done when"
check, fix what fails, and only then start the next phase.
### Stack (fixed, do not substitute)
- Edge: A single edge worker on your own subdomain, with the slug map in key-value storage · the redirect has to be fast and it must not wait on a write
- Click log: A relational store at the edge, written after the response is handed back · a click nobody logged is a conversion nobody can attribute
- Identity: A first-party cookie on your own domain plus a server-side click record · the join happens on your side, not in somebody else's browser
- Attribution: An explicit join table with a deadline column, not a view over two tables · a conversion has an expiry and an expiry needs somewhere to live
- Upload: A queue with retries, deduplication keys and a dead letter state · silent failure here is the expensive one, because the dashboard still looks fine
### Data model (create this before Phase 1)
- links(slug, destination, utm_json, created_at, archived_at, owner_note)
- clicks(id, slug, at, country, user_agent_hash, referer, click_ids_json, visitor_id, import_deadline_at) · the deadline is computed at click time, not at conversion time
- visitors(visitor_id, first_seen_at, last_seen_at) · a first-party identifier you set, nothing borrowed
- conversions(id, at, kind, value_minor, currency, order_ref, visitor_id, matched_click_id, match_method, reversed_at)
- uploads(id, conversion_id, platform, attempt, status, dedup_key, request_summary, response_code, response_excerpt, at)
- expired(id, conversion_id, reason, noticed_at) · conversions that could never be uploaded, counted and shown rather than dropped
- The deadline lives on the click, because it is measured from the click. Storing it on the conversion means computing it from a timestamp you may no longer have.
### Phase 1 · A redirect that does not wait for anything
Build: An edge worker reading the slug from key-value storage and returning a redirect straight away, with
the click write handed to the platform's after-response mechanism. Inject the link's own campaign
parameters and pass any incoming ad click identifiers through to the destination untouched. Mark the
response as not indexable and not cacheable.
Trap: Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop.
Done when: The redirect response is produced before the click write is awaited, verified by review and by a test that stalls the write and still returns promptly. Incoming ad click identifiers arrive at the destination unmodified, tested with a request carrying several. An unknown slug returns a plain not-found rather than a redirect to a default, tested. The response carries headers preventing indexing and caching, checked against a live request.
Do not build yet: No visitor identity, no conversions, no uploads.
### Phase 2 · A visitor identity you own
Build: Set a first-party cookie on your redirect domain carrying a random visitor identifier, and store the
same identifier on the click row. Record the ad click identifiers alongside it. Compute and store an
import deadline on the click from the platform's published window, and do it at click time. Honour a
do-not-track or consent signal by logging the click without the identifier.
Trap: Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent.
Done when: The visitor identifier is random and independent of any ad click identifier, asserted in the suite. A click with no ad identifiers still produces a usable visitor record, tested. The import deadline is computed at click time from configured platform windows and stored on the click row, tested at the boundary. With consent withheld, the click is logged with counts but no visitor identifier, tested.
Do not build yet: No conversion recording, no matching, no uploads.
### Phase 3 · Joining a sale to a click days later
Build: An authenticated endpoint your application calls when a sale completes, carrying an order reference,
a value and whatever identity it has. Match on the visitor identifier first, then on an ad click
identifier if your application captured one, and record which method matched. Unmatched conversions
are stored as unmatched rather than attached to the most recent click.
Trap: Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired.
Done when: Every conversion stores a match method and an unmatched conversion is stored as unmatched rather than guessed, asserted in the suite. A conversion arriving nine days after the click on a different device still matches when the visitor identifier was carried by your own application, tested. The endpoint refuses unauthenticated calls and duplicate order references, tested. The unmatched count is displayed beside the matched count everywhere a total appears.
Do not build yet: No uploads to any platform, no reversal, no exports.
### Phase 4 · The import window is a deadline, not a preference
Build: A queue that uploads matched conversions with a deduplication key stable across retries, and a
scheduler that prioritises conversions nearest their deadline. Read the current published import
windows yourself and put them in configuration: when checked in September 2026, Google's guidance was
that conversions uploaded more than 90 days after the associated last click are not imported, with a
shorter 63 day window for enhanced conversions for leads.
Trap: A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total.
Done when: A conversion whose deadline has passed is moved to expired with a reason and never uploaded, tested at the boundary with a configured window. The upload queue is ordered by nearest deadline, tested with a mixed backlog. Retries reuse the same deduplication key so a duplicate arrival is not a second conversion, tested. Expired counts appear on the dashboard beside matched and unmatched counts, and a non-zero count is visibly a problem.
Do not build yet: No refund reversal yet, no export, no key rotation.
### Phase 5 · Refunds, exports and what you refuse to run
Build: Record a refund as a reversal linked to the original conversion and upload it as an adjustment rather
than deleting the row. Export links, clicks, conversions and uploads as CSV. Keep secrets out of the
worker configuration file. Write down what this build does not do: certificates for domains you do
not own, and any per-customer branded domain issuance.
Trap: Deleting a conversion row on refund makes your own reports correct and leaves the platform holding a purchase that never stuck, which is the exact condition that trains a bidder towards buyers who refund. Keep the original, add a reversal, upload the adjustment, and reconcile the two counts in the export rather than in your head.
Done when: A refund creates a reversal row, leaves the original intact and produces an adjustment upload, tested end to end against a stubbed platform. Export produces links, clicks, conversions, uploads and expired rows as CSV and reimports into an empty database with equal counts. No secret appears in any committed configuration file, asserted by a test that scans the repository. The README states plainly that certificates for other people's domains are out of scope and why.
Do not build yet: Nothing after this.
### Out of scope (and why)
- Certificates for domains you do not own, which is a hosted issuance and renewal problem rather than one DNS record, and is a real part of why agencies pay for this.
- Browser-side pixel installation on the destination site, since the conversion endpoint is called by your own application where you already know the order reference.
- Cross-platform identity resolution beyond what your own application can carry, because everything past that is inference presented as fact.
- Per-creator commission reporting, which is a payments problem attached to an attribution problem and deserves its own build.
### README must contain
- That the click write happens after the redirect is returned, and how to measure the redirect at the edge
- That the import windows live in configuration with the date they were checked, and where to recheck them
- That unmatched conversions are counted and never attributed to the most recent click
- What the expired count means and why a non-zero value is a bug rather than a statistic
- That refunds are reversals and never deletions, and how the two counts reconcile in the export
===== ARCHITECTURE.md =====
# Architecture · LnkFlow
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Edge | A single edge worker on your own subdomain, with the slug map in key-value storage | the redirect has to be fast and it must not wait on a write |
| Click log | A relational store at the edge, written after the response is handed back | a click nobody logged is a conversion nobody can attribute |
| Identity | A first-party cookie on your own domain plus a server-side click record | the join happens on your side, not in somebody else's browser |
| Attribution | An explicit join table with a deadline column, not a view over two tables | a conversion has an expiry and an expiry needs somewhere to live |
| Upload | A queue with retries, deduplication keys and a dead letter state | silent failure here is the expensive one, because the dashboard still looks fine |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Redirect | Slug resolution, parameter injection and the fast path. | Any edge runtime, provided the write never blocks the response. |
| Identity | First-party visitor identifiers and consent handling. | Any scheme, provided it does not derive identity from an ad identifier. |
| Attribution | Matching, match methods and unmatched counts. | Any rule set, provided a fallback never writes a match method it did not use. |
| Upload | Deadline-ordered queue, deduplication keys and expiry. | Any transport, provided expired conversions are recorded rather than retried. |
| Reversal | Refunds as adjustments linked to originals. | Not swappable for deletion, which leaves the platform optimising for refunds. |
## Configuration
Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists.
- `SHORT_DOMAIN` · required · The domain the worker answers on and the cookie is set for.
- `CONVERSION_API_TOKEN` · required, secret · Authenticates your own application calling the conversion endpoint.
- `ADS_PLATFORM_SECRET` · required, secret · Ad platform credentials. Kept in the platform secret store, never in the worker config file.
- `PLATFORM_CONFIG_PATH` · required · Import windows and their checked-on dates.
- `COOKIE_TTL_DAYS` · required · How long a visitor identifier survives. Keeping it longer than the import window buys nothing.
- `ADMIN_PASSWORD` · required, secret · The only gate on the link editor and the dashboard.
## Production baseline
- Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives.
- Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised.
- Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts.
- Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists.
- Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path.
## Decision records
For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
===== AGENTS.md =====
# Agent instructions · LnkFlow product build
- Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A single edge worker on your own subdomain, with the slug map in key-value storage, A relational store at the edge, written after the response is handed back, A first-party cookie on your own domain plus a server-side click record, An explicit join table with a deadline column, not a view over two tables, A queue with retries, deduplication keys and a dead letter state.
- Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit.
- Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present.
- Never invent cryptography or silently weaken a requirement to make a check pass.
- Put every external service behind an interface with a deterministic fake for tests.
- Add migrations and rollback or recovery notes for every persistent data change.
- Log useful operational context without credentials, tokens, passwords or personal data.
- Update documentation and run every check before completing a milestone.
## Known traps
- Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop.
- Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent.
- Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired.
- A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total.
- Deleting a conversion row on refund makes your own reports correct and leaves the platform holding a purchase that never stuck, which is the exact condition that trains a bidder towards buyers who refund. Keep the original, add a reversal, upload the adjustment, and reconcile the two counts in the export rather than in your head.
===== MILESTONES.md =====
# Delivery milestones · LnkFlow
Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · A redirect that does not wait for anything
A request to your domain resolves and redirects immediately, and the click is logged afterwards.
### Steps
1. Return the redirect before awaiting the write
Latency here is paid by every single visitor.
Files: `src/worker/redirect.ts`, `tests/redirect-latency.test.ts`
2. Pass ad click identifiers through unchanged
Rewriting one of them is how attribution dies quietly.
Files: `src/worker/params.ts`
3. Refuse unknown slugs rather than guessing
A default destination turns a typo into traffic you cannot explain.
Files: `src/worker/resolve.ts`
### Done when
- [ ] The redirect does not wait on the click write
- [ ] Ad click identifiers reach the destination intact
- [ ] Unknown slugs return not-found
- [ ] Responses are marked no-index and no-store
### Watch out
- Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop.
## M2 · A visitor identity you own
A click can be recognised again later from your own domain, without borrowing anybody else's identifier.
### Steps
1. Generate your own opaque visitor identifier
Borrowing one couples your join to somebody else's format.
Files: `src/worker/visitor.ts`
2. Store the import deadline on the click
It is measured from the click and nothing else knows it.
Files: `src/db/schema.sql`, `tests/deadline.test.ts`
3. Log a consent-withheld click without identity
The count still matters even when the join cannot happen.
Files: `src/worker/consent.ts`
### Done when
- [ ] Visitor identifiers are independent of ad identifiers
- [ ] Clicks without ad identifiers still work
- [ ] Deadlines are computed and stored at click time
- [ ] Consent-withheld clicks log counts only
### Watch out
- Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent.
## M3 · Joining a sale to a click days later
A conversion reported from your own application finds the click that caused it, or admits it cannot.
### Steps
1. Record the match method on every conversion
An unlabelled match is indistinguishable from a guess.
Files: `src/attribution/match.ts`, `tests/match.test.ts`
2. Store unmatched conversions as unmatched
The count is the honest number and people need to see it.
Files: `src/attribution/store.ts`
3. Reject duplicate order references
A retried webhook should not double your revenue.
Files: `src/api/conversion.ts`
### Done when
- [ ] Match method is recorded on every conversion
- [ ] Unmatched conversions are stored, not guessed
- [ ] Duplicate order references are rejected
- [ ] Unmatched counts appear beside matched ones
### Watch out
- Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired.
## M4 · The import window is a deadline, not a preference
Matched conversions reach the platform inside its window, and the ones that miss it are counted rather than lost.
### Steps
1. Put the published windows in config with a checked-on date
These numbers change and a constant will not tell you.
Files: `config/platforms.json`, `src/upload/windows.ts`
2. Order the queue by nearest deadline
First in, first out is the wrong order when things expire.
Files: `src/upload/queue.ts`, `tests/queue-order.test.ts`
3. Expire loudly, on the dashboard
An expired conversion is money the bidder never learned about.
Files: `src/web/dashboard.ts`
### Done when
- [ ] Past-deadline conversions expire instead of uploading
- [ ] The queue is ordered by nearest deadline
- [ ] Retries reuse a stable deduplication key
- [ ] Expired counts are visible beside the totals
### Watch out
- A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total.
## M5 · Refunds, exports and what you refuse to run (production only)
A reversed sale is reported as reversed, everything exports, and the parts you will not operate are documented rather than half-built.
### Steps
1. Reverse, never delete
The platform already has the purchase; it needs the correction.
Files: `src/attribution/reverse.ts`, `tests/reverse.test.ts`
2. Export every table, including expired
The expired table is the one that explains the gap.
Files: `src/export/csv.ts`
3. Keep secrets out of worker configuration
That file gets committed, every time.
Files: `tests/no-secrets-in-config.test.ts`
### Done when
- [ ] A refund produces a reversal and an adjustment
- [ ] Export and import round-trip every table
- [ ] No secret is committed in configuration
- [ ] Out-of-scope certificate work is documented
### Watch out
- Deleting a conversion row on refund makes your own reports correct and leaves the platform holding a purchase that never stuck, which is the exact condition that trains a bidder towards buyers who refund. Keep the original, add a reversal, upload the adjustment, and reconcile the two counts in the export rather than in your head.
===== OPERATIONS.md =====
# Operations · LnkFlow
## Backup
The click log and the conversion tables. The link map can be rebuilt from an export, but a lost click is an attribution that can never be recovered because the deadline keeps running.
## Restore
Restore, then check that the nearest-deadline conversion in the queue is still inside its window. A restore that took a day can itself expire conversions, which is a failure mode worth knowing about before it happens.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
Four numbers on one page: matched, unmatched, expired and upload failures. The expired count should be zero, and when it is not the cause is almost always a stopped queue rather than a slow one.
## Incident checklist
The incident that matters is silent: uploads failing while the dashboard still shows revenue. Compare your matched conversion count against the platform's own reported count weekly, because that discrepancy is the only external check you have.
1. Contain the issue without destroying evidence or user data.
2. Record the timeline and affected scope.
3. Rotate exposed secrets and revoke compromised sessions or credentials.
4. Restore from a verified backup when needed.
5. Document the root cause, the remediation and the regression test.
## Release gate
- [ ] The redirect is confirmed not to wait on the click write
- [ ] Visitor identity is confirmed independent of ad click identifiers
- [ ] Unmatched conversions are confirmed stored as unmatched
- [ ] Past-deadline conversions are confirmed expired rather than uploaded
- [ ] A refund is confirmed to produce a reversal, not a deletion
## Launch constraint
Do not market omitted LnkFlow capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. The domain the worker answers on and the cookie is set for.
SHORT_DOMAIN=go.example.com
# Required · secret. Authenticates your own application calling the conversion endpoint.
CONVERSION_API_TOKEN=redacted
# Required · secret. Ad platform credentials. Kept in the platform secret store, never in the worker config file.
ADS_PLATFORM_SECRET=redacted
# Required. Import windows and their checked-on dates.
PLATFORM_CONFIG_PATH=./config/platforms.json
# Required. How long a visitor identifier survives. Keeping it longer than the import window buys nothing.
COOKIE_TTL_DAYS=90
# Required · secret. The only gate on the link editor and the dashboard.
ADMIN_PASSWORD=redacted
# LnkFlow · indie build A short link service whose hard problems are logging a click without slowing the redirect, joining a sale that happens days later on a different device back to the click that caused it, and getting that conversion out to an ad platform before the platform's import window closes, which is a deadline nobody warns you about. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Edge | A single edge worker on your own subdomain, with the slug map in key-value storage | the redirect has to be fast and it must not wait on a write | | Click log | A relational store at the edge, written after the response is handed back | a click nobody logged is a conversion nobody can attribute | | Identity | A first-party cookie on your own domain plus a server-side click record | the join happens on your side, not in somebody else's browser | | Attribution | An explicit join table with a deadline column, not a view over two tables | a conversion has an expiry and an expiry needs somewhere to live | | Upload | A queue with retries, deduplication keys and a dead letter state | silent failure here is the expensive one, because the dashboard still looks fine | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **An edge platform account with key-value and relational storage** · free tiers cover a personal volume comfortably - Why: The redirect needs to run close to the visitor and the click log needs somewhere durable to land. - Get it: Create the account, the key-value namespace and the database, and install the platform's command line tool. - Verify: A deployed worker returns a redirect from a slug you created - [ ] **A domain or subdomain you control** · the price of a domain - Why: A first-party cookie has to be set on a domain you own, and the whole identity design rests on that. - Get it: Point a record at the worker following the platform's instructions. - Verify: The short domain resolves and sets the cookie - [ ] **API access to at least one ad platform** · free to obtain; approval can take days - Why: Without an upload path the build stops at a dashboard, which is the part that was never worth paying for. - Get it: Create developer credentials in the platform's console and request whatever access level conversion upload requires. - Verify: A test conversion appears in the platform's own reporting - [ ] **The current import windows, written down with dates** · free - Why: The deadline is computed at click time from these numbers, so a wrong one silently expires conversions months later. - Get it: Read each platform's own documentation on offline conversion import and record the window and the date you read it. - Verify: Every platform in the configuration has a window and a checked-on date ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Certificates for domains you do not own. - A browser pixel on the destination site. - Identity resolution beyond what your application carries. - Per-creator commission reporting. - Purchase events delivered back to Meta CAPI with stable event_id deduplication against the browser pixel - Google offline conversion import keyed on gclid, gbraid, or wbraid - click to lead to sale attribution that survives a multi-day gap, with refund reversal - TLS on a customer's branded domain (Cloudflare for SaaS and 90-day DCV renewals, not one DNS record) - per-creator performance and commission reporting If one of those is essential to you, that is the reason to keep paying for LnkFlow, and the README should say so rather than pretend.
# Build brief · LnkFlow The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a branded redirector that keeps a click identity long enough to be worth reporting, and knows when it has run out of time to report it to replace LnkFlow. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Edge: A single edge worker on your own subdomain, with the slug map in key-value storage · the redirect has to be fast and it must not wait on a write - Click log: A relational store at the edge, written after the response is handed back · a click nobody logged is a conversion nobody can attribute - Identity: A first-party cookie on your own domain plus a server-side click record · the join happens on your side, not in somebody else's browser - Attribution: An explicit join table with a deadline column, not a view over two tables · a conversion has an expiry and an expiry needs somewhere to live - Upload: A queue with retries, deduplication keys and a dead letter state · silent failure here is the expensive one, because the dashboard still looks fine ### Data model (create this before Phase 1) - links(slug, destination, utm_json, created_at, archived_at, owner_note) - clicks(id, slug, at, country, user_agent_hash, referer, click_ids_json, visitor_id, import_deadline_at) · the deadline is computed at click time, not at conversion time - visitors(visitor_id, first_seen_at, last_seen_at) · a first-party identifier you set, nothing borrowed - conversions(id, at, kind, value_minor, currency, order_ref, visitor_id, matched_click_id, match_method, reversed_at) - uploads(id, conversion_id, platform, attempt, status, dedup_key, request_summary, response_code, response_excerpt, at) - expired(id, conversion_id, reason, noticed_at) · conversions that could never be uploaded, counted and shown rather than dropped - The deadline lives on the click, because it is measured from the click. Storing it on the conversion means computing it from a timestamp you may no longer have. ### Phase 1 · A redirect that does not wait for anything Build: An edge worker reading the slug from key-value storage and returning a redirect straight away, with the click write handed to the platform's after-response mechanism. Inject the link's own campaign parameters and pass any incoming ad click identifiers through to the destination untouched. Mark the response as not indexable and not cacheable. Trap: Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. Done when: The redirect response is produced before the click write is awaited, verified by review and by a test that stalls the write and still returns promptly. Incoming ad click identifiers arrive at the destination unmodified, tested with a request carrying several. An unknown slug returns a plain not-found rather than a redirect to a default, tested. The response carries headers preventing indexing and caching, checked against a live request. Do not build yet: No visitor identity, no conversions, no uploads. ### Phase 2 · A visitor identity you own Build: Set a first-party cookie on your redirect domain carrying a random visitor identifier, and store the same identifier on the click row. Record the ad click identifiers alongside it. Compute and store an import deadline on the click from the platform's published window, and do it at click time. Honour a do-not-track or consent signal by logging the click without the identifier. Trap: Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. Done when: The visitor identifier is random and independent of any ad click identifier, asserted in the suite. A click with no ad identifiers still produces a usable visitor record, tested. The import deadline is computed at click time from configured platform windows and stored on the click row, tested at the boundary. With consent withheld, the click is logged with counts but no visitor identifier, tested. Do not build yet: No conversion recording, no matching, no uploads. ### Phase 3 · Joining a sale to a click days later Build: An authenticated endpoint your application calls when a sale completes, carrying an order reference, a value and whatever identity it has. Match on the visitor identifier first, then on an ad click identifier if your application captured one, and record which method matched. Unmatched conversions are stored as unmatched rather than attached to the most recent click. Trap: Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. Done when: Every conversion stores a match method and an unmatched conversion is stored as unmatched rather than guessed, asserted in the suite. A conversion arriving nine days after the click on a different device still matches when the visitor identifier was carried by your own application, tested. The endpoint refuses unauthenticated calls and duplicate order references, tested. The unmatched count is displayed beside the matched count everywhere a total appears. Do not build yet: No uploads to any platform, no reversal, no exports. ### Phase 4 · The import window is a deadline, not a preference Build: A queue that uploads matched conversions with a deduplication key stable across retries, and a scheduler that prioritises conversions nearest their deadline. Read the current published import windows yourself and put them in configuration: when checked in September 2026, Google's guidance was that conversions uploaded more than 90 days after the associated last click are not imported, with a shorter 63 day window for enhanced conversions for leads. Trap: A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. Done when: A conversion whose deadline has passed is moved to expired with a reason and never uploaded, tested at the boundary with a configured window. The upload queue is ordered by nearest deadline, tested with a mixed backlog. Retries reuse the same deduplication key so a duplicate arrival is not a second conversion, tested. Expired counts appear on the dashboard beside matched and unmatched counts, and a non-zero count is visibly a problem. Do not build yet: No refund reversal yet, no export, no key rotation. ### Phase 5 · Refunds, exports and what you refuse to run Build: Record a refund as a reversal linked to the original conversion and upload it as an adjustment rather than deleting the row. Export links, clicks, conversions and uploads as CSV. Keep secrets out of the worker configuration file. Write down what this build does not do: certificates for domains you do not own, and any per-customer branded domain issuance. Trap: Deleting a conversion row on refund makes your own reports correct and leaves the platform holding a purchase that never stuck, which is the exact condition that trains a bidder towards buyers who refund. Keep the original, add a reversal, upload the adjustment, and reconcile the two counts in the export rather than in your head. Done when: A refund creates a reversal row, leaves the original intact and produces an adjustment upload, tested end to end against a stubbed platform. Export produces links, clicks, conversions, uploads and expired rows as CSV and reimports into an empty database with equal counts. No secret appears in any committed configuration file, asserted by a test that scans the repository. The README states plainly that certificates for other people's domains are out of scope and why. Do not build yet: Nothing after this. ### Out of scope (and why) - Certificates for domains you do not own, which is a hosted issuance and renewal problem rather than one DNS record, and is a real part of why agencies pay for this. - Browser-side pixel installation on the destination site, since the conversion endpoint is called by your own application where you already know the order reference. - Cross-platform identity resolution beyond what your own application can carry, because everything past that is inference presented as fact. - Per-creator commission reporting, which is a payments problem attached to an attribution problem and deserves its own build. ### README must contain - That the click write happens after the redirect is returned, and how to measure the redirect at the edge - That the import windows live in configuration with the date they were checked, and where to recheck them - That unmatched conversions are counted and never attributed to the most recent click - What the expired count means and why a non-zero value is a bug rather than a statistic - That refunds are reversals and never deletions, and how the two counts reconcile in the export
# Agent instructions · LnkFlow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A single edge worker on your own subdomain, with the slug map in key-value storage, A relational store at the edge, written after the response is handed back, A first-party cookie on your own domain plus a server-side click record, An explicit join table with a deadline column, not a view over two tables, A queue with retries, deduplication keys and a dead letter state. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. - Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. - Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. - A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total.
# Build plan · LnkFlow A short link service whose hard problems are logging a click without slowing the redirect, joining a sale that happens days later on a different device back to the click that caused it, and getting that conversion out to an ad platform before the platform's import window closes, which is a deadline nobody warns you about. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A redirect that does not wait for anything A request to your domain resolves and redirects immediately, and the click is logged afterwards. ### Steps 1. Return the redirect before awaiting the write Latency here is paid by every single visitor. Files: `src/worker/redirect.ts`, `tests/redirect-latency.test.ts` 2. Pass ad click identifiers through unchanged Rewriting one of them is how attribution dies quietly. Files: `src/worker/params.ts` 3. Refuse unknown slugs rather than guessing A default destination turns a typo into traffic you cannot explain. Files: `src/worker/resolve.ts` ### Done when - [ ] The redirect does not wait on the click write - [ ] Ad click identifiers reach the destination intact - [ ] Unknown slugs return not-found - [ ] Responses are marked no-index and no-store ### Watch out - Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. ## Phase 2 · A visitor identity you own A click can be recognised again later from your own domain, without borrowing anybody else's identifier. ### Steps 1. Generate your own opaque visitor identifier Borrowing one couples your join to somebody else's format. Files: `src/worker/visitor.ts` 2. Store the import deadline on the click It is measured from the click and nothing else knows it. Files: `src/db/schema.sql`, `tests/deadline.test.ts` 3. Log a consent-withheld click without identity The count still matters even when the join cannot happen. Files: `src/worker/consent.ts` ### Done when - [ ] Visitor identifiers are independent of ad identifiers - [ ] Clicks without ad identifiers still work - [ ] Deadlines are computed and stored at click time - [ ] Consent-withheld clicks log counts only ### Watch out - Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. ## Phase 3 · Joining a sale to a click days later A conversion reported from your own application finds the click that caused it, or admits it cannot. ### Steps 1. Record the match method on every conversion An unlabelled match is indistinguishable from a guess. Files: `src/attribution/match.ts`, `tests/match.test.ts` 2. Store unmatched conversions as unmatched The count is the honest number and people need to see it. Files: `src/attribution/store.ts` 3. Reject duplicate order references A retried webhook should not double your revenue. Files: `src/api/conversion.ts` ### Done when - [ ] Match method is recorded on every conversion - [ ] Unmatched conversions are stored, not guessed - [ ] Duplicate order references are rejected - [ ] Unmatched counts appear beside matched ones ### Watch out - Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. ## Phase 4 · The import window is a deadline, not a preference Matched conversions reach the platform inside its window, and the ones that miss it are counted rather than lost. ### Steps 1. Put the published windows in config with a checked-on date These numbers change and a constant will not tell you. Files: `config/platforms.json`, `src/upload/windows.ts` 2. Order the queue by nearest deadline First in, first out is the wrong order when things expire. Files: `src/upload/queue.ts`, `tests/queue-order.test.ts` 3. Expire loudly, on the dashboard An expired conversion is money the bidder never learned about. Files: `src/web/dashboard.ts` ### Done when - [ ] Past-deadline conversions expire instead of uploading - [ ] The queue is ordered by nearest deadline - [ ] Retries reuse a stable deduplication key - [ ] Expired counts are visible beside the totals ### Watch out - A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. ## Not in this build - Certificates for domains you do not own. - A browser pixel on the destination site. - Identity resolution beyond what your application carries. - Per-creator commission reporting. ## After v1, if you want it - A weekly reconciliation against each platform's own reported conversion count, which is the only independent check that exists - A second ad platform behind the same queue, which is mostly a window number and a request shape - A link expiry and destination history, so a campaign that ends stops sending people to a dead page
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The domain the worker answers on and the cookie is set for. SHORT_DOMAIN=go.example.com # Required · secret. Authenticates your own application calling the conversion endpoint. CONVERSION_API_TOKEN=redacted # Required · secret. Ad platform credentials. Kept in the platform secret store, never in the worker config file. ADS_PLATFORM_SECRET=redacted # Required. Import windows and their checked-on dates. PLATFORM_CONFIG_PATH=./config/platforms.json # Required. How long a visitor identifier survives. Keeping it longer than the import window buys nothing. COOKIE_TTL_DAYS=90 # Required · secret. The only gate on the link editor and the dashboard. ADMIN_PASSWORD=redacted
# LnkFlow · product brief
## Problem
A short link that 302s, logs the click, and injects UTMs is genuinely one sitting on Cloudflare Workers and KV, and the prompt below builds exactly that. What is not one sitting is the loop back out to the ad platforms. Meta and Google bid against the conversion events you send them, and since ATT the browser pixel misses a large share of purchases, which is the entire reason the Conversions API and offline conversion import exist. Closing that loop means persisting fbclid and gclid at redirect time, carrying that identity to a sale that happens days later on another device, posting a Purchase with a stable event_id so it deduplicates against the pixel, rebuilding fbc in Meta's fb.1.{timestamp}.{fbclid} form, importing offline conversions to Google on gclid, gbraid, or wbraid, and reversing all of it on refund. Every one of those fails silently. That is the expensive part: the dashboard still shows clicks while the bidder optimizes against nothing.
## Product outcome
A redirector whose clicks cost the visitor nothing in latency, whose attribution says out loud when it did not match, and which treats the platform's import window as a deadline with a counter rather than a detail in somebody's documentation.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- Cloudflare account (Workers, KV, D1)
- a domain you control
- wrangler CLI
## Explicit non-goals for v1
- Certificates for domains you do not own.
- A browser pixel on the destination site.
- Identity resolution beyond what your application carries.
- Per-creator commission reporting.
- Purchase events delivered back to Meta CAPI with stable event_id deduplication against the browser pixel
- Google offline conversion import keyed on gclid, gbraid, or wbraid
- click to lead to sale attribution that survives a multi-day gap, with refund reversal
- TLS on a customer's branded domain (Cloudflare for SaaS and 90-day DCV renewals, not one DNS record)
- per-creator performance and commission reporting
## Success criteria
- The redirect is confirmed not to wait on the click write
- Visitor identity is confirmed independent of ad click identifiers
- Unmatched conversions are confirmed stored as unmatched
- Past-deadline conversions are confirmed expired rather than uploaded
- A refund is confirmed to produce a reversal, not a deletion# Build brief · LnkFlow The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a branded redirector that keeps a click identity long enough to be worth reporting, and knows when it has run out of time to report it to replace LnkFlow. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Edge: A single edge worker on your own subdomain, with the slug map in key-value storage · the redirect has to be fast and it must not wait on a write - Click log: A relational store at the edge, written after the response is handed back · a click nobody logged is a conversion nobody can attribute - Identity: A first-party cookie on your own domain plus a server-side click record · the join happens on your side, not in somebody else's browser - Attribution: An explicit join table with a deadline column, not a view over two tables · a conversion has an expiry and an expiry needs somewhere to live - Upload: A queue with retries, deduplication keys and a dead letter state · silent failure here is the expensive one, because the dashboard still looks fine ### Data model (create this before Phase 1) - links(slug, destination, utm_json, created_at, archived_at, owner_note) - clicks(id, slug, at, country, user_agent_hash, referer, click_ids_json, visitor_id, import_deadline_at) · the deadline is computed at click time, not at conversion time - visitors(visitor_id, first_seen_at, last_seen_at) · a first-party identifier you set, nothing borrowed - conversions(id, at, kind, value_minor, currency, order_ref, visitor_id, matched_click_id, match_method, reversed_at) - uploads(id, conversion_id, platform, attempt, status, dedup_key, request_summary, response_code, response_excerpt, at) - expired(id, conversion_id, reason, noticed_at) · conversions that could never be uploaded, counted and shown rather than dropped - The deadline lives on the click, because it is measured from the click. Storing it on the conversion means computing it from a timestamp you may no longer have. ### Phase 1 · A redirect that does not wait for anything Build: An edge worker reading the slug from key-value storage and returning a redirect straight away, with the click write handed to the platform's after-response mechanism. Inject the link's own campaign parameters and pass any incoming ad click identifiers through to the destination untouched. Mark the response as not indexable and not cacheable. Trap: Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. Done when: The redirect response is produced before the click write is awaited, verified by review and by a test that stalls the write and still returns promptly. Incoming ad click identifiers arrive at the destination unmodified, tested with a request carrying several. An unknown slug returns a plain not-found rather than a redirect to a default, tested. The response carries headers preventing indexing and caching, checked against a live request. Do not build yet: No visitor identity, no conversions, no uploads. ### Phase 2 · A visitor identity you own Build: Set a first-party cookie on your redirect domain carrying a random visitor identifier, and store the same identifier on the click row. Record the ad click identifiers alongside it. Compute and store an import deadline on the click from the platform's published window, and do it at click time. Honour a do-not-track or consent signal by logging the click without the identifier. Trap: Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. Done when: The visitor identifier is random and independent of any ad click identifier, asserted in the suite. A click with no ad identifiers still produces a usable visitor record, tested. The import deadline is computed at click time from configured platform windows and stored on the click row, tested at the boundary. With consent withheld, the click is logged with counts but no visitor identifier, tested. Do not build yet: No conversion recording, no matching, no uploads. ### Phase 3 · Joining a sale to a click days later Build: An authenticated endpoint your application calls when a sale completes, carrying an order reference, a value and whatever identity it has. Match on the visitor identifier first, then on an ad click identifier if your application captured one, and record which method matched. Unmatched conversions are stored as unmatched rather than attached to the most recent click. Trap: Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. Done when: Every conversion stores a match method and an unmatched conversion is stored as unmatched rather than guessed, asserted in the suite. A conversion arriving nine days after the click on a different device still matches when the visitor identifier was carried by your own application, tested. The endpoint refuses unauthenticated calls and duplicate order references, tested. The unmatched count is displayed beside the matched count everywhere a total appears. Do not build yet: No uploads to any platform, no reversal, no exports. ### Phase 4 · The import window is a deadline, not a preference Build: A queue that uploads matched conversions with a deduplication key stable across retries, and a scheduler that prioritises conversions nearest their deadline. Read the current published import windows yourself and put them in configuration: when checked in September 2026, Google's guidance was that conversions uploaded more than 90 days after the associated last click are not imported, with a shorter 63 day window for enhanced conversions for leads. Trap: A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. Done when: A conversion whose deadline has passed is moved to expired with a reason and never uploaded, tested at the boundary with a configured window. The upload queue is ordered by nearest deadline, tested with a mixed backlog. Retries reuse the same deduplication key so a duplicate arrival is not a second conversion, tested. Expired counts appear on the dashboard beside matched and unmatched counts, and a non-zero count is visibly a problem. Do not build yet: No refund reversal yet, no export, no key rotation. ### Phase 5 · Refunds, exports and what you refuse to run Build: Record a refund as a reversal linked to the original conversion and upload it as an adjustment rather than deleting the row. Export links, clicks, conversions and uploads as CSV. Keep secrets out of the worker configuration file. Write down what this build does not do: certificates for domains you do not own, and any per-customer branded domain issuance. Trap: Deleting a conversion row on refund makes your own reports correct and leaves the platform holding a purchase that never stuck, which is the exact condition that trains a bidder towards buyers who refund. Keep the original, add a reversal, upload the adjustment, and reconcile the two counts in the export rather than in your head. Done when: A refund creates a reversal row, leaves the original intact and produces an adjustment upload, tested end to end against a stubbed platform. Export produces links, clicks, conversions, uploads and expired rows as CSV and reimports into an empty database with equal counts. No secret appears in any committed configuration file, asserted by a test that scans the repository. The README states plainly that certificates for other people's domains are out of scope and why. Do not build yet: Nothing after this. ### Out of scope (and why) - Certificates for domains you do not own, which is a hosted issuance and renewal problem rather than one DNS record, and is a real part of why agencies pay for this. - Browser-side pixel installation on the destination site, since the conversion endpoint is called by your own application where you already know the order reference. - Cross-platform identity resolution beyond what your own application can carry, because everything past that is inference presented as fact. - Per-creator commission reporting, which is a payments problem attached to an attribution problem and deserves its own build. ### README must contain - That the click write happens after the redirect is returned, and how to measure the redirect at the edge - That the import windows live in configuration with the date they were checked, and where to recheck them - That unmatched conversions are counted and never attributed to the most recent click - What the expired count means and why a non-zero value is a bug rather than a statistic - That refunds are reversals and never deletions, and how the two counts reconcile in the export
# Architecture · LnkFlow ## Stack | Part | Choice | Why | | --- | --- | --- | | Edge | A single edge worker on your own subdomain, with the slug map in key-value storage | the redirect has to be fast and it must not wait on a write | | Click log | A relational store at the edge, written after the response is handed back | a click nobody logged is a conversion nobody can attribute | | Identity | A first-party cookie on your own domain plus a server-side click record | the join happens on your side, not in somebody else's browser | | Attribution | An explicit join table with a deadline column, not a view over two tables | a conversion has an expiry and an expiry needs somewhere to live | | Upload | A queue with retries, deduplication keys and a dead letter state | silent failure here is the expensive one, because the dashboard still looks fine | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Redirect | Slug resolution, parameter injection and the fast path. | Any edge runtime, provided the write never blocks the response. | | Identity | First-party visitor identifiers and consent handling. | Any scheme, provided it does not derive identity from an ad identifier. | | Attribution | Matching, match methods and unmatched counts. | Any rule set, provided a fallback never writes a match method it did not use. | | Upload | Deadline-ordered queue, deduplication keys and expiry. | Any transport, provided expired conversions are recorded rather than retried. | | Reversal | Refunds as adjustments linked to originals. | Not swappable for deletion, which leaves the platform optimising for refunds. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `SHORT_DOMAIN` · required · The domain the worker answers on and the cookie is set for. - `CONVERSION_API_TOKEN` · required, secret · Authenticates your own application calling the conversion endpoint. - `ADS_PLATFORM_SECRET` · required, secret · Ad platform credentials. Kept in the platform secret store, never in the worker config file. - `PLATFORM_CONFIG_PATH` · required · Import windows and their checked-on dates. - `COOKIE_TTL_DAYS` · required · How long a visitor identifier survives. Keeping it longer than the import window buys nothing. - `ADMIN_PASSWORD` · required, secret · The only gate on the link editor and the dashboard. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · LnkFlow product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A single edge worker on your own subdomain, with the slug map in key-value storage, A relational store at the edge, written after the response is handed back, A first-party cookie on your own domain plus a server-side click record, An explicit join table with a deadline column, not a view over two tables, A queue with retries, deduplication keys and a dead letter state. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. - Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. - Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. - A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. - Deleting a conversion row on refund makes your own reports correct and leaves the platform holding a purchase that never stuck, which is the exact condition that trains a bidder towards buyers who refund. Keep the original, add a reversal, upload the adjustment, and reconcile the two counts in the export rather than in your head.
# Delivery milestones · LnkFlow Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A redirect that does not wait for anything A request to your domain resolves and redirects immediately, and the click is logged afterwards. ### Steps 1. Return the redirect before awaiting the write Latency here is paid by every single visitor. Files: `src/worker/redirect.ts`, `tests/redirect-latency.test.ts` 2. Pass ad click identifiers through unchanged Rewriting one of them is how attribution dies quietly. Files: `src/worker/params.ts` 3. Refuse unknown slugs rather than guessing A default destination turns a typo into traffic you cannot explain. Files: `src/worker/resolve.ts` ### Done when - [ ] The redirect does not wait on the click write - [ ] Ad click identifiers reach the destination intact - [ ] Unknown slugs return not-found - [ ] Responses are marked no-index and no-store ### Watch out - Awaiting the database write before returning the redirect adds the write latency to every click, which is invisible in testing and obvious on a mobile network in another continent, and it is the reason people abandon a link. Return the response first and complete the write after, then measure the redirect at the edge rather than from your own laptop. ## M2 · A visitor identity you own A click can be recognised again later from your own domain, without borrowing anybody else's identifier. ### Steps 1. Generate your own opaque visitor identifier Borrowing one couples your join to somebody else's format. Files: `src/worker/visitor.ts` 2. Store the import deadline on the click It is measured from the click and nothing else knows it. Files: `src/db/schema.sql`, `tests/deadline.test.ts` 3. Log a consent-withheld click without identity The count still matters even when the join cannot happen. Files: `src/worker/consent.ts` ### Done when - [ ] Visitor identifiers are independent of ad identifiers - [ ] Clicks without ad identifiers still work - [ ] Deadlines are computed and stored at click time - [ ] Consent-withheld clicks log counts only ### Watch out - Deriving the visitor identifier from the ad click identifier saves a cookie and then breaks the day a visitor arrives from an email instead of an advertisement, because those clicks now share whatever fallback you chose and the join produces confident nonsense. Generate an opaque identifier of your own and store the ad identifiers as separate fields that may be absent. ## M3 · Joining a sale to a click days later A conversion reported from your own application finds the click that caused it, or admits it cannot. ### Steps 1. Record the match method on every conversion An unlabelled match is indistinguishable from a guess. Files: `src/attribution/match.ts`, `tests/match.test.ts` 2. Store unmatched conversions as unmatched The count is the honest number and people need to see it. Files: `src/attribution/store.ts` 3. Reject duplicate order references A retried webhook should not double your revenue. Files: `src/api/conversion.ts` ### Done when - [ ] Match method is recorded on every conversion - [ ] Unmatched conversions are stored, not guessed - [ ] Duplicate order references are rejected - [ ] Unmatched counts appear beside matched ones ### Watch out - Falling back to the most recent click within some window when nothing matches produces attribution that looks complete and is partly invented, and because it is never obviously wrong it quietly steers the bidding. Record the match method on every conversion, keep unmatched ones visible with their own count, and never let a fallback write a match_method of anything but the rule that actually fired. ## M4 · The import window is a deadline, not a preference Matched conversions reach the platform inside its window, and the ones that miss it are counted rather than lost. ### Steps 1. Put the published windows in config with a checked-on date These numbers change and a constant will not tell you. Files: `config/platforms.json`, `src/upload/windows.ts` 2. Order the queue by nearest deadline First in, first out is the wrong order when things expire. Files: `src/upload/queue.ts`, `tests/queue-order.test.ts` 3. Expire loudly, on the dashboard An expired conversion is money the bidder never learned about. Files: `src/web/dashboard.ts` ### Done when - [ ] Past-deadline conversions expire instead of uploading - [ ] The queue is ordered by nearest deadline - [ ] Retries reuse a stable deduplication key - [ ] Expired counts are visible beside the totals ### Watch out - A nightly upload job that retries failures forever looks healthy and quietly accumulates conversions past their window, and because a rejected import is not an error in your own system the dashboard keeps showing revenue the bidder never saw. Order the queue by deadline, move anything past it into an expired table with a reason, and put that count on the front page next to the matched total. ## M5 · Refunds, exports and what you refuse to run (production only) A reversed sale is reported as reversed, everything exports, and the parts you will not operate are documented rather than half-built. ### Steps 1. Reverse, never delete The platform already has the purchase; it needs the correction. Files: `src/attribution/reverse.ts`, `tests/reverse.test.ts` 2. Export every table, including expired The expired table is the one that explains the gap. Files: `src/export/csv.ts` 3. Keep secrets out of worker configuration That file gets committed, every time. Files: `tests/no-secrets-in-config.test.ts` ### Done when - [ ] A refund produces a reversal and an adjustment - [ ] Export and import round-trip every table - [ ] No secret is committed in configuration - [ ] Out-of-scope certificate work is documented ### Watch out - Deleting a conversion row on refund makes your own reports correct and leaves the platform holding a purchase that never stuck, which is the exact condition that trains a bidder towards buyers who refund. Keep the original, add a reversal, upload the adjustment, and reconcile the two counts in the export rather than in your head.
# Operations · LnkFlow ## Backup The click log and the conversion tables. The link map can be rebuilt from an export, but a lost click is an attribution that can never be recovered because the deadline keeps running. ## Restore Restore, then check that the nearest-deadline conversion in the queue is still inside its window. A restore that took a day can itself expire conversions, which is a failure mode worth knowing about before it happens. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Four numbers on one page: matched, unmatched, expired and upload failures. The expired count should be zero, and when it is not the cause is almost always a stopped queue rather than a slow one. ## Incident checklist The incident that matters is silent: uploads failing while the dashboard still shows revenue. Compare your matched conversion count against the platform's own reported count weekly, because that discrepancy is the only external check you have. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The redirect is confirmed not to wait on the click write - [ ] Visitor identity is confirmed independent of ad click identifiers - [ ] Unmatched conversions are confirmed stored as unmatched - [ ] Past-deadline conversions are confirmed expired rather than uploaded - [ ] A refund is confirmed to produce a reversal, not a deletion ## Launch constraint Do not market omitted LnkFlow capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The domain the worker answers on and the cookie is set for. SHORT_DOMAIN=go.example.com # Required · secret. Authenticates your own application calling the conversion endpoint. CONVERSION_API_TOKEN=redacted # Required · secret. Ad platform credentials. Kept in the platform secret store, never in the worker config file. ADS_PLATFORM_SECRET=redacted # Required. Import windows and their checked-on dates. PLATFORM_CONFIG_PATH=./config/platforms.json # Required. How long a visitor identifier survives. Keeping it longer than the import window buys nothing. COOKIE_TTL_DAYS=90 # Required · secret. The only gate on the link editor and the dashboard. ADMIN_PASSWORD=redacted
$ choose a build depth, inspect the files, then open the complete pack in your agent
The redirect is commodity and nobody should pay much for it. What is not commodity is staying current with Meta and Google as they change conversion endpoints, identifier formats, and consent requirements, which they do continuously and without waiting for you. Paying outsources that upkeep plus the attribution store that connects a click to a sale days later on a different device. Agencies pay for a second reason: issuing and renewing TLS for a client's branded domain is a genuinely different problem from pointing your own DNS record at a worker.
xPurchase events delivered back to Meta CAPI with stable event_id deduplication against the browser pixel
xGoogle offline conversion import keyed on gclid, gbraid, or wbraid
xclick to lead to sale attribution that survives a multi-day gap, with refund reversal
xTLS on a customer's branded domain (Cloudflare for SaaS and 90-day DCV renewals, not one DNS record)
xper-creator performance and commission reporting
LnkFlow pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $10.39/workspace | $10.39/workspace | 10,000 tracked clicks/month, 50 active links, 1 branded domain, 1 month history, and 1 user |
| growth | $45.01/workspace | $22.50/workspace | 100,000 tracked clicks/month, 500 active links, 3 branded domains, 1 year history, and unlimited team seats |
| scale | $114.25/workspace | $57.12/workspace | 500,000 tracked clicks/month, unlimited active links, 10 branded domains, 2 years history, and unlimited team seats |
| enterprise | custom | — | Custom traffic, link, domain, history, and team allowances |
free tierno free tier
billingmonthly + annual; 7-day trial with no card; 14-day money-back guarantee; prices exclude possible taxes
hidden costsGrowth and Scale annual prices are capacity-limited launch offers rather than normal list prices; going beyond the click, active-link, branded-domain, or history caps requires a higher plan or custom terms.
verified 2026-08-12 · source ↗
Vibecode LnkFlow
Kinda. The core of LnkFlow is buildable in a weekend with the prompt on this page, but there are real gaps: Purchase events delivered back to Meta CAPI with stable event_id deduplication against the browser pixel, Google offline conversion import keyed on gclid, gbraid, or wbraid. Read the honest list above before committing.
How much does LnkFlow cost?
LnkFlow costs about $10.39/month (Starter, checked 2026-08-12), which is $124.68 per year.
What do I lose by replacing LnkFlow?
Honestly: Purchase events delivered back to Meta CAPI with stable event_id deduplication against the browser pixel; Google offline conversion import keyed on gclid, gbraid, or wbraid; click to lead to sale attribution that survives a multi-day gap, with refund reversal; TLS on a customer's branded domain (Cloudflare for SaaS and 90-day DCV renewals, not one DNS record); per-creator performance and commission reporting. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to LnkFlow?
Yes: Dub (The most complete open-source link platform · analytics, custom domains, and a conversion API. Self-hostable.), Shlink (Self-hosted URL shortener with a REST API, click stats, and QR codes.), YOURLS (The long-running PHP shortener · drop it on any LAMP host and it works.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.