Skip to content

Tracking and attribution

Capture UTM tags, ad click IDs, the referrer and landing page with each submission, with a cookie-free tracking script and a server-side proxy mode.

Send attribution with a submission and quickforms stores it on the submission as meta.tracking. It feeds the top sources, campaigns, referrers and ad click IDs in analytics.

What is captured

Attribution is sent in the _tracking control field. quickforms keeps these keys and drops everything else:

Key What it is
utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id UTM tags.
gclid, gbraid, wbraid Google Ads click IDs.
fbclid Meta click ID.
msclkid Microsoft Ads click ID.
ttclid TikTok click ID.
twclid X click ID.
li_fat_id LinkedIn click ID.
rdt_cid Reddit click ID.
referrer The page the visitor came from.
landing_url The page they landed on, without its query string.

Query strings and fragments are removed from referrer and landing_url, because they can hold reset tokens and personal links. Values are trimmed and capped at 500 characters, or 2048 for referrer and landing_url. Empty values are dropped. The same keys are kept inside optional first and last objects, for first-touch and last-touch attribution. If nothing is left after cleaning, nothing is stored.

_tracking can be a JSON object (for JSON submissions) or a JSON string (for form posts and multipart). It is a control field, so it never ends up in the submission’s data.

JSON submission with _trackingjson
{
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "_tracking": {
    "utm_source": "newsletter",
    "utm_campaign": "spring",
    "gclid": "abc123",
    "referrer": "https://news.example.com/post"
  }
}

Embed and hosted page

The embed widget and the hosted form page send _tracking for you.

  • Embed widget: reads the UTM tags and click IDs (all of the keys above except utm_id and rdt_cid) from the current page’s URL when the form is submitted, plus the referrer if it is from another site and the landing URL. If the tracking script is on the page, it sends that instead.
  • Hosted page: captures the same keys, the referrer and the landing URL on the first load, and keeps them in sessionStorage for that form so they survive later page changes in the tab.

Tracking script

quickforms-track.js is a small, cookie-free script for your own site. Add it to every page, not just the one with the form:

index.htmlhtml
<script src="https://YOUR-QUICKFORMS-HOST/quickforms-track.js" defer></script>

To pin a release with Subresource Integrity, use the embed.tracker_sri tag from the API. See pin the script with SRI.

On each page load it reads the UTM tags and click IDs from the URL (all of the keys in what is captured), plus the referrer if it is from another site and the landing URL. When a page has any of these, it records a touch with a timestamp. It keeps the first touch and the latest touch in localStorage under qf-attribution. Nothing leaves the browser until a form is submitted.

The embed widget picks it up automatically. For your own forms, either:

  • add data-quickforms-track to the <form>. On submit, a hidden _tracking input is added (or updated) with the attribution, or
  • call quickformsTrack.get() and send the result as _tracking yourself.
contact.htmlhtml
<form action="https://YOUR-QUICKFORMS-HOST/f/FORM_ID" method="post" data-quickforms-track>
  <input name="email" type="email" required>
  <button type="submit">Send</button>
</form>

quickformsTrack.get() returns the latest touch’s keys at the top level, plus first and last:

quickformsTrack.get()json
{
  "utm_source": "newsletter",
  "utm_campaign": "spring",
  "landing_url": "https://yoursite.com/pricing",
  "first": { "utm_source": "google", "gclid": "abc123", "landing_url": "https://yoursite.com/", "at": "2026-11-01T09:12:00.000Z" },
  "last": { "utm_source": "newsletter", "utm_campaign": "spring", "landing_url": "https://yoursite.com/pricing", "at": "2026-11-06T10:20:00.000Z" }
}

The at timestamps are not stored, since they are not one of the kept keys.

Method What it does
quickformsTrack.get() The attribution to send with a submission.
quickformsTrack.attach(form) Adds or updates the hidden _tracking input on a form element.
quickformsTrack.clear() Forgets the stored touches.

Where attribution appears

Where How
API In each submission’s meta.tracking.
Exports The tracking key on each row of JSON and NDJSON exports. CSV exports don’t include it.
Analytics Top utm_source values, utm_campaign values and referrer domains, and submission counts per ad network click ID.

The webhook event includes attribution as submission.tracking.

Server-side proxy

You can post your own form to your own backend and forward it to quickforms from there, so the form posts to your own domain and the API key never reaches the browser.

Without proxy mode, every forwarded submission looks like it came from your server: the stored IP and user agent are your server’s, and the rate limit counts all your visitors together. Proxy mode fixes that.

Send a team API key with the proxy ability as Authorization: Bearer <api_key>, plus the visitor’s details in these headers:

Header Value
X-Quickforms-Client-Ip The visitor’s IP address. Ignored unless it is a valid IP.
X-Quickforms-User-Agent The visitor’s user agent.
X-Quickforms-Client-Country Optional. The visitor’s two-letter country code.

The headers are only trusted when the API key is valid, belongs to the form’s team and hasn’t expired. Otherwise they are ignored and the request is treated like any other. With a trusted token:

  • The submission’s meta.ip, meta.user_agent and meta.country come from the headers, and meta.proxied is true.
  • The submission rate limit is per visitor IP rather than per server.

Keep the API key on your server. Responses come back unchanged (201, 422 with field errors, 409 for a used personal link and so on), so you can pass them straight back to the browser. Include _tracking from quickformsTrack.get() in the body you forward.

app/api/contact/route.tsts
import { NextResponse, type NextRequest } from 'next/server';

const QUICKFORMS_URL = process.env.QUICKFORMS_URL!; // e.g. https://quickforms.example.com
const FORM_ID = process.env.QUICKFORMS_FORM_ID!;
const TOKEN = process.env.QUICKFORMS_TOKEN!; // a team API key with the proxy ability
const PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS ?? 1); // reverse proxies in front of this app

// Visitors can send their own X-Forwarded-For, and proxies append to it, so
// only the right-most entries were written by infrastructure you run. Count
// back from the right by your number of proxies. Never use the left-most entry.
function clientIp(request: NextRequest): string {
    const hops = request.headers.get('x-forwarded-for')?.split(',').map((ip) => ip.trim()) ?? [];

    return hops[hops.length - PROXY_HOPS] ?? '';
}

export async function POST(request: NextRequest) {
    const body = await request.json();

    const response = await fetch(`${QUICKFORMS_URL}/f/${FORM_ID}`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            Accept: 'application/json',
            Authorization: `Bearer ${TOKEN}`,
            'X-Quickforms-Client-Ip': clientIp(request),
            'X-Quickforms-User-Agent': request.headers.get('user-agent') ?? '',
        },
        // Pass through the answers plus _tracking from quickformsTrack.get() in the browser.
        body: JSON.stringify(body),
    });

    return NextResponse.json(await response.json(), { status: response.status });
}
server.jsjs
import express from 'express';

const app = express();
app.use(express.json());
// Count the reverse proxies in front of this app (0 if none). With `true`,
// Express believes the left-most X-Forwarded-For entry, which any visitor can
// set themselves. With a count, req.ip is the address your own proxy saw.
app.set('trust proxy', Number(process.env.TRUSTED_PROXY_HOPS ?? 0));

app.post('/api/contact', async (req, res) => {
    const response = await fetch(`${process.env.QUICKFORMS_URL}/f/${process.env.QUICKFORMS_FORM_ID}`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            Accept: 'application/json',
            Authorization: `Bearer ${process.env.QUICKFORMS_TOKEN}`,
            'X-Quickforms-Client-Ip': req.ip,
            'X-Quickforms-User-Agent': req.get('user-agent') ?? '',
        },
        body: JSON.stringify(req.body),
    });

    res.status(response.status).json(await response.json());
});

app.listen(3000);
app/Http/Controllers/ContactController.phpphp
<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

// Route::post('/contact', ContactController::class);
class ContactController
{
    public function __invoke(Request $request): JsonResponse
    {
        $response = Http::withToken(config('services.quickforms.token'))
            ->acceptJson()
            ->withHeaders([
                'X-Quickforms-Client-Ip' => $request->ip(),
                'X-Quickforms-User-Agent' => (string) $request->userAgent(),
            ])
            ->post(config('services.quickforms.url').'/f/'.config('services.quickforms.form_id'), $request->all());

        return response()->json($response->json(), $response->status());
    }
}

These examples are also in the repository under integrations/proxy.

Type to search…

↑↓ navigate↵ selectEsc close