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

# Analytics

> Program-wide rollups from your backend: a summary of members and points, per-offer performance, and a timeseries over a window - all server-side with the Org-API-Key.

<Warning>
  Analytics is **server-side**. These reads use the secret **Org-API-Key** on
  your backend only - **never** ship it in an app or client code. A request with
  a **member token** is rejected with `403`. See
  [Authentication](/authentication) and [Server-side API](/guides/server-mode).
</Warning>

Three read-only endpoints roll up your program's activity: a program-wide
**summary**, per-**offers** performance, and a **timeseries** bucketed over a
window. All require the Org-API-Key.

| Endpoint                       | SDK method                                    | Returns                                              |
| ------------------------------ | --------------------------------------------- | ---------------------------------------------------- |
| `GET /v1/analytics/summary`    | `analyticsSummary()`                          | Program-wide totals and redemption status breakdown. |
| `GET /v1/analytics/offers`     | `analyticsOffers()`                           | One row per offer.                                   |
| `GET /v1/analytics/timeseries` | `analyticsTimeseries({ from, to, interval })` | Buckets over a window.                               |

## Summary

`analyticsSummary()` calls `GET /v1/analytics/summary` and returns
program-wide totals plus a redemption status-count breakdown.

```bash curl theme={null}
curl https://api.expys.com/v1/analytics/summary \
  -H "Authorization: Bearer YOUR_ORG_API_KEY"
```

<ResponseField name="memberCount" type="number" required>
  Total members in the program.
</ResponseField>

<ResponseField name="pointsMinted" type="number" required>
  Total points ever minted across the program.
</ResponseField>

<ResponseField name="pointsSpent" type="number" required>
  Total points ever spent across the program.
</ResponseField>

<ResponseField name="completionRate" type="number" required>
  Share of redemptions that reached completion.
</ResponseField>

<ResponseField name="redemptions" type="RedemptionStatusCounts" required>
  A breakdown of redemptions by status (see below).
</ResponseField>

### Redemption status counts

`redemptions` counts every redemption by its lifecycle status, plus a `total`:

| Key                 | Meaning                                 |
| ------------------- | --------------------------------------- |
| `submitted`         | Redemptions submitted.                  |
| `open`              | Redemptions in the open state.          |
| `awaiting_vendor`   | Waiting on the vendor.                  |
| `awaiting_customer` | Waiting on the customer.                |
| `purchased`         | Purchased (sometimes called confirmed). |
| `completed`         | Completed experiences.                  |
| `canceled`          | Canceled redemptions.                   |
| `total`             | Sum across all statuses.                |

All values are numbers.

## Offers

`analyticsOffers()` calls `GET /v1/analytics/offers` and returns one
`OfferAnalytics` row per offer.

```bash curl theme={null}
curl https://api.expys.com/v1/analytics/offers \
  -H "Authorization: Bearer YOUR_ORG_API_KEY"
```

<ResponseField name="offers" type="OfferAnalytics[]" required>
  Per-offer performance rows.
</ResponseField>

Each `OfferAnalytics` row:

| Field           | Type   | Description                                  |
| --------------- | ------ | -------------------------------------------- |
| `offerId`       | string | The offer this row describes.                |
| `signups`       | number | Members who signed up for the offer.         |
| `completions`   | number | Redemptions of the offer that completed.     |
| `cancellations` | number | Redemptions of the offer that were canceled. |
| `pointsSpent`   | number | Points spent on this offer.                  |

## Timeseries

`analyticsTimeseries({ from, to, interval })` calls
`GET /v1/analytics/timeseries` and returns buckets over a window. All three query
parameters are **required**.

<ParamField query="from" type="string" required>
  Start timestamp of the window (inclusive). The first bucket begins here.
</ParamField>

<ParamField query="to" type="string" required>
  End timestamp of the window. The last bucket ends here.
</ParamField>

<ParamField query="interval" type="string" required>
  The bucket granularity - how the window between `from` and `to` is divided into
  buckets (for example by day or by hour).
</ParamField>

```bash curl theme={null}
curl "https://api.expys.com/v1/analytics/timeseries?from=2026-06-01T00:00:00Z&to=2026-06-23T00:00:00Z&interval=day" \
  -H "Authorization: Bearer YOUR_ORG_API_KEY"
```

<ResponseField name="buckets" type="TimeseriesBucket[]" required>
  One entry per interval bucket across the window.
</ResponseField>

Each `TimeseriesBucket`:

| Field          | Type   | Description                  |
| -------------- | ------ | ---------------------------- |
| `startTime`    | number | Start of the bucket.         |
| `endTime`      | number | End of the bucket.           |
| `signups`      | number | Signups in the bucket.       |
| `pointsMinted` | number | Points minted in the bucket. |
| `pointsSpent`  | number | Points spent in the bucket.  |

<Note>
  `from` and `to` are timestamps bounding the window; `interval` is the bucket
  granularity that divides it. Omitting any of the three is an error - all are
  required.
</Note>

## In code

The server client is constructed with the Org-API-Key. `analyticsSummary`,
`analyticsOffers`, and `analyticsTimeseries` are all called on it.

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { initialize } from "@expys/sdk";

  const orgApiKey = process.env.EXPYS_ORG_API_KEY;
  if (!orgApiKey) {
    throw new Error(
      "Set EXPYS_ORG_API_KEY (your secret Org-API-Key, e.g. expys_live_...). " +
        "Run this on a backend only, never in a client app.",
    );
  }

  // Construct the client with the machine credential as the token. Server-mode
  // methods are guarded against member tokens client-side.
  const expys = initialize({
    baseUrl: process.env.EXPYS_BASE_URL,
    environment: "sandbox",
    token: orgApiKey,
  });

  const externalUserID = process.env.EXPYS_EXTERNAL_USER_ID ?? "user_42";

  async function main(): Promise<void> {
    // Mint a short-lived member token for your app to use (return this to the app,
    // never the Org-API-Key). Idempotent POST: a retry replays rather than re-mints.
    const grant = await expys.exchangeToken({ externalUserID });
    console.log(`minted member token expiring at ${grant.expiresAt}`);

    // Upsert the member's profile. PUT is idempotent by HTTP semantics (no key).
    const member = await expys.setMember(externalUserID, {
      displayName: "Ada Lovelace",
      tier: "gold",
    });
    console.log(`member ${member.externalUserID} is now tier=${member.tier}`);

    // Credit points to the member's wallet. Idempotent POST sends an Idempotency-Key.
    const credited = await expys.creditPoints({
      amount: 100,
      externalUserID,
      reason: "welcome bonus",
    });
    console.log(`new balance: ${credited.balance} ${credited.currency.symbol}`);

    // Register a webhook. The signingSecret is shown ONLY on creation - store it now.
    const webhook = await expys.createWebhook({
      events: ["redemption.created"],
      url: "https://example.com/expys/webhooks",
    });
    console.log(`webhook ${webhook.id} secret: ${webhook.signingSecret}`);

    // Org-wide analytics rollups.
    const summary = await expys.analyticsSummary();
    console.log(
      `members: ${summary.memberCount}, minted: ${summary.pointsMinted}`,
    );
  }
  ```

  ```swift Swift theme={null}
  import ExpysSDK
  import Foundation

  @main
  struct ServerModeExample {
    static func main() async throws {
      let environment = ProcessInfo.processInfo.environment
      guard let orgApiKey = environment["EXPYS_ORG_API_KEY"] else {
        fatalError(
          "Set EXPYS_ORG_API_KEY (your secret Org-API-Key, e.g. expys_live_...). "
            + "Run this on a backend only, never in a client app.")
      }
      let baseURL =
        environment["EXPYS_BASE_URL"].flatMap(URL.init(string:))
        ?? ExpysConfiguration.defaultBaseURL
      let externalUserID = environment["EXPYS_EXTERNAL_USER_ID"] ?? "user_42"

      // Configure the client with the machine credential as the token. Server-mode
      // methods are guarded against member tokens client-side.
      let client = ExpysClient(
        configuration: ExpysConfiguration(token: orgApiKey, environment: .sandbox, baseURL: baseURL)
      )

      // Mint a short-lived member token for your app to use (return this to the app,
      // never the Org-API-Key). Idempotent POST: a retry replays rather than re-mints.
      let grant = try await client.exchangeToken(TokenExchangeRequest(externalUserID: externalUserID))
      print("minted member token expiring at \(grant.expiresAt)")

      // Upsert the member's profile. PUT is idempotent by HTTP semantics (no key).
      let member = try await client.setMember(
        externalUserID: externalUserID,
        SetMemberRequest(displayName: "Ada Lovelace", tier: "gold"))
      print("member \(member.externalUserID) is now tier=\(member.tier)")

      // Credit points to the member's wallet. Idempotent POST sends an Idempotency-Key.
      let credited = try await client.creditPoints(
        CreditWalletRequest(amount: 100, externalUserID: externalUserID, reason: "welcome bonus"))
      print("new balance: \(credited.balance) \(credited.currency.symbol)")

      // Register a webhook. The signingSecret is shown ONLY on creation - store it now.
      let webhook = try await client.createWebhook(
        CreateWebhookRequest(
          events: ["redemption.created"], url: "https://example.com/expys/webhooks"))
      print("webhook \(webhook.id) secret: \(webhook.signingSecret)")

      // Org-wide analytics rollups.
      let summary = try await client.analyticsSummary()
      print("members: \(summary.memberCount), minted: \(summary.pointsMinted)")
    }
  }
  ```

  ```kotlin Kotlin theme={null}
  package com.expys.sdk.examples.servermode

  import com.expys.sdk.ExpysClient
  import com.expys.sdk.ExpysConfiguration
  import com.expys.sdk.ExpysEnvironment
  import com.expys.sdk.models.CreateWebhookRequest
  import com.expys.sdk.models.CreditWalletRequest
  import com.expys.sdk.models.SetMemberRequest
  import com.expys.sdk.models.TokenExchangeRequest
  import kotlinx.coroutines.runBlocking
  import java.net.URI

  fun main() = runBlocking {
    val orgApiKey = System.getenv("EXPYS_ORG_API_KEY")
      ?: error(
        "Set EXPYS_ORG_API_KEY (your secret Org-API-Key, e.g. expys_live_...). " +
          "Run this on a backend only, never in a client app.",
      )
    val externalUserID = System.getenv("EXPYS_EXTERNAL_USER_ID") ?: "user_42"

    // Configure the client with the machine credential as the token. Server-mode
    // methods are guarded against member tokens client-side.
    val client = ExpysClient.create(
      ExpysConfiguration(
        token = orgApiKey,
        environment = ExpysEnvironment.SANDBOX,
        baseUrl = System.getenv("EXPYS_BASE_URL") ?: ExpysConfiguration.DEFAULT_BASE_URL,
      ),
    )

    // Mint a short-lived member token for your app to use (return this to the app,
    // never the Org-API-Key). Idempotent POST: a retry replays rather than re-mints.
    val grant = client.exchangeToken(TokenExchangeRequest(externalUserID = externalUserID))
    println("minted member token expiring at ${grant.expiresAt}")

    // Upsert the member's profile. PUT is idempotent by HTTP semantics (no key).
    val member = client.setMember(externalUserID, SetMemberRequest(displayName = "Ada Lovelace", tier = "gold"))
    println("member ${member.externalUserID} is now tier=${member.tier}")

    // Credit points to the member's wallet. Idempotent POST sends an Idempotency-Key.
    val credited = client.creditPoints(
      CreditWalletRequest(amount = 100, externalUserID = externalUserID, reason = "welcome bonus"),
    )
    println("new balance: ${credited.balance} ${credited.currency.symbol}")

    // Register a webhook. The signingSecret is shown ONLY on creation - store it now.
    val webhook = client.createWebhook(
      CreateWebhookRequest(events = listOf("redemption.created"), url = URI("https://example.com/expys/webhooks")),
    )
    println("webhook ${webhook.id} secret: ${webhook.signingSecret}")

    // Org-wide analytics rollups.
    val summary = client.analyticsSummary()
    println("members: ${summary.memberCount}, minted: ${summary.pointsMinted}")
  }
  ```
</CodeGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Members and tiers" icon="users" href="/guides/members">
    The per-member counterpart: profiles, wallet, and redemption counts.
  </Card>

  <Card title="Points and wallet" icon="coins" href="/guides/points-and-wallet">
    How `pointsMinted` and `pointsSpent` are generated.
  </Card>
</CardGroup>
