Build LnkFlow
KINDAreplaces $10.39/mosaves $124.68/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- accountfree 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 - have readythe 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 keyfree 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 - decidefree
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
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- 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.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
SHORT_DOMAIN | required | go.example.com | The domain the worker answers on and the cookie is set for. |
CONVERSION_API_TOKENsecret | required | redacted | Authenticates your own application calling the conversion endpoint. |
ADS_PLATFORM_SECRETsecret | required | redacted | Ad platform credentials. Kept in the platform secret store, never in the worker config file. |
PLATFORM_CONFIG_PATH | required | ./config/platforms.json | Import windows and their checked-on dates. |
COOKIE_TTL_DAYS | required | 90 | How long a visitor identifier survives. Keeping it longer than the import window buys nothing. |
ADMIN_PASSWORDsecret | required | redacted | The only gate on the link editor and the dashboard. |
The build, in order
A redirect that does not wait for anything
A request to your domain resolves and redirects immediately, and the click is logged afterwards.
Latency here is paid by every single visitor.
Files
src/worker/redirect.tstests/redirect-latency.test.tsRewriting one of them is how attribution dies quietly.
Files
src/worker/params.tsA default destination turns a typo into traffic you cannot explain.
Files
src/worker/resolve.ts
done when · tick each as it passeswatch 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.
A visitor identity you own
A click can be recognised again later from your own domain, without borrowing anybody else's identifier.
Borrowing one couples your join to somebody else's format.
Files
src/worker/visitor.tsIt is measured from the click and nothing else knows it.
Files
src/db/schema.sqltests/deadline.test.tsThe count still matters even when the join cannot happen.
Files
src/worker/consent.ts
done when · tick each as it passeswatch 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.
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.
An unlabelled match is indistinguishable from a guess.
Files
src/attribution/match.tstests/match.test.tsThe count is the honest number and people need to see it.
Files
src/attribution/store.tsA retried webhook should not double your revenue.
Files
src/api/conversion.ts
done when · tick each as it passeswatch 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.
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.
These numbers change and a constant will not tell you.
Files
config/platforms.jsonsrc/upload/windows.tsFirst in, first out is the wrong order when things expire.
Files
src/upload/queue.tstests/queue-order.test.tsAn expired conversion is money the bidder never learned about.
Files
src/web/dashboard.ts
done when · tick each as it passeswatch 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.
Refunds, exports and what you refuse to runproduct builder
A reversed sale is reported as reversed, everything exports, and the parts you will not operate are documented rather than half-built.
The platform already has the purchase; it needs the correction.
Files
src/attribution/reverse.tstests/reverse.test.tsThe expired table is the one that explains the gap.
Files
src/export/csv.tsThat file gets committed, every time.
Files
tests/no-secrets-in-config.test.ts
done when · tick each as it passeswatch 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.
That is the whole plan for LnkFlow. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more analytics.