SDKs
Client and server libraries for sending events and managing identity from your apps. Every integration shares one mental model — initialize once, track events, identify users, reset on sign-out — whether you use a browser/app SDK, the server-side Node SDK, or call the API directly. The one exception is reset: the Node SDK has no ambient user or session to reset, so you name the distinctId on each call instead.
Choose your platform
| Platform | Use it when | Package |
|---|---|---|
| Web | Browser apps — React, Vue, Next.js, or plain JavaScript | @pug-sh/browser |
| Flutter | Cross-platform mobile & desktop apps | pug_flutter |
| Node | A Node.js backend that sends events and reads analytics | @pug-sh/node |
| HTTP API | Any other backend or language — anything that can POST JSON | — |
Native iOS, Android, and React Native SDKs are on the roadmap — for now, use Flutter for cross-platform mobile, or the HTTP API from any runtime.
Client vs server keys. The Web and Flutter SDKs run in untrusted environments and authenticate with your public key (pub_…) — write-scoped (events + identify) and safe to ship in client code. The Node SDK runs on your server and uses your private key (prv_…), which additionally unlocks analytics reads (profiles, activity, insights); never embed it in client code. See Authentication.
Install
npm install @pug-sh/browser
# or: pnpm add @pug-sh/browser · yarn add @pug-sh/browser · bun add @pug-sh/browserThe Web SDK ships as compiled JavaScript plus type definitions for bundlers (Vite, webpack, esbuild, Next.js, …) and runs in the browser. There is no standalone <script>/CDN build.
Import named exports from the package root:
import { init, track, identify, reset } from '@pug-sh/browser'init() runs once wherever your app boots — there are no framework-specific packages. For Vite, Vue, and other bundlers, just call it at your entry point.
Next.js App Router
In the App Router, init must run in the browser, not during SSR, so call it from a client provider:
// components/PugProvider.tsx
'use client'
import { init } from '@pug-sh/browser'
import { useEffect } from 'react'
export function PugProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
init(process.env.NEXT_PUBLIC_PUG_PROJECT_ID!, {
apiKey: process.env.NEXT_PUBLIC_PUG_PUBLIC_KEY!
})
}, [])
return <>{children}</>
}The App Router also doesn’t do a full History push on client-side navigation, so auto-tracking fires page_view only on the initial load — track later navigations explicitly with track('page_view') in a usePathname() effect (see Auto-tracking).
Verify by opening your dashboard’s Live view — an auto-tracked page_view appears within ~15 seconds.
pug_flutter is published on pub.dev. flutter pub add pug_flutter adds it to pubspec.yaml and fetches it; to pin the version yourself, add it under dependencies instead:
dependencies:
pug_flutter: ^0.0.2| Requirement | Minimum |
|---|---|
| Dart SDK | >=3.7.0 <4.0.0 |
| Flutter | >=3.29.0 |
Import it wherever you call the SDK:
import 'package:pug_flutter/pug_flutter.dart';Smoke-test by adding a minimal Pug.init in main() (see Initialize) and running the app — with auto-tracking on, an app_open event appears in Live within a few seconds. (page_view events need the PugRouteObserver — see Auto-tracking.)
The Node SDK is server-side and ships as ESM — it needs Node.js 18+. Import the Pug class:
import { Pug, PugError } from '@pug-sh/node'Unlike the client SDKs it authenticates with your private key (prv_…), so keep it on the server — in an environment variable, never in code shipped to a browser or app. Construct one instance at boot and reuse it across requests.
Send requests to https://api.pugs.dev — or your self-hosted base URL, e.g. http://localhost:3000 — with three headers:
| Header | Value |
|---|---|
Content-Type | application/json |
Connect-Protocol-Version | 1 |
x-api-key | Your public key (pub_…) |
The public key is write-scoped and safe in server-side code that sends events on behalf of your users. Never use a private key for ingestion.
Initialize
Client SDKs call init once at startup with your project ID and public key; the Node SDK constructs a Pug instance with your private key instead. Either way, every other call needs it first. Find your project ID in the dashboard URL (/p/<projectId>) and your keys in the API Keys section of your project Overview.
import { init } from '@pug-sh/browser'
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY'
})init returns nothing. It throws if projectId or apiKey is missing, and no-ops (with a console warning) if called a second time or outside a browser.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | required | Public key (pub_…), sent as x-api-key. |
endpoint | string | Pug Cloud | API base URL. Defaults to Pug Cloud (https://api.pugs.dev) — set it only when self-hosting. |
batch | Partial<BatchConfig> | maxSize 10, maxWaitMs 5000, maxQueueSize 1000 | Queue size and flush timing; persisted to localStorage. |
session | SessionConfig | idleTimeoutMinutes 30, maxSessionMinutes 1440 | Session idle and max-duration timeouts (minutes). |
autoCapture | boolean | AutoCaptureSelection | true | Automatic capture: true enables all listeners, false none, or an object is a per-listener allowlist. See Auto-tracking. |
trackingConsent | 'granted' | 'denied' | { default?, persist? } | 'granted' | Consent gate — while denied, listeners stay off and track()/identify() are dropped. See Consent and privacy. |
crossSubdomainTracking | boolean | { domain } | false | Share identity across subdomains via a first-party cookie. Off by default — it relaxes origin isolation to same-site. |
sanitizeUrl | (url) => string | — | Rewrite $url / $referrer / form actions before they leave the device; fails closed. See Consent and privacy. |
dryRun | boolean | false | Log events to the console without sending. |
Tear down and re-init with destroy() (flushes the queue, removes listeners). reset() and rotate() are covered under Identify and sessions.
Pug.init returns Future<void> and throws ArgumentError if projectId or apiKey is empty. A second init while running is silently ignored (a warning is logged via PugLogger); call Pug.destroy() first to reinitialize.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | String | required | Public key (pub_…), sent as x-api-key. |
endpoint | String | Pug Cloud | API base URL. Defaults to Pug Cloud (https://api.pugs.dev) — set it only when self-hosting. |
batch | BatchConfig | maxSize 10, maxWaitMs 5000, maxQueueSize 1000 | Queue size and flush timing; persisted to SharedPreferences. |
session | SessionConfig | idleTimeout 30 min, maxDuration 24 h | Session timeouts as Durations. |
autoTrack | bool | true | Master switch for all auto-trackers (lifecycle + page views). |
autoPageViews | bool | true | Emit a navigation event on route changes — screen_view on mobile, page_view on web (needs PugRouteObserver). |
autoCaptureCampaigns | bool | true | Capture UTM parameters and ad click-ids from incoming deep links as auto-properties. |
dryRun | bool | false | Log via PugLogger without sending. |
Advanced overrides — logger, storage, transport, autoPropertyProvider, linkProvider — let you inject test doubles or custom collection.
new Pug(options) returns an instance immediately. It authenticates with your private key and batches events in memory — construct it once and share it across requests.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | required | Private key (prv_…), sent as x-api-key. Server-side only. |
endpoint | string | Pug Cloud | Pug server origin. Defaults to Pug Cloud (https://api.pugs.dev) — set it only when self-hosting. |
batch | Partial<BatchConfig> | maxSize 100, maxWaitMs 5000, maxQueueSize 10000 | Ingestion buffer size and flush timing. |
onError | (err, events) => void | no-op | Dead-letter hook for buffered track() events that couldn’t be delivered (identify() failures are logged, not routed here). |
Call await pug.close() on graceful shutdown so buffered events flush — anything still undeliverable is reported through onError. The Node SDK has no destroy(), reset(), or rotate(): a server has no ambient user or session to reset.
There is no initialization step. Authenticate every request with the x-api-key header (your pub_… key) and target your base URL — https://api.pugs.dev, or a self-hosted URL such as http://localhost:3000. Event properties are wrapped by hand (see Track events).
Endpoint defaults to Pug Cloud. Every SDK defaults
endpointtohttps://api.pugs.dev— so you only set it when self-hosting, pointing it at your own server (the dev server listens on port3000).
Track events
Events are the foundation of every analytic in Pug. The first argument is the event name (the kind): custom names work without a schema, while well-known events add typed properties and richer dashboards. Names starting with pug. are reserved.
import { track } from '@pug-sh/browser'
// Custom event — any name
track('button_clicked', { label: 'Sign up', page: '/pricing' })
// Well-known event — typed properties
track('purchase', { productId: 'sku_123', amount: 29.99, currency: 'USD' })track() never throws and returns nothing — events are queued and flushed in the background, so analytics can’t break or block your app.
Property values accept string (truncated to 1024 bytes client-side), number, boolean, and Date (stored as a timestamp). Objects and arrays are JSON-encoded to a string. null, undefined, and non-finite numbers are dropped. Send numbers as numbers — aggregations like Sum don’t work on strings.
track(event, props?, options?) options:
| Option | Type | Description |
|---|---|---|
immediate | boolean | Bypass the batch queue and send right away. |
timestamp | number | Override the event time, as epoch milliseconds (e.g. Date.now()). |
// Conversion that must arrive before a redirect
track('purchase', { productId: 'sku_789', amount: 99, currency: 'USD' }, { immediate: true })Pug.track(...) never throws — calls are wrapped in try/catch and queued in the background. The generated Pug.track.* namespace has a typed method per well-known event; pass schema-external properties via extras: {...} (on a key collision, the named argument wins). For runtime-determined names, call Pug.track(kind, props: {...}) directly.
Property values accept String (truncated to 1024 UTF-8 bytes), bool, int, finite double/num (non-finite dropped), and DateTime (UTC epoch ms). Iterable/Map are JSON-encoded to a string; null is dropped — omit the key instead.
TrackOptions (on Pug.track and every typed method):
| Option | Type | Default | Description |
|---|---|---|---|
immediate | bool | false | Bypass the queue and send now (falls back to the queue on transient failure). |
timestampMillis | int? | wall clock | Override the event time as epoch milliseconds. |
Pug.track.purchase(
productId: 'sku-789', amount: 99.0, currency: 'USD',
options: const TrackOptions(immediate: true),
);pug.track(distinctId, kind, props?, options?) puts the distinctId first — a server has no ambient user, so you name who each event is for (your external ID, or an anon-… ID forwarded from the client). It never throws and is non-blocking: events are validated, batched, and flushed in the background.
There is no { immediate } on the server — flush explicitly instead:
// track() is non-blocking and batched — there is no { immediate }.
await pug.flush() // send what's buffered right now
await pug.close() // drain on graceful shutdown so nothing buffered is losttrack options: timestamp (epoch-millisecond override) and sessionId (per-call session override — otherwise the instance uses one session ID for its lifetime). Property values accept the same JSON scalar types as the other SDKs.
Endpoint: POST /sdk.events.v1.EventsService/BatchCreate (max 1000 events per request).
The SDKs wrap property values for you; over raw HTTP you wrap each value in a typed object yourself — the single most important thing to get right:
| Type | JSON shape | Notes |
|---|---|---|
| String | { "stringValue": "hello" } | SDKs truncate strings to 1024 bytes |
| Integer | { "intValue": "42" } | int64 — value is a JSON string |
| Double | { "doubleValue": 3.14 } | must be finite |
| Boolean | { "boolValue": true } | |
| Timestamp | { "timestampValue": "2026-06-05T10:00:00Z" } | RFC 3339 |
customProperties keys must not start with $ (reserved for auto-properties); kind must not start with pug.. The response is { "accepted": <n> }. See Errors and the raw HTTP API for status codes and full examples, and the Events API reference for the complete schema.
Identify and sessions
Identity turns anonymous events into actionable profiles. The pattern: events accumulate against an anonymous ID, you identify() on sign-in to merge that history into a known profile, and reset() on sign-out. Use a stable, unique external ID — your database user ID, not an email (which can change). Only the first identify after an anonymous session merges the prior activity; the same external ID always resolves to the same profile, and traits are merged (never removed) server-side.
// On sign-in — merge anonymous history into the known profile
await identify('user-123', { email: '[email protected]', plan: 'pro' })
// On sign-out — clear identity, start a fresh anonymous session
reset()
// New session ID without clearing identity
rotate()identify(externalId, traits?) returns a promise and never throws — invalid input, a call before init(), denied consent, dryRun, and RPC failures are all logged and the promise resolves without sending, so you can await it without a try/catch. On the first identify it includes the anonymous ID so prior anonymous events merge into the profile. reset() clears the stored identity and starts a new anonymous session — it does not delete the profile in the dashboard.
| Function | Clears identity | New device ID | New session ID |
|---|---|---|---|
rotate() | No | No | Yes |
reset() | Yes | Yes | Yes |
Pug.identify(externalId, {traits}) returns Future<void> and never throws — invalid input and transport failures are logged and the future completes normally; a call before Pug.init() is ignored with a warning. reset() is synchronous — it generates a new device ID, a new anonymous ID, and a new session, and removes the stored externalId (the profile stays in the dashboard). Drain the queue with await Pug.flush(); fully tear down with Pug.destroy().
| Function | Clears identity | New device ID | New session ID |
|---|---|---|---|
rotate() | No | No | Yes |
reset() | Yes | Yes | Yes |
pug.identify(externalId, traits?, options?) returns a promise and never throws — failures are logged. Pass { anonymousId } (must start with anon-) on the first identify to merge a client’s prior anonymous activity into the profile, and { deviceId } to attach a device. There is no reset() or rotate(): identity isn’t ambient on the server — you name the distinctId on every track() call instead.
Endpoint: POST /sdk.profiles.v1.ProfilesSDKService/Identify
| Field | JSON name | Required | Description |
|---|---|---|---|
external_id | externalId | Yes | Stable user identifier |
traits | traits | No | Profile properties; shallow-merged on conflict |
anonymous_id | anonymousId | No | Anon ID to merge; must start with anon-. Send on the first identify |
device_id | deviceId | No | Device to assign (mobile SDKs; omit for web) |
The response is an empty object {} on success. You set each event’s distinctId yourself — the anonymous ID before identify, your external ID after.
traitsis plain JSON, notPropertyValue-wrapped. Unlike eventcustomProperties(which need{"plan":{"stringValue":"pro"}}), identifytraitsis agoogle.protobuf.Struct— pass bare JSON values ("plan": "pro","seats": 5). The SDKs send traits as-is; only event properties get the wrapper.
Sessions
Sessions group events within a single visit. The SDK creates a session ID on the first event, extends it on each event within the idle window, and starts a new one once the idle or max-duration timeout passes — identity is preserved across the boundary.
The Web SDK syncs the session across tabs via localStorage; Flutter persists it to SharedPreferences and restores it on cold start (re-checking the timeouts). The Node SDK uses a single session ID for the instance’s lifetime — override it per call with track(..., { sessionId }), since sessions are a client concept. Configure the session timeouts at init on the Web (idleTimeoutMinutes/maxSessionMinutes) and Flutter (idleTimeout/maxDuration) SDKs — see Initialize. For the anonymous→identified model, see Core concepts.
Auto-tracking
With automatic capture on (the default), the client SDKs capture common interactions for you — no manual track() calls. Every auto-tracked event also carries the standard auto-properties, and the server attaches enrichment properties to every event regardless of platform.
Six trackers install with autoCapture on (the default). Pass a per-listener allowlist to run only some — omitted keys stay off — and change the selection at runtime with setAutoCapture():
import { init } from '@pug-sh/browser'
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY',
autoCapture: { pageView: true, click: true, scroll: false }
})| Tracker | Allowlist key | Event(s) | Captured properties |
|---|---|---|---|
| Page views | pageView | page_view | auto-properties only ($url, $referrer, …) |
| Clicks | click | click | tag, id, class, text (≤50 chars), x, y |
| Scroll | scroll | scroll | percent (0–100), scrollY |
| Forms | form | form_start, form_submit | form metadata only — formId, formName (both), plus action on form_submit. Never field values |
| Rage clicks | rageClick | rage_click | clickCount, element, x, y |
| Dead clicks | deadClick | dead_click | element, text (≤20 chars), x, y |
page_view fires on the initial load and on SPA navigation via the History API (pushState/replaceState/popstate), so call init() before your router mounts. The Next.js App Router navigates without a History push — track those page_views manually. To gate all capture behind a consent banner, see Consent and privacy.
Navigation is captured when you wire a PugRouteObserver into your MaterialApp:
MaterialApp(
navigatorObservers: [PugRouteObserver()],
// … rest of your config
)On each route change the SDK emits a navigation event — screen_view (with screenName set to the route name) on iOS/Android, and page_view on web; desktop targets emit neither. The navigation event itself carries no url props — instead every event picks up the current and previous route as the $url and $referrer auto-properties, so route context is present even when autoPageViews is off. Route names come from route.settings.name, falling back to the route’s runtime type.
App lifecycle is observed automatically (no wiring): app_open when the app enters the foreground, and app_close on background/hidden/paused/detached — sent with immediate: true so it arrives before the OS kills the process.
Campaign capture (autoCaptureCampaigns, on by default) reads UTM parameters and ad click-ids from incoming deep links and attaches them as auto-properties ($utmSource, $utmMedium, $gclid, …) to subsequent events.
There is no client-side auto-tracking on the server — you send every event explicitly with pug.track(distinctId, …). The server still enriches each event during processing, attaching geo ($country, $region, $city) and bot-detection ($bot_score, $verified_bot) auto-properties you don’t set yourself.
There is no client-side auto-tracking over raw HTTP — you send every event explicitly. The server still enriches each event during processing, attaching geo ($country, $region, $city) and bot-detection ($bot_score, $verified_bot) auto-properties you don’t set yourself.
Auto-property keys are camelCase on the client SDKs —
$utmSource,$pageTitle,$screenWidth. The only snake_case keys are the two server-set bot signals,$bot_scoreand$verified_bot. See Auto-properties for the full per-platform catalog.
Consent and privacy
The Web SDK ships GDPR/CCPA controls that run in the browser before anything is sent, so raw values never leave the device. (Flutter gates capture at init; over the Node SDK or raw HTTP, redact PII before you call track().)
Tracking consent
Gate all capture behind consent. Start denied — trackingConsent: { default: 'denied', persist: true } remembers the choice across reloads — then flip it from your cookie banner. While denied, automatic listeners aren’t attached and manual track() / identify() calls are dropped (not queued for later replay).
import { init, optInTracking, optOutTracking, isTrackingEnabled } from '@pug-sh/browser'
// Start denied and remember the choice across reloads.
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY',
trackingConsent: { default: 'denied', persist: true }
})
// After the user accepts — attaches listeners, applies the stored autoCapture selection.
optInTracking()
// After the user declines — tears listeners down, drops future track()/identify().
optOutTracking()
isTrackingEnabled() // → true once consent is granted| Function | Description |
|---|---|
optInTracking() | Grant consent, attach listeners (applying the stored autoCapture selection), allow track() / identify(). |
optOutTracking() | Revoke consent, tear down listeners, drop future track() / identify(), and clear the stored anonymous ID / session (a later opt-in starts fresh). |
isTrackingEnabled() | true when consent is granted (independent of dryRun). |
getTrackingConsent() | 'granted' or 'denied'. |
setAutoCapture(selection) | Change the automatic-listener allowlist at runtime — applied immediately when granted, deferred until opt-in when denied. |
Redacting PII
Two device-side controls keep PII out of captured events:
sanitizeUrl — an init() function that rewrites $url, $referrer, and captured form actions before they’re sent. It fails closed: if it throws or returns a non-string, the URL is dropped to an empty string rather than sent raw. It covers URL fields only — don’t put PII in UTM params, which are parsed separately.
import { init } from '@pug-sh/browser'
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY',
// Rewrite $url, $referrer, and captured form actions before they leave the device.
sanitizeUrl: (url) => {
const u = new URL(url, window.location.origin)
u.pathname = u.pathname.replace(/\/orders\/\d+/, '/orders/:orderId') // mask IDs
u.searchParams.delete('email') // strip PII params
return u.toString()
}
})data-pug-no-capture — an HTML attribute that blanks captured element text for that element and everything inside it, while still recording structural fields (tag, id, class, coordinates) so the interaction still counts. Put it on an ancestor of every clickable element; id and class are still sent, so keep PII out of those too.
<!-- The click still counts, but "[email protected]" is never captured. -->
<button data-pug-no-capture>Account: [email protected]</button>
<!-- On a container, it covers every element inside. -->
<div data-pug-no-capture>
<span>Card ending 4242</span>
<button>Pay $49.00</button>
</div>Server-side reads
The Node SDK’s private key authorizes ingestion and reads — the same insights, profiles, and activity data the dashboard shows. Reads are request/response and throw PugError (which carries the Connect code and cause); they run in your control flow, so you own timeout and retry. Ingestion (track / identify) still never throws.
import { PugError } from '@pug-sh/node'
try {
// Look up a profile by your external ID.
const profile = await pug.profiles.getByExternalId('user-123')
// Auto-paginating async iterator over every matching profile.
for await (const p of pug.profiles.list()) {
console.log(p.externalId)
}
// Run an insights query — the same spec the dashboard uses.
const result = await pug.insights.query({ /* spec, timeRange, granularity */ })
} catch (err) {
// Reads throw; ingestion (track/identify) never does.
if (err instanceof PugError) console.error(err.code, err.message)
}| Namespace | Methods |
|---|---|
pug.profiles | get(id), getByExternalId(externalId), delete(id), list(req?) — an auto-paginating async iterator |
pug.activity | feed, eventExplorer, heatmap, profileStats, filterSchema, propertyValues |
pug.insights | query, segmentUsers, filterSchema, propertyValues |
The request/response shapes match the shared.* Connect services — see the API reference for field-by-field schemas. From a non-Node backend, call those services directly with your private key (see Authentication).
Errors and the raw HTTP API
The SDKs surface failures as thrown or logged errors and retry transient ones for you — in the Node SDK the read methods throw PugError, which carries the Connect code below; over raw HTTP you handle them yourself. Connect uses standard HTTP status codes, and on error the body is:
{
"code": "invalid_argument",
"message": "event kind must not use reserved prefix 'pug.'"
}| HTTP status | Connect code | Meaning |
|---|---|---|
| 400 | invalid_argument | Validation failed (bad field value, reserved prefix, etc.) |
| 401 | unauthenticated | Missing or invalid x-api-key |
| 429 | resource_exhausted | Rate limit exceeded |
| 500 | internal | Server error — safe to retry with backoff |
Validation rules:
| Rule | Detail |
|---|---|
| Batch size | Max 1000 events per BatchCreate request |
kind | Must not start with pug. (reserved) |
customProperties keys | Must not start with $ |
autoProperties keys | Must start with $ |
$bot_score, $verified_bot | Server-only; any client-set value is stripped |
stringValue length | The SDKs truncate strings to 1024 bytes client-side |
Examples
curl -X POST https://api.pugs.dev/sdk.events.v1.EventsService/BatchCreate \
-H 'Content-Type: application/json' \
-H 'Connect-Protocol-Version: 1' \
-H 'x-api-key: pub_your_public_key' \
-d '{
"events": [{
"eventId": "01966b9e-1234-7abc-abcd-0123456789ab",
"distinctId": "anon-abc123",
"kind": "page_view",
"sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"occurTime": "2026-06-05T10:00:00Z",
"customProperties": { "path": { "stringValue": "/pricing" } },
"autoProperties": {}
}]
}'import uuid
from datetime import datetime, timezone
import requests
API_KEY = "pub_your_public_key"
BASE_URL = "https://api.pugs.dev"
def send_event(distinct_id, kind, session_id, custom_props):
event = {
"eventId": str(uuid.uuid4()), # swap for uuidv7() if available
"distinctId": distinct_id,
"kind": kind,
"sessionId": session_id,
"occurTime": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"customProperties": custom_props,
"autoProperties": {},
}
resp = requests.post(
f"{BASE_URL}/sdk.events.v1.EventsService/BatchCreate",
headers={
"Content-Type": "application/json",
"Connect-Protocol-Version": "1",
"x-api-key": API_KEY,
},
json={"events": [event]},
timeout=5,
)
resp.raise_for_status()
return resp.json()
result = send_event(
"anon-abc123", "signup", "f47ac10b-58cc-4372-a567-0e02b2c3d479",
{"plan": {"stringValue": "pro"}, "seats": {"intValue": "5"}}, # int64 as a JSON string
)
print(result) # {"accepted": 1}package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
)
const (
apiKey = "pub_your_public_key"
baseURL = "https://api.pugs.dev"
)
// propertyValue is the Connect-JSON encoding of the protobuf oneof.
type propertyValue struct {
StringValue *string `json:"stringValue,omitempty"`
IntValue *string `json:"intValue,omitempty"` // int64 as a JSON string
DoubleValue *float64 `json:"doubleValue,omitempty"`
BoolValue *bool `json:"boolValue,omitempty"`
TimestampValue *string `json:"timestampValue,omitempty"` // RFC 3339
}
func strVal(s string) propertyValue { return propertyValue{StringValue: &s} }
type event struct {
EventID string `json:"eventId"`
DistinctID string `json:"distinctId"`
Kind string `json:"kind"`
SessionID string `json:"sessionId"`
OccurTime string `json:"occurTime"`
CustomProperties map[string]propertyValue `json:"customProperties"`
AutoProperties map[string]propertyValue `json:"autoProperties"`
}
func main() {
body, _ := json.Marshal(map[string]any{
"events": []event{{
EventID: uuid.New().String(),
DistinctID: "anon-abc123",
Kind: "signup",
SessionID: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
OccurTime: time.Now().UTC().Format(time.RFC3339),
CustomProperties: map[string]propertyValue{"plan": strVal("pro")},
AutoProperties: map[string]propertyValue{},
}},
})
req, _ := http.NewRequest(http.MethodPost,
baseURL+"/sdk.events.v1.EventsService/BatchCreate", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connect-Protocol-Version", "1")
req.Header.Set("x-api-key", apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println("status:", resp.Status)
}Further reading
- Quickstart — your first event in five minutes
- Core concepts — events, profiles, sessions, identity
- Well-known events — the typed event catalog
- Auto-properties — every
$-property the SDKs and server attach - Authentication — public vs. private keys, rotation
- Self-hosting — run the Pug stack on your own infrastructure
- API reference: Events · Profiles · RPC services — the full wire schema for every endpoint and message
- SDK source: browser · node · flutter