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

> Documentation Index
> Fetch the complete documentation index at: https://docs.pug.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# HTTP API

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](/api) 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](#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](/get-started/ai-setup).

```text
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](/reference/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).

```json
{
  "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](#consent-and-privacy) 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](#errors) for status codes,
[Examples](#examples) for complete requests, and the
[Events API reference](/api/events) 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`

```json
{
  "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) |

> **traits is plain JSON, not PropertyValue-wrapped**
>
> Unlike event `customProperties` (which need `{"plan":{"stringValue":"pro"}}`),
> identify `traits` is a `google.protobuf.Struct` - pass bare JSON values
> (`"plan": "pro"`, `"seats": 5`). Only event properties get the wrapper.

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](#consent-and-privacy) - then the server stitches one, closed
after 30 minutes of inactivity. For the anonymous->identified model, see
[Core concepts](/get-started/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](/api/events#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:

```json
{
  "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

```bash
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": {}
    }]
  }'
```

### Python

```python
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}
```

### Go

```go
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)
}
```

Source: https://docs.pug.sh/sdks/http/index.mdx
