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

# Go SDK

> Send AI-traffic pageviews and revenue events to Traceten from a Go backend with traceten-go, over HTTP that ad-blockers and privacy browsers cannot strip.

## What this lets you do

Send pageviews and revenue conversions to Traceten from your Go backend, over server-to-server HTTP that survives ad-blockers and privacy browsers. Use it where the browser snippet cannot run: server-rendered pages, background jobs, webhook handlers, and headless flows.

## Before you start

* Go 1.21 or newer.
* Your Traceten site id, from **Sites → Install** in the [dashboard](https://app.traceten.com/dashboard).
* Your ingest host URL, for example `https://ingest.traceten.com`.
* A `VisitorID` for each event. The SDK runs on your server, so there is no Traceten cookie to read. You supply the identifier from your own request context. It is either a UUID or an `h:<64-hex>` identify hash. See [supplying identifiers](/sdks/overview#you-supply-the-visitor-id).

## Install

```sh theme={null}
go get github.com/traceten/traceten-go
```

Zero dependencies. The SDK uses the Go standard library only.

```go theme={null}
import traceten "github.com/traceten/traceten-go"
```

## Minimal example

```go theme={null}
package main

import (
	"log"
	"os"

	traceten "github.com/traceten/traceten-go"
)

func main() {
	client, err := traceten.New(
		"ttid_7Rb4TrC1dTbnD8w3s1TS12",                  // your site id
		"https://ingest.traceten.com",  // your ingest host (no trailing slash needed)
		traceten.WithAPIKey(os.Getenv("TRACETEN_API_KEY")), // required
		traceten.WithOnError(func(err error) {
			log.Printf("traceten delivery error: %v", err)
		}),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close() // required: flushes queued events on shutdown

	// A pageview -> POST /v1/server/events
	_ = client.Page(traceten.PageEvent{
		URL:       "https://acme.com/pricing",
		Referrer:  "https://www.perplexity.ai/",
		VisitorID: "550e8400-e29b-41d4-a716-446655440000", // from your request context
	})

	// A conversion worth $49.00 -> POST /v1/server/conversions
	cents := int64(4900)
	_ = client.Track("subscription_started", traceten.TrackEvent{
		VisitorID:  "550e8400-e29b-41d4-a716-446655440000",
		ValueCents: &cents, // minor units: 4900 == $49.00
		Currency:   "usd",
		Properties: map[string]interface{}{"plan": "team"},
	})
}
```

## How it behaves

`Page` and `Track` enqueue the event and return immediately, never blocking on the network. A background goroutine batches, retries, and flushes: when the events queue reaches the flush threshold, on the flush interval, on an explicit `Flush`, and on `Close`.

Neither method returns a delivery error. Delivery failures reach your `WithOnError` callback and are also returned by `Flush`. Both methods do return an `error` synchronously for a programmer mistake, so you catch a mis-shaped call before it enqueues:

* `Track` called without a `VisitorID`
* an event name that fails its pattern
* a `URL` that is not a valid URL
* a bad `Currency` or id charset

## Configuration

```go theme={null}
client, err := traceten.New(
	"ttid_7Rb4TrC1dTbnD8w3s1TS12",
	"https://ingest.traceten.com",
	traceten.WithAPIKey(os.Getenv("TRACETEN_API_KEY")),  // required
	traceten.WithFlushAt(50),                      // flush the events queue at this size (clamped to 1..50)
	traceten.WithFlushInterval(5*time.Second),     // background flush cadence
	traceten.WithMaxRetries(3),                    // retries on 5xx / 429 / network error
	traceten.WithTimeout(10*time.Second),          // per-request timeout
	traceten.WithOnError(func(err error) { /* ... */ }),
)
```

`host` must be an absolute http(s) URL. A trailing slash is stripped. There is no default host: each deployment has its own ingest URL.

`host` must use `https` unless it is a loopback address. The API key is sent as a Bearer token on every request, so a plaintext `http` host would transmit a live credential in cleartext; the SDK refuses one at construction.

`WithAPIKey` is required. See [The API key](#the-api-key) below.

## The API key

`WithAPIKey` is required. Create a key in the dashboard under Settings, API keys, or use the key shown once when you created the site.

The key must carry the **Send events** permission (`ingest:write`). New keys are created read-only by default, so tick that box when you create one, or the API will reject your calls. The key created automatically with a new site already has it. See [permissions](/api/authentication#permissions).

Keep it on your server. It is a secret: never put it in client-side code, a mobile app, or a public repository. It is not the same value as the site id, which is public and already embedded in your pages.

The key does two things:

1. It gets you in. The SDK posts to `{host}/v1/server/events`, `{host}/v1/server/conversions` and `{host}/v1/server/payments`, which return `401` without a valid key. The open `/v1/events` and `/v1/conversions` endpoints stay open for the browser snippet, which cannot hold a secret.
2. It gets you your own quota. Authenticated traffic is rate-limited on a bucket tied to the key, separate from the shared per-site bucket. the site id is public, so anyone who can read your page source can send events under it. With a key, that traffic cannot exhaust your allowance and 429 your conversion calls.

`New` validates the key's shape and returns `ErrMissingAPIKey` or a `*ValidationError` before the client starts. An integration that silently drops every event is worse than one that fails on its first line.

To rotate a key: create the new one, deploy it, then revoke the old one. Revocation normally takes effect at the edge within about a minute. If our
database is unreachable at that moment, an edge location that was already using
the key may keep honouring it for up to about fifteen minutes more, so that a
database blip cannot silently drop your events.

## API reference

### `New(siteID, host string, opts ...Option) (*Client, error)`

Returns `ErrMissingSiteID`, `ErrMissingHost`, `ErrInvalidHost`, `ErrMissingAPIKey`, or a `*ValidationError` for a bad `siteID` charset or a malformed API key.

| Option                             | Default    | Description                                                                            |
| ---------------------------------- | ---------- | -------------------------------------------------------------------------------------- |
| `WithAPIKey(string)`               | (required) | Secret key, sent as `Authorization: Bearer <key>`. See [The API key](#the-api-key).    |
| `WithFlushAt(int)`                 | `50`       | Flush the events queue at this size. Clamped to `[1, 50]`.                             |
| `WithFlushInterval(time.Duration)` | `5s`       | Background flush cadence.                                                              |
| `WithMaxRetries(int)`              | `3`        | Retry attempts on 5xx / 429 / network error.                                           |
| `WithTimeout(time.Duration)`       | `10s`      | Per-request timeout.                                                                   |
| `WithHTTPClient(*http.Client)`     |            | Custom transport or proxy.                                                             |
| `WithOnError(func(error))`         |            | Async delivery-failure hook. May run on multiple goroutines; keep it concurrency-safe. |

### `Page(ev PageEvent) error`

Enqueues a pageview to `POST {host}/v1/server/events` (camelCase wire fields). Returns an error only for a programmer or validation mistake, never for delivery.

| Field       | Type        | Required | Default      | Description                          |
| ----------- | ----------- | -------- | ------------ | ------------------------------------ |
| `URL`       | `string`    | yes      |              | Valid URL, up to 2048 chars.         |
| `Referrer`  | `string`    | no       | `""`         | Referring URL.                       |
| `VisitorID` | `string`    | no       |              | UUID or `h:<hash>`.                  |
| `SessionID` | `string`    | no       |              | Your session token.                  |
| `UserAgent` | `string`    | no       |              | The end user's User-Agent, if known. |
| `EventName` | `string`    | no       | `"pageview"` | Must match `^[a-z][a-z0-9_-]*$`.     |
| `Timestamp` | `time.Time` | no       | now (UTC)    | Event time.                          |

**Which user agent gets recorded.** The `UserAgent` you pass is what Traceten
stores on the event and classifies on. Omit it and the event falls back to the
`User-Agent` header the SDK's own HTTP client sent. For Go that is the standard
library's `Go-http-client/1.1`, which describes your server rather than the
visitor. That substitution applies only to requests authenticated with an API
key; the browser snippet cannot override its own `User-Agent` this way.

### `Track(name string, ev TrackEvent) error`

Enqueues a conversion to `POST {host}/v1/server/conversions` (snake\*case wire fields: `event_name`, `value_cents`, `visitor_id`). `name` must match `^[a-z]a-z0-9*]\*$`(no hyphen).`VisitorID` is required. Both are validated synchronously: a bad call returns an error and enqueues nothing.

| Field        | Type                     | Required | Default   | Description                                                                                |
| ------------ | ------------------------ | -------- | --------- | ------------------------------------------------------------------------------------------ |
| `VisitorID`  | `string`                 | yes      |           | UUID or `h:<hash>`.                                                                        |
| `Properties` | `map[string]interface{}` | no       | `{}`      | Arbitrary JSON properties.                                                                 |
| `ValueCents` | `*int64`                 | no       |           | Non-negative minor units. Pointer so a real `0` differs from unset. Maps to `value_cents`. |
| `Currency`   | `string`                 | no       |           | ISO-4217 code, uppercased on the wire.                                                     |
| `SessionID`  | `string`                 | no       |           | Your session token.                                                                        |
| `Timestamp`  | `time.Time`              | no       | now (UTC) | Event time.                                                                                |

`ValueCents` is in minor units. For \$49.00, set it to `4900`. The field is spelled out as `ValueCents` (not `Value`) to prevent the dollars-versus-cents mistake. It is a pointer so a real `0` is distinguishable from unset.

```go theme={null}
cents := int64(12999)
_ = client.Track("order_completed", traceten.TrackEvent{
	VisitorID:  "550e8400-e29b-41d4-a716-446655440000",
	ValueCents: &cents,
	Currency:   "usd",
	Properties: map[string]interface{}{"order_id": "ord_5521", "items": 3},
})
```

<Warning>
  Keep `VisitorID` opaque: a random first-party id or a salted hash you control. Never a bare `sha256(email)` or any hash of an email or phone number (that is re-identifiable PII). The SDK enforces the shape (UUID or `h:<64-hex>`) but cannot see what you hashed. Keep `Properties` to ids and enums such as `{"plan": "team"}`. Do not put emails, names, or phone numbers in them.
</Warning>

### `Goal(name string, ev TrackEvent) error`

```go theme={null}
err := client.Goal("demo_booked", traceten.TrackEvent{
	VisitorID:  visitorID,
	Properties: map[string]interface{}{"plan": "pro"},
})
```

Records a [goal](/goals/overview) completion. Same `TrackEvent`, same endpoint, and same payload as `Track`, with one difference: it returns a `*ValidationError` on the reserved names, which belong to the Stripe and Shopify integrations, and enqueues nothing.

Reserved: `payment`, `free_trial`, `trial_started`, `trial_converted`, `subscription_started`, `subscription_upgraded`, `subscription_downgraded`, `subscription_renewed`, `subscription_cancel_scheduled`, `subscription_reactivated`, `subscription_ended`. `Track` still accepts them, because that is how those events are legitimately sent.

Goal property keys and values are both stored, and both are read back: `GET /v1/goals/{name}/properties` returns each key and its most common values, and the dashboard renders the same breakdown. Keep them to ids and enums. See [what happens to property keys and values](/goals/overview#what-happens-to-property-keys-and-values-exactly).

### `Payment(ctx context.Context, p Payment) (PaymentResult, error)`

Records a payment from any payment processor via `POST {host}/v1/server/payments`. See the [Payment API reference](/api/payments) for the full contract.

Unlike `Page` and `Track`, this one blocks until the server answers and returns what it did with the payment.

| Field                | Type        | Required | Default   | Description                                                                                                   |
| -------------------- | ----------- | -------- | --------- | ------------------------------------------------------------------------------------------------------------- |
| `TransactionID`      | `string`    | yes      |           | The processor's id for the payment. The idempotency key.                                                      |
| `Amount`             | `float64`   | yes      |           | Major unit, not cents. `49.99` for \$49.99. Zero records a trial.                                             |
| `Currency`           | `string`    | yes      |           | ISO-4217 code, uppercased on the wire.                                                                        |
| `Provider`           | `string`    | no       | `api`     | Your label for the processor. Lowercase, 1 to 32 characters.                                                  |
| `VisitorID`          | `string`    | no       |           | UUID or `h:<hash>`. The strongest match.                                                                      |
| `Email`              | `string`    | no       |           | Used to find the customer's sessions. Never stored.                                                           |
| `CustomerID`         | `string`    | no       |           | The processor's customer identifier.                                                                          |
| `Renewal`            | `bool`      | no       | `false`   | A subscription renewal. Does not feed the LTV report.                                                         |
| `Refunded`           | `bool`      | no       | `false`   | Refunds the payment **in full**. Never send a negative `Amount`.                                              |
| `IsFreeTrial`        | `bool`      | no       | `false`   | Records a trial. Implied by `Amount: 0`.                                                                      |
| `Timestamp`          | `time.Time` | no       | now (UTC) | Payment time.                                                                                                 |
| `SettlementAmount`   | `*float64`  | no       |           | Fallback amount, in a currency Traceten can price. Must be set with `SettlementCurrency`, or neither is sent. |
| `SettlementCurrency` | `*string`   | no       |           | Fallback ISO-4217 code. Must be set with `SettlementAmount`, or neither is sent.                              |

`Amount` is the major unit, the opposite of `Track`'s `ValueCents`. For \$49.99 pass `49.99`, and for ¥5000 pass `5000`. It is the number you read off your processor.

If `Currency` isn't one Traceten has a reference rate for, set `SettlementAmount`/`SettlementCurrency` to the processor's own conversion of the payment into a currency it does price (for example, a payout figure), and the payment is recorded off that instead of being dropped. Both must be set together; if only one is set, neither is sent.

<Note>
  **Partial refunds do not go through this method.** `Refunded: true` takes back everything still
  outstanding on the transaction, and this SDK has no field for a smaller figure. To refund part of
  a payment, POST to [`/v1/server/payments`](/api/payments#refunds) with `refunded_amount`, which is
  the **total** refunded against the transaction so far.
</Note>

```go theme={null}
res, err := client.Payment(context.Background(), traceten.Payment{
	TransactionID: "pay_9fK2mQ",
	Amount:        49.99,
	Currency:      "USD",
	Provider:      "dodo",
	Email:         "ada@example.com",
})
if err != nil {
	// Log and retry later. The endpoint is idempotent, so a retry is safe.
}
// res.Status is "recorded", "trial", "refunded" or "duplicate".
```

Calling it again with the same `TransactionID` returns `"duplicate"` and creates nothing. That is why the SDK retries a 5xx here.

It returns a `*ValidationError` for an invalid field, and a delivery error when the payment could not be sent after every retry. It is the only method in this SDK that returns a delivery failure to the caller, because a dropped payment is missing revenue rather than a missing pageview.

<Warning>
  Do not send payments here for a processor you have also connected natively. Traceten would record
  the payment twice and overstate your revenue. See [avoiding duplicate
  payments](/api/payments#avoiding-duplicate-payments).
</Warning>

### `Flush(ctx context.Context) error`

```go theme={null}
err := client.Flush(ctx)
```

Drains both queues and blocks until every batch has been attempted, with retries. Returns the first delivery error, if any. Honors `ctx` cancellation.

### `Close() error`

```go theme={null}
err := client.Close()
```

Flushes, stops the background goroutine, and marks the client closed. Idempotent.

## Batching and retries

* Pageviews are sent to `/v1/server/events` in batch envelopes of up to 50 events. If you enqueue 120 pageviews, the SDK sends them as batches of 50, 50, and 20.
* Conversions are sent to `/v1/server/conversions` one body per event.
* On a 5xx, a 429, or a network or timeout error, the batch is retried with `200ms * 2^attempt` full-jitter backoff, up to `WithMaxRetries` times.
* On any other 4xx the payload is malformed. The SDK drops it and calls `WithOnError`. It does not retry, because retrying a rejected payload forever is a self-inflicted denial of service.

## Flush on exit

Go has no reliable `atexit`. `Close` is the flush-on-exit mechanism. Call it, usually `defer client.Close()`, before the process exits so buffered events are delivered. Data loss on a hard `kill -9` is expected. Data loss on a graceful shutdown is not, provided you called `Close`.

## Errors

* `New`, `Page`, and `Track` return an `error` synchronously for programmer mistakes: `ErrMissingSiteID`, `ErrMissingHost`, `ErrInvalidHost`, `ErrMissingVisitorID`, `ErrClosed`, and `*ValidationError` (bad URL, event name, currency, or ids).
* Delivery failures never come back from `Page` or `Track`. They reach `WithOnError` and are also returned by `Flush`. The `OnError` callback may run on multiple goroutines, so keep it concurrency-safe.

## Verify it worked

1. Send one `Page` call from your backend.
2. Open **Sites → Events** in the [Traceten dashboard](https://app.traceten.com/dashboard) for that site.
3. The event appears in the live event feed within a few seconds.

If nothing arrives, register `WithOnError` to see why.

## Verify webhook signatures

If you subscribe to Traceten's outbound webhooks (AI-session and conversion events), verify every delivery before you act on it. `VerifyWebhook` recomputes the `X-Traceten-Signature` HMAC in constant time (`hmac.Equal`), enforces the replay window, and returns the parsed `*WebhookEvent`, or an error wrapping `ErrSignatureVerification`.

### Verify the raw body, not a re-serialized one

The single most common verification bug: you decode the JSON, re-encode it, and the bytes no longer match what Traceten signed (key order, whitespace, and unicode escaping all differ). Read the body with `io.ReadAll` **before** any JSON decoding, and only act on the event after verification succeeds.

```go theme={null}
package main

import (
	"errors"
	"io"
	"net/http"
	"os"

	traceten "github.com/traceten/traceten-go"
)

func main() {
	secret := os.Getenv("TRACETEN_WEBHOOK_SECRET")

	http.HandleFunc("/traceten-webhooks", func(w http.ResponseWriter, r *http.Request) {
		rawBody, err := io.ReadAll(r.Body) // RAW bytes, before any decode
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		event, err := traceten.VerifyWebhook(
			rawBody,
			r.Header.Get("X-Traceten-Signature"),
			r.Header.Get("X-Traceten-Signature-Timestamp"),
			secret,
		)
		if err != nil {
			if errors.Is(err, traceten.ErrSignatureVerification) {
				w.WriteHeader(http.StatusUnauthorized)
				return
			}
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		switch event.Type {
		case "ai_session.classified":
			data, err := event.AISessionClassifiedData() // typed *AiSessionClassifiedData
			if err == nil {
				_ = data
			}
		case "conversion.attributed":
			data, err := event.ConversionAttributedData()
			if err == nil {
				_ = data
			}
		}
		// Acknowledge fast; do heavy work on a queue.
		w.WriteHeader(http.StatusOK)
	})

	http.ListenAndServe(":3000", nil)
}
```

Use `traceten.WithTolerance(seconds)` to change the 300-second replay window (pass `math.Inf(1)` only when testing against the fixed [conformance vector](/webhooks/verify-signatures)), and `traceten.WithNow(seconds)` to inject the clock in tests.

## Troubleshooting

**Events never appear.** Register `WithOnError`. The most common causes are a wrong host, a site id typo, or a firewall blocking outbound HTTPS.

**Error from `Track`.** You omitted `VisitorID`, or the event name has a hyphen. Conversion names allow only `a-z`, `0-9`, and `_`.

**Error from `Page`.** The `URL` is not a valid absolute URL, or the `EventName` does not match `^[a-z][a-z0-9_-]*$`.

**A short-lived program exits before sending.** Ensure `defer client.Close()` runs, or call `client.Flush(ctx)` before returning from `main`.

## Next

* [Node.js SDK](/sdks/node)
* [Python SDK](/sdks/python)
* [Browser API reference](/sdks/browser) for client-side tracking
* [Stripe revenue attribution](/integrations/stripe)
