No SDK for your runtime? Anything that can POST JSON can send events and identify users. This page covers ingestion end to end; the API reference has the full schemas and every other service.
Setup
Nothing to install - send requests straight to the API at 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.
There is no initialization step: every request carries the key itself, and event properties are wrapped by hand (see Track events).
Working with a coding agent? Paste this instead - it points the agent at the markdown copy of this page, so it works from the reference below rather than from memory. More on the approach in Install with AI.
Add Pug product analytics to this service by calling the HTTP API directly - there is no SDK for this runtime. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/http/index.md - request format, including worked examples - https://docs.pug.sh/api/events/index.md - the full event schema - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the service actually does: its routes or handlers, its main models, and the state changes that matter to the business. The events worth tracking are the moments the service exists to produce, not request logging. If you cannot state what this service is for in one sentence, keep reading. Then: 1. Write one small client that POSTs to /sdk.events.v1.EventsService/BatchCreate on the base URL, with the headers Content-Type: application/json, Connect-Protocol-Version: 1, and x-api-key set to the public key (pub_...) from an environment variable. 2. Wrap every custom property value in its typed form - stringValue, intValue (a JSON string), doubleValue, boolValue, timestampValue. This is the part that is most often wrong. Property keys must not start with a dollar sign, and the event kind must not start with pug. 3. Send events in batches of at most 1000, and read "dropped" and "droppedByReason" on the response - a 200 does not mean every event landed. 4. Call it for the three or four actions you identified. Name events from the catalog before you invent one. All 127 well-known events are available over HTTP, and since nothing here is compile-checked they are the only validation you get: a well-known kind has its properties checked at ingestion and fills in the dashboards built on it, while a custom kind is accepted unvalidated and silently. Search the catalog for every action you are about to track, take property names and types from that page, and fall back to a custom snake_case name only when nothing fits. When you do, tell me which event you considered and why it did not. Do not invent fields that are not in the events schema above. When you are done, tell me in one sentence what you concluded this service does, then show me the client, the event names you chose, and the environment variables I need to set.
Track events
Events are the foundation of every analytic in Pug. An event’s kind is its
name: custom names work without a schema, while
well-known events add typed properties and
richer dashboards. Names starting with pug. are reserved.
Endpoint: POST /sdk.events.v1.EventsService/BatchCreate (max 1000
events per request).
{
"events": [
{
"eventId": "01966b9e-1234-7abc-abcd-0123456789ab",
"distinctId": "anon-abc123",
"kind": "purchase",
"sessionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"occurTime": "2026-06-05T10:00:00Z",
"customProperties": {
"productId": { "stringValue": "sku_123" },
"amount": { "doubleValue": 29.99 },
"currency": { "stringValue": "USD" }
},
"autoProperties": {}
}
]
}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.. For a visitor who hasn’t
consented, send the event cookieless instead. The
response is { "accepted": <n> }, plus dropped and droppedByReason when
part of a batch was refused, so check dropped rather than reading a 200 as
“all landed”. See Errors for status codes,
Examples for complete requests, and the
Events API reference for the full schema.
Identify and sessions
Identity turns anonymous events into actionable profiles: events accumulate
against an anonymous ID, and an Identify call on sign-in merges that history
into a known profile. 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.
Endpoint: POST /sdk.profiles.v1.ProfilesSDKService/Identify
{
"externalId": "user-123",
"traits": { "email": "user@example.com", "plan": "pro", "seats": 5 },
"anonymousId": "anon-abc123"
}| 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.
Sessions
Every event carries a sessionId, a UUID grouping events in a visit, unless it
is cookieless - then the server stitches one, closed
after 30 minutes of inactivity. For the anonymous->identified model, see
Core concepts.
Auto-tracking
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.
Consent and privacy
To send events for a visitor who hasn’t consented, omit distinctId and
sessionId and set "cookieless": true - the server derives both, so the
traffic still counts while staying anonymous. See
Cookieless events for the derivation and its
rules. There’s no SDK to redact PII for you, so keep it out of the properties
you send.
Errors
The SDKs surface failures as thrown or logged errors and retry transient ones for you; 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)
}