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.
{
"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_idandrdt_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
sessionStoragefor 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:
<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-trackto the<form>. On submit, a hidden_trackinginput is added (or updated) with the attribution, or - call
quickformsTrack.get()and send the result as_trackingyourself.
<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:
{
"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_agentandmeta.countrycome from the headers, andmeta.proxiedistrue. - 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.
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 });
}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);<?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.