---
title: "Tracking and attribution"
description: "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."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.getquickforms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tracking and attribution

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](https://docs.getquickforms.com/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 title="JSON submission with _tracking"
{
  "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](https://docs.getquickforms.com/embed/) and the [hosted form page](https://docs.getquickforms.com/hosted-forms/) 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](#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:

```html title="index.html"
<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](https://docs.getquickforms.com/embed/#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](#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.

```html title="contact.html"
<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`:

```json title="quickformsTrack.get()"
{
  "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](https://docs.getquickforms.com/api/#get-apiv1formsformsubmissions) | 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](https://docs.getquickforms.com/analytics/) | Top `utm_source` values, `utm_campaign` values and referrer domains, and submission counts per ad network click ID. |

The [webhook](https://docs.getquickforms.com/webhooks/) 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](https://docs.getquickforms.com/submit/#rate-limits-and-statuses) counts all your visitors together. Proxy mode fixes that.

Send a team [API key](https://docs.getquickforms.com/api/#authentication) 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](https://docs.getquickforms.com/personal-links/) and so on), so you can pass them straight back to the browser. Include `_tracking` from `quickformsTrack.get()` in the body you forward.

### Next.js

```ts title="app/api/contact/route.ts"
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 });
}
```

### Express

```js title="server.js"
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);
```

### Laravel

```php title="app/Http/Controllers/ContactController.php"
<?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`.

Source: https://docs.getquickforms.com/tracking/index.mdx
