Skip to content

feat(redirect): forward inbound query parameters to http(s) destinations - #67

Open
hdeyana wants to merge 1 commit into
LinkForty:mainfrom
hdeyana:feature/forward-inbound-query-params
Open

hdeyana wants to merge 1 commit into
LinkForty:mainfrom
hdeyana:feature/forward-inbound-query-params

Conversation

@hdeyana

@hdeyana hdeyana commented Oct 8, 2026 •

Copy link
Copy Markdown

Description

Forwards the visitor's own query parameters to the http(s) redirect destination, so a link shared as /<code>?v=titanic-ep1 opens an already-installed app (via the Universal Link / App Link) with v=titanic-ep1, the same value a deferred install already gets from the click row.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • ✅ Test addition or update

Related Issues

Fixes #66

Changes Made

  • decorateWebDestination() in src/routes/redirect.ts merges extractLinkParams(request.query) into the destination URL. This covers the Universal Link / App Link 302, the in-app-browser web_fallback_url hop, the desktop destination and the launchpad's "Continue on the web" link.
  • Fill-only: a key already on the destination URL, or set by the link's deep_link_parameters, is never overridden. A public URL can't rewrite e.g. the id of a Play Store link.
  • It reuses extractLinkParams, so reserved utm_* / fp_* / lf_click are excluded and the count/length caps apply, as for the click row.
  • Custom-scheme (app_scheme) URLs are left as they were; happy to extend it there too if you want it.

Testing

  • Tests pass locally (npm test): 410 passed, 2 skipped
  • New tests added for new functionality: src/routes/redirect.query-forwarding.test.ts (8 cases). 7 of them fail without the change; the 8th guards that a request with no parameters is unchanged.
  • Type checking passes (npm run typecheck)
npx vitest run src/routes/redirect.query-forwarding.test.ts

Breaking Changes

None for links whose visitors arrive without a query string, and destinations never lose or change a parameter they already had. The one behavioural difference: extra parameters on an inbound URL now reach the destination instead of being dropped. If you'd rather make this opt-in per link (like append_click_id), I can add the flag and migration.

Checklist

  • My code follows the project's code style
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings or errors
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have used conventional commit messages (e.g., feat:, fix:)
  • I have signed the Contributor License Agreement (the CLA bot will prompt you)

🤖 Generated with Claude Code

A link shared as `/abc123?slug=titanic` stored `slug` on the click row for
deferred installs, but the Universal Link / App Link / web fallback redirect
dropped it, so an app that was already installed opened without it.

decorateWebDestination() now merges extractLinkParams(request.query) into the
destination, fill-only: keys already on the destination URL or set by the
link's deep_link_parameters always win, and reserved utm_*/fp_*/lf_click are
excluded as before.

Closes LinkForty#66

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@CLAassistant

CLAassistant commented Oct 8, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@onamfc

onamfc commented Oct 8, 2026

Copy link
Copy Markdown
Member

Hi @hdeyana! Thank you for the contribution and such a detailed PR description! The PR is currently going through our review process, and I will follow up soon. 🚀

As we review, there's a piece of the picture that isn't visible from this repo, though, and it might be the answer to your case.

Parameter passthrough is implemented in the SDKs.

The server deliberately does not forward these because on the supported path it doesn't need to. The app receives the tapped URL itself, query string intact, and the SDK parses it.

From urlParameters.ts in the React Native SDK

Resolving a short code returns the link's stored configuration; the server has no way to know what was appended to the URL that was actually tapped. The SDK does, having just parsed it. Without this merge a link shared as ?slug=titanic reaches the app with that value missing on a direct open, while the same link after a deferred install carries it — the server merges the click's parameters there.

So ?v=titanic-ep1 arrives in customParameters on a direct open as well as a deferred install, with URL values winning over the link's stored configuration on a key collision. It's documented here: https://docs.linkforty.com/guides/attribution/deferred-deep-linking#url-parameter-passthrough

This is the configuration to reach for. Whichever host serves your short links also needs to serve the association file, so the OS hands your app https://<your-short-host>/<code>?v=titanic-ep1 directly. The server redirect never runs, there's no second hop, and the query string is right there for the SDK to read.

One question so I point you at the right specifics: are you self-hosting @linkforty/core, or running into this on the hosted platform? The setup differs between the two, and it changes what I'd suggest checking first.

For self-hosting, Core serves both files from environment config via the /.well-known/* routes:

File Variables
apple-app-site-association IOS_TEAM_ID, IOS_BUNDLE_ID
assetlinks.json ANDROID_PACKAGE_NAME, ANDROID_SHA256_FINGERPRINTS

With any of those unset the endpoint returns a 404 naming the ones it's missing, so that's worth curling first - it's the quickest way to tell whether the association is actually in place.

One other thing to check: direct-open passthrough has an SDK version floor.

SDK Minimum version
React Native / Expo 1.6.1
iOS 1.4.1
Android 1.3.2
Flutter 0.2.2

Below those, a direct open falls back to the link's stored configuration and customParameters won't carry what was appended to the URL. That alone would produce the symptom you describe.

Your case is real, though

If the association file sits on the short link's host, none of this comes up. Where it does is when your ios_universal_link points at a different domain than the short link, so the chain is short link → server 302 → https://app.example/content → app. The redirect does execute, and it hands off a URL it stripped the query from. At that point the SDK can't recover what isn't in the URL - DeepLinkHandler even warns customParameters will be empty for App Link URLs.

So there's a genuine hole, but I think it might be narrower than the issue frames it. Specifically, the hop where the destination is a Universal Link on a different domain than the short link.

On the change itself

We'll need to consider whether we want a per-link flag for a second-order path, so I'm not going to merge this as it stands. However, I don't want to leave you guessing about why.

Forwarding unconditionally isn't something we can do. Today a short link's destination is a pure function of its stored configuration. With this change, anyone who can hand someone a link can append arbitrary parameters to the destination. Fill-only protects keys the destination already carries - but a bare Universal Link like https://app.example/content carries none, so in the common case every key is open. On a web destination that reaches whatever the page does with unexpected parameters, which is a wider door than it looks. So if this ships, I think it might have to ship as opt-in, the way append_click_id does.

Which leaves the question worth sitting with: whether a new per-link flag and migration is the right amount of permanent surface for a path that serving the association file on the short link's host already handles. I'd rather weigh that properly than take it on reflex, so I'll come back to you with where we land.

I'll leave this open rather than close it - the gap you found is genuine even if the fix isn't settled yet. Thanks again for the submission. The test file in particular is written the way we'd write it, and the fill-only instinct was the right call!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inbound query parameters are dropped when redirecting to the Universal Link / App Link

3 participants