Skip to content

HTTP API

Send events and identify users from any language by POSTing JSON to Pug's Connect API - no SDK required.

Updated View as Markdown

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.

Prompt
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.

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)
}
Navigation

Type to search...

↑↓ navigate↵ selectEsc close