Skip to content

Resources

Everything other than analytics queries. Each section maps to one API area; the typed return objects are described inline. Add ->asRaw() to any resource to get arrays instead.

Two writes against a single visitor session: a custom event, and a patch of acquisition data. Both need the visitor’s session id, which only the browser knows. Session handoff explains how it reaches your backend; fromRequest() below reads it back.

// From a Request - the SDK resolves the session id
$event = $snipform->session()->event($request, [
'name' => 'purchase',
'value' => 9900, // optional
'meta' => ['order_id' => 'X-1', 'currency' => 'USD'], // optional
]);
// With an id you stored earlier
$event = $snipform->session()->event([
'session_id' => $order->snipform_session_id,
'name' => 'order_shipped',
]);
$event->id; // string
$event->sessionId; // string
$event->name; // 'purchase'
$event->value; // ?string
$event->meta; // array
$event->createdTs; // ?int, unix seconds

Throws MissingSessionIdException when a Request carries no resolvable id. Call fromRequest() first if you would rather branch than catch.

Only the keys you send are touched. Tags merge with existing tags and are de-duplicated. Each value is recorded as an acquisition event and the session’s value is the roll-up of those events; tag the order as <source>:order:<id> to make a repeat call for the same order update it instead of adding to it. Money is integer cents. A cost key is accepted but ignored: session cost comes from cost entries and connected ad platforms.

$snipform->session()->acquisition($request, [
'value' => 9900, // integer cents
'currency_code' => 'USD', // ISO 4217, defaults to the session's currency, else USD
'tags' => ['affiliate', 'shop:order:10421'],
]);
$snipform->session()->acquisition(['session_id' => $sessionId, 'value' => 9900]);
// returns ['id' => ..., 'acquisition_meta' => [...], 'value' => [...]]
$sessionId = $snipform->session()->fromRequest($request); // string|null

fromRequest() checks, in order:

  1. The server session, under snipform_session_id (stashed by the Laravel hydrate loader on an earlier request).
  2. The X-SnipForm-Session-Id header (set by the tracker’s signals.attachToFetch()).
  3. The snip_session_id form field (set by signals.attachToForm()).
  4. The snip_session_id query parameter.

Any Symfony or Laravel Request works; the class constants Session::SESSION_HEADER, Session::SESSION_FORM_FIELD and Session::SESSION_STASH_KEY name the three transports.

Capture the id while the visitor is on the page, keep it on your own record, and write against it from anywhere afterwards:

// Checkout controller - the visitor is present, so the id resolves
public function store(Request $request, \SnipForm\Client $snipform)
{
$order = Order::create([
// ...
'snipform_session_id' => $snipform->session()->fromRequest($request), // may be null
]);
if ($order->snipform_session_id) {
$snipform->session()->event($request, ['name' => 'purchase', 'value' => $order->total_cents]);
$snipform->session()->acquisition($request, [
'value' => $order->total_cents,
'currency_code' => $order->currency,
]);
}
}
// Fulfilment job, days later - no request, just the stored id
public function handle(\SnipForm\Client $snipform)
{
if ($this->order->snipform_session_id) {
$snipform->session()->event([
'session_id' => $this->order->snipform_session_id,
'name' => 'order_shipped',
]);
}
}

A contact is a known person. The contact holds the PII; sessions reference it by id only, and a session links to a contact once. Background and lifecycle stages: Contacts.

Find-or-create by external_id (preferred) or email, and link the session if one is supplied.

$contact = $snipform->contacts()->identify([
'external_id' => 'usr_123',
'email' => '[email protected]',
'session_id' => $sessionId, // optional - the link only happens when present
'traits' => [
'first_name' => 'Jane',
'company' => 'Acme',
'lifecycle_stage' => 'customer',
'meta' => [['key' => 'plan', 'value' => 'pro']],
],
]);

At least one of external_id or email is required (the API returns 422 otherwise). Accepted trait keys: first_name, last_name, phone, company, job_title, website, country, city, lifecycle_stage, and meta as a list of {key, value} rows.

Identify is idempotent on the server: repeat calls with the same payload merge in place. To skip the round-trip as well, wire a dedup gate on the client and use identifyOnce():

// Any atomic add-if-missing works: Cache::add in Laravel, SETNX in Redis
$snipform->withIdentifyDedup(
fn (string $key, int $ttl): bool => \Illuminate\Support\Facades\Cache::add($key, true, $ttl),
ttl: 3600,
);
$fired = $snipform->contacts()->identifyOnce([...]); // bool
$fired = $snipform->contacts()->identifyFromRequest($request, [...]); // merges the tracker header as session_id

The fingerprint is sha1 of the session id plus the sorted payload, so a changed email or a new trait fires again. passesDedup($payload) runs the gate without sending, when you want to decide for yourself.

$contact = $snipform->contacts()->find($id);
foreach ($snipform->contacts()->all(['search' => 'acme']) as $contact) { ... } // paginated
$snipform->contacts()->all(['state' => 'active', 'lifecycle_stage' => 'lead'])->page(1);
foreach ($snipform->contacts()->sessionsFor($id) as $session) { ... } // SessionRow, cursor-walked
$snipform->contacts()->update($id, ['lifecycle_stage' => 'customer', 'company' => 'Acme Ltd']);
$snipform->contacts()->delete($id); // bool

Filters for all(): search, state, lifecycle_stage, per_page. update() accepts every contact field plus state and meta; updating a deleted contact returns a 410 ApiException.

Delete is redaction in place, not a row drop: every identifying field is destroyed, related identify events are scrubbed, and the contact’s sessions keep only an anonymous reference. This is how an erasure request is honoured. See Contacts.

Contact fields: id, externalId, email, firstName, lastName, fullName, phone, company, jobTitle, website, country, city, lifecycleStage, state, meta, firstSeenTs, identifiedTs, lastSeenTs, sessionCount.

Two surfaces: definition CRUD, and an analytics reader opened with ->for($id). Concepts are covered in Conversions.

Ask the server for the valid vocabulary before building a config:

$schema = $snipform->conversions()->schema();
$schema['conversion_types']; // e.g. lead, sale, signup, activation, download, custom
$schema['trigger_types']; // per trigger: kind, defaults, field and match options
$schema['cycle_intervals']; // day, week, month
$schema['segment_dimensions']; // fields you can segment by
$schema['page_match_modes']; // contains, exact, starts_with, regex
$schema['event_value_match_modes']; // exists, equals, gt, gte, lt, lte
$conversion = $snipform->conversions()->create()
->name('Newsletter signup')
->description('Free trial sign-up flow')
->type('lead')
->conversionValue(5.00)
->defaultPeriod('last_28')
->defaultCycle('week')
->step('Visit pricing')->onPageView('/pricing')
->step('Click signup')->onEvent('signup_click')
->step('Submit form')->onFormSubmit($snipFormId)
->publish() // omit to leave it as a draft
->save(); // Conversion

Each step() returns a step builder; its on*() method commits the step and hands back the conversion builder.

Step terminalTrips when
onPageView($value, $match = 'contains', $field = 'path')The session views a matching page. $field is path or url
onEntryPage($value, $match = 'contains', $field = 'entry_path')The session entered on a matching page. $field is entry_path or entry_url
onEvent($name, $value = null, $valueMatch = 'exists')A custom event fires, optionally gated on its value
onFormSubmit($snipFormId)A specific form is submitted
onShortLink($id, $scope = 'link')The session arrived through a short link, or any link in a group ($scope = 'group')

Chain ->optional() before the terminal to mark a step not required. Other builder setters: valueFromEvent() (take the value from the final event instead of conversionValue), startingFrom('2026-01-01') (never count sessions before a date).

$all = $snipform->conversions()->all(); // Conversion[]
$c = $snipform->conversions()->find($id); // Conversion with ->steps populated
$c->isDraft();
$c->isActive();
$snipform->conversions()->update($id, ['name' => 'Renamed', 'conversion_value' => 12.5]);
$snipform->conversions()->replaceSteps($id, [
['name' => 'Visit', 'trigger_type' => 'page_view', 'trigger_config' => ['type' => 'page', 'field' => 'path', 'match' => 'contains', 'value' => '/pricing']],
['name' => 'Buy', 'trigger_type' => 'event', 'trigger_config' => ['type' => 'event', 'name' => 'purchase', 'valueMatch' => 'exists']],
]);
$snipform->conversions()->publish($id); // draft to active (needs at least one step)
$snipform->conversions()->toggle($id); // active to paused and back
$snipform->conversions()->delete($id); // bool

Conversion fields: id, name, description, type, state, conversionValue, valueFromEvent, stepsCount, startingFrom, defaultPeriod, defaultCycle, steps (ConversionStep[]: id, name, order, triggerType, triggerConfig, isRequired).

->for($id) opens a reader. Set the window with unix timestamps, optionally filter, then call a terminal.

$reader = $snipform->conversions()->for($id)
->between(strtotime('-30 days'), time()) // or ->since(strtotime('-30 days'))
->filter(['channel_category' => 'paid_search']); // optional
$summary = $reader->summary();
$summary->sessions; // int - entered the funnel
$summary->conversions; // int
$summary->rate; // float, 0-100
$summary->value; // ?float - attributed value
$summary->windowFrom; // int
$summary->windowTo; // int
$summary->funnel; // FunnelStep[]: stepId, name, order, count, dropOff, isConversion, triggerLabel, triggerSummary

Segment by a flat dimension or by a session tag key:

$reader->segments('channel_category'); // ConversionSegment[]
$reader->segmentsByTag('campaign_phase'); // ConversionSegment[]
// ConversionSegment: value, label, sessions, converted, rate, icon, favicon

Cycles recompute the funnel per repeating interval, with a delta against the previous one:

$result = $reader->cycles('week', page: 0, perPage: 6);
// ['cycles' => ConversionCycle[], 'has_more' => bool, 'page' => int, 'interval' => 'week']
foreach ($result['cycles'] as $cycle) {
// label, fromTs, toTs, dateFrom, dateTo, isCurrent, sessions, conversions, rate, value, delta
}

Sessions that reached a step (or converted fully, with null):

$reader->sessionsAt($stepId, page: 1, perPage: 25);
// ['sessions' => array[], 'page' => int, 'per_page' => int, 'total' => int, 'has_more' => bool]

Returning a reader from a controller serialises as summary().

Groups hold links; clicks are recorded by the redirect. Concepts: Short Links & Campaigns.

$groups = $snipform->linkGroups()->all(); // LinkGroup[] - not paginated
$group = $snipform->linkGroups()->find($id);
$group = $snipform->linkGroups()->create([
'name' => 'Spring affiliates',
'description' => 'Affiliate links for Q2',
'purpose' => 'affiliate',
'track_clicks' => true,
]);
$group = $snipform->linkGroups()->update($id, ['name' => 'Spring 2026', 'state' => 'archived']);
$snipform->linkGroups()->delete($id); // bool - deletes the group's links too

LinkGroup: id, name, description, purpose, state, trackClicks, countLinks, countClicks, createdAt.

foreach ($snipform->links()->all() as $link) { ... } // paginated
foreach ($snipform->links()->all(['group_id' => $groupId]) as $link) { ... }
$link = $snipform->links()->find($id);
$link = $snipform->links()->create([
'group_id' => $groupId,
'destination_url' => 'https://example.com/landing',
'domain' => 'snpf.io',
'utm' => [
'utm_source' => 'newsletter',
'utm_medium' => 'email',
'utm_campaign' => 'spring_sale',
'utm_content' => 'pub_12345',
],
]);
$link = $snipform->links()->update($id, ['destination_url' => 'https://example.com/v2', 'is_active' => false]);
$snipform->links()->delete($id);
$link->shortUrl; // https://snpf.io/abc123
$link->utm('utm_content'); // 'pub_12345' or null

Link: id, groupId, code, shortUrl, destinationUrl, domain, utm, isActive, clicks, createdTs. The domain must be one the property is allowed to use; the dashboard’s link builder lists them.

Read-only. Chain filters, then all() or find().

foreach ($snipform->clicks()->forLink($linkId)->all() as $click) { ... }
$human = $snipform->clicks()
->forGroup($groupId)
->between(strtotime('-30 days'), time())
->usersOnly()
->perPage(100)
->all();
$bots = $snipform->clicks()->botsOnly()->all()->count();
$click = $snipform->clicks()->find($clickId);
FilterEffect
forLink($id)One short link
forGroup($id)One group
between($fromTs, $toTs)Unix-timestamp range
since($fromTs)Open-ended range
usersOnly() / botsOnly()Click type
perPage($n)1 to 100

Click: id, shortLinkId, shortLinkGroupId, clickTs, type, referrerDomain, referrerUrl, country, countryCode, region, city, device, browser, os, isBot, botName.

Run the channel engine without a visitor, and fetch the UTM preset catalog. Concepts: Channels & Attribution.

$result = $snipform->attribution()->preview([
'utm_source' => 'whatsapp',
'utm_medium' => 'social',
'utm_campaign' => 'spring',
]);
// From a full URL - UTMs parsed from the query string; explicit keys win over URL-derived ones
$result = $snipform->attribution()->preview([
'url' => 'https://example.com/landing?utm_source=tg&utm_medium=messaging',
]);
// Click ids and a referrer, for cases beyond UTMs
$result = $snipform->attribution()->preview([
'click_ids' => ['gclid' => 'xyz123'],
'referrer' => 'https://www.google.com/',
]);
$result->category; // e.g. 'paid_search'
$result->categoryLabel; // 'Paid Search'
$result->categoryColor; // ?string
$result->name; // e.g. 'Google Ads'
$result->source;
$result->medium;
$result->campaign; // ?string
$result->method; // 'utm' | 'referrer' | 'click_id' | 'custom_rule' | 'direct'
$result->clickId; // ?string
$result->customRule; // ?string
$result->isDirect();
$result->isPaid();

The preview runs the same code path that classifies live sessions, so what it says now is what a visitor landing with those parameters gets.

foreach ($snipform->attribution()->presets() as $preset) {
// ['group' => 'Messaging', 'key' => 'whatsapp', 'label' => 'WhatsApp',
// 'utm_source' => 'whatsapp', 'utm_medium' => 'messaging']
}

presets() always returns the raw array; it is shaped for rendering as chips in a link builder.