{
  "openapi": "3.1.0",
  "info": {
    "title": "DataGauge API",
    "version": "1.0.0",
    "summary": "Programmable metrics API for developers running multiple apps: POST events and subscription state from any stack, read MAU, signups, revenue, and MRR back over HTTP.",
    "description": "DataGauge has three planes.\n\nThe **ingest** plane (`https://in.datagauge.dev`) accepts append-only events and mutable subscription state. The **query** plane (`https://api.datagauge.dev`) serves the same numbers the dashboard shows. Both authenticate with an app-scoped data-plane key: server keys (`sk_live_`) can do everything; publishable keys (`pk_live_`) may only send behavioral events and can never read or write money.\n\nThe **control** plane manages apps and keys themselves — create an app, mint and revoke its ingest keys, set billing webhook secrets — so an agent can run the whole loop headlessly. It authenticates with an *org-scoped* `dg_live_` token, a deliberately separate credential class: a `dg_live_` token is not accepted by ingest or query, and an `sk_live_` key is not accepted here.\n\n**Two responses return credential material exactly once and unrecoverably**: `POST /v1/apps` (`sk_key`) and `POST /v1/apps/{app_id}/keys` (`key`). Persist them at the moment you receive them. Every read path returns masked forms only; if the material is lost the only remedy is to mint a replacement and revoke the old key.",
    "contact": {
      "name": "DataGauge",
      "url": "https://datagauge.dev"
    }
  },
  "servers": [
    {
      "url": "https://in.datagauge.dev",
      "description": "Ingest plane (writes)"
    },
    {
      "url": "https://api.datagauge.dev",
      "description": "Query plane (reads)"
    },
    {
      "url": "https://api.datagauge.dev",
      "description": "Control plane (manage apps, keys and webhook secrets)"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Ingest",
      "description": "Send events and subscription state."
    },
    {
      "name": "Query",
      "description": "Read metrics back."
    },
    {
      "name": "Control",
      "description": "Manage apps, their ingest keys and their webhook secrets with a `dg_live_` token. Each operation lists the scope it requires under `x-required-scopes`; a token carries an explicit scope set chosen at mint time and scopes never imply one another (`apps:write` does not grant `keys:write`)."
    }
  ],
  "paths": {
    "/v1/events": {
      "post": {
        "tags": [
          "Ingest"
        ],
        "summary": "Ingest events",
        "description": "Append-only event ingestion. Body is a single event object **or** an array of them (offline replay), up to 500 items / 1 MB. Reserved names: `user_signup` (counted in signups + MAU) and `payment` (routed to revenue — requires `props.amount` in minor units and `props.currency`, and an `sk_live_` key). Unknown fields are ignored. A `202` means the batch is durably queued.",
        "servers": [
          {
            "url": "https://in.datagauge.dev"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/Event"
                  },
                  {
                    "type": "array",
                    "maxItems": 500,
                    "items": {
                      "$ref": "#/components/schemas/Event"
                    }
                  }
                ]
              },
              "examples": {
                "single": {
                  "summary": "A single signup event",
                  "value": {
                    "name": "user_signup",
                    "user": "usr_8813",
                    "id": "evt_01J9XW",
                    "at": "2026-07-18T09:14:02.123Z",
                    "props": {
                      "plan": "pro"
                    }
                  }
                },
                "payment": {
                  "summary": "A payment event (sk key only)",
                  "value": {
                    "name": "payment",
                    "user": "usr_8813",
                    "id": "ch_01J9XW",
                    "props": {
                      "amount": 4900,
                      "currency": "usd"
                    }
                  }
                },
                "batch": {
                  "summary": "An offline replay batch",
                  "value": [
                    {
                      "name": "app_open",
                      "user": "usr_1"
                    },
                    {
                      "name": "app_open",
                      "user": "usr_2"
                    }
                  ]
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted and durably queued.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IngestResult"
                },
                "example": {
                  "ok": true,
                  "accepted": 2,
                  "rejected": 0
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "A publishable (`pk_live_`) key attempted a batch that is entirely payment (money) writes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "ok": false,
                  "error": "pk keys cannot write payments"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        }
      }
    },
    "/v1/objects": {
      "post": {
        "tags": [
          "Ingest"
        ],
        "summary": "Upsert subscription state",
        "description": "Mutable state upserts, last-write-wins by `at`. v1 supports `type: \"subscription\"` only — the source of MRR. Requires an `sk_live_` key. Only subscriptions with `status: \"active\"` count toward MRR. `mrr` is monthly-normalized minor units (annual plan → amount / 12).",
        "servers": [
          {
            "url": "https://in.datagauge.dev"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/SubscriptionObject"
                  },
                  {
                    "type": "array",
                    "maxItems": 500,
                    "items": {
                      "$ref": "#/components/schemas/SubscriptionObject"
                    }
                  }
                ]
              },
              "example": {
                "type": "subscription",
                "id": "sub_9",
                "status": "active",
                "mrr": 4900,
                "currency": "usd",
                "at": "2026-07-18T09:14:02Z"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted and durably queued.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IngestResult"
                },
                "example": {
                  "ok": true,
                  "accepted": 1,
                  "rejected": 0
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Objects require an `sk_live_` key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "ok": false,
                  "error": "objects require an sk key"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          }
        }
      }
    },
    "/v1/metrics/{metric}": {
      "get": {
        "tags": [
          "Query"
        ],
        "summary": "Read a metric",
        "description": "Returns the same numbers the dashboard shows, for a date window. Authenticate one of two ways: an app-scoped `sk_live_` key (the project is implied by the key), or an org-scoped `dg_live_` control token carrying the `metrics:read` scope (name the project with `?project=`, and only projects in the token's organization are readable — any other id is a `404`). Publishable `pk_live_` keys are read-denied. Responses are cached ~5 min at the edge (`x-cache: hit|miss`), and served stale (`x-cache: stale`) rather than failed if the upstream is rate-limited or down. Distinct counts (`mau`, `dau`, `signups`) are HyperLogLog-precise and immune to duplicate events; money endpoints are exact.",
        "servers": [
          {
            "url": "https://api.datagauge.dev"
          }
        ],
        "security": [
          {
            "bearerAuth": []
          },
          {
            "controlBearerAuth": [
              "metrics:read"
            ]
          }
        ],
        "x-required-scopes": [
          "metrics:read"
        ],
        "parameters": [
          {
            "name": "metric",
            "in": "path",
            "required": true,
            "description": "Which metric to read.",
            "schema": {
              "type": "string",
              "enum": [
                "mau",
                "dau",
                "signups",
                "signups_daily",
                "revenue",
                "mrr",
                "mrr_history",
                "volume",
                "event_names",
                "event_count",
                "event_series"
              ]
            }
          },
          {
            "name": "date_from",
            "in": "query",
            "required": false,
            "description": "Inclusive start date, `YYYY-MM-DD`. Defaults to 29 days before today (UTC). Ignored by the windowless `mrr` metric.",
            "schema": {
              "type": "string",
              "format": "date",
              "example": "2026-06-19"
            }
          },
          {
            "name": "date_to",
            "in": "query",
            "required": false,
            "description": "Inclusive end date, `YYYY-MM-DD`. Defaults to today (UTC).",
            "schema": {
              "type": "string",
              "format": "date",
              "example": "2026-07-18"
            }
          },
          {
            "name": "name",
            "in": "query",
            "required": false,
            "description": "Event name to scope to — **required** for `event_count` and `event_series`.",
            "schema": {
              "type": "string",
              "example": "checkout_completed"
            }
          },
          {
            "name": "project",
            "in": "query",
            "required": false,
            "description": "Which project to read. **Required** when authenticating with a `dg_live_` control token (which is org-scoped and could read any of the org's projects); an id outside the token's organization is a `404`. Ignored for `sk_live_` keys, where the project is implied by the key.",
            "schema": {
              "type": "string",
              "example": "proj_abc123"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The metric value(s) for the window.",
            "headers": {
              "x-cache": {
                "description": "Edge cache status. `stale` means the upstream was rate-limited or down and the last known value was served instead — see `x-cache-age` for its age in seconds.",
                "schema": {
                  "type": "string",
                  "enum": [
                    "hit",
                    "miss",
                    "uncached",
                    "stale"
                  ]
                }
              },
              "x-cache-age": {
                "description": "Age in seconds of a stale response. Present only when `x-cache: stale`.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MetricResult"
                },
                "examples": {
                  "mrr": {
                    "summary": "Current MRR per currency",
                    "value": {
                      "ok": true,
                      "metric": "mrr",
                      "project": "prj_abc",
                      "date_from": "2026-06-19",
                      "date_to": "2026-07-18",
                      "data": [
                        {
                          "currency": "usd",
                          "mrr": 4900
                        }
                      ]
                    }
                  },
                  "dau": {
                    "summary": "Daily active users series",
                    "value": {
                      "ok": true,
                      "metric": "dau",
                      "project": "prj_abc",
                      "date_from": "2026-07-17",
                      "date_to": "2026-07-18",
                      "data": [
                        {
                          "day": "2026-07-17",
                          "dau": 1204
                        },
                        {
                          "day": "2026-07-18",
                          "dau": 1330
                        }
                      ]
                    }
                  },
                  "revenue": {
                    "summary": "Revenue series (minor units, earned only)",
                    "value": {
                      "ok": true,
                      "metric": "revenue",
                      "project": "prj_abc",
                      "date_from": "2026-07-17",
                      "date_to": "2026-07-18",
                      "data": [
                        {
                          "day": "2026-07-18",
                          "currency": "usd",
                          "revenue": 129900
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid date range, or `name` missing when required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "ok": false,
                  "error": "invalid date range (YYYY-MM-DD)"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "A publishable (`pk_live_`) key attempted a read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "ok": false,
                  "error": "read APIs require an sk key"
                }
              }
            }
          },
          "404": {
            "description": "Unknown metric, or — for a `dg_live_` control token — a `project` that is not in the token's organization. A cross-organization project is indistinguishable from one that does not exist, so this is not an existence oracle.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "ok": false,
                  "error": "unknown metric"
                }
              }
            }
          },
          "503": {
            "description": "Upstream query failed — safe to retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "ok": false,
                  "error": "upstream query failed"
                }
              }
            }
          }
        }
      }
    },
    "/v1/apps": {
      "post": {
        "tags": [
          "Control"
        ],
        "summary": "Create an app",
        "description": "Creates an app in the organization the `dg_live_` token belongs to and mints its first server key in the same transaction.\n\n**The `sk_key` in the response is returned exactly once.** It is full data-plane key material, already live on ingest and query. No later call — including `GET /v1/apps/{app_id}/keys` — can retrieve it again; reads return a masked form. If you lose it, mint a replacement with `POST /v1/apps/{app_id}/keys` and revoke the old one.",
        "servers": [
          {
            "url": "https://api.datagauge.dev"
          }
        ],
        "security": [
          {
            "controlBearerAuth": []
          }
        ],
        "x-required-scopes": [
          "apps:write"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Display name. Trimmed and internally whitespace-collapsed server-side; must be non-empty and at most 80 characters after normalization.",
                    "example": "My App"
                  },
                  "ownership": {
                    "type": "string",
                    "enum": [
                      "mine",
                      "client_operated"
                    ],
                    "description": "Defaults to `mine`."
                  },
                  "type": {
                    "type": "string",
                    "enum": [
                      "saas",
                      "library",
                      "desktop",
                      "other"
                    ],
                    "description": "Defaults to `saas`."
                  }
                }
              },
              "example": {
                "name": "My App",
                "ownership": "mine",
                "type": "saas"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. Contains `sk_key`, the full server key material — returned **exactly once** and unrecoverable afterwards.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreatedApp"
                },
                "example": {
                  "app_id": "k17b2c3d4e5f6g7h8j9k0m1n2p",
                  "public_id": "proj_abc123",
                  "name": "My App",
                  "ownership": "mine",
                  "type": "saas",
                  "first_event_at": null,
                  "has_stripe_webhook": false,
                  "has_polar_webhook": false,
                  "created_at": 1753142042123,
                  "sk_key": "sk_live_a2K9xQ7mVb3nR8tYc5wZ4dF6gH1jL0pS"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ControlBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ControlUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ControlForbidden"
          },
          "502": {
            "$ref": "#/components/responses/ControlUpstreamUnavailable"
          }
        }
      },
      "get": {
        "tags": [
          "Control"
        ],
        "summary": "List apps",
        "description": "Every app in the token's organization, and only that organization's. An organization with no apps returns `[]` rather than an error. Webhook signing secrets are never included — only `has_stripe_webhook` / `has_polar_webhook` booleans.",
        "servers": [
          {
            "url": "https://api.datagauge.dev"
          }
        ],
        "security": [
          {
            "controlBearerAuth": []
          }
        ],
        "x-required-scopes": [
          "apps:read"
        ],
        "responses": {
          "200": {
            "description": "The organization's apps.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/App"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ControlUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ControlForbidden"
          },
          "502": {
            "$ref": "#/components/responses/ControlUpstreamUnavailable"
          }
        }
      }
    },
    "/v1/apps/{app_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/AppId"
        }
      ],
      "get": {
        "tags": [
          "Control"
        ],
        "summary": "Read an app",
        "servers": [
          {
            "url": "https://api.datagauge.dev"
          }
        ],
        "security": [
          {
            "controlBearerAuth": []
          }
        ],
        "x-required-scopes": [
          "apps:read"
        ],
        "responses": {
          "200": {
            "description": "The app.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/App"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ControlUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ControlForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ControlNotFound"
          },
          "502": {
            "$ref": "#/components/responses/ControlUpstreamUnavailable"
          }
        }
      },
      "patch": {
        "tags": [
          "Control"
        ],
        "summary": "Update an app",
        "description": "Partial update. Omitted fields are left untouched. An invalid field rejects the whole request with a `400` and applies nothing.",
        "servers": [
          {
            "url": "https://api.datagauge.dev"
          }
        ],
        "security": [
          {
            "controlBearerAuth": []
          }
        ],
        "x-required-scopes": [
          "apps:write"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "properties": {
                  "name": {
                    "type": "string",
                    "example": "Renamed"
                  },
                  "ownership": {
                    "type": "string",
                    "enum": [
                      "mine",
                      "client_operated"
                    ]
                  },
                  "type": {
                    "type": "string",
                    "enum": [
                      "saas",
                      "library",
                      "desktop",
                      "other"
                    ]
                  }
                }
              },
              "example": {
                "name": "Renamed"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated app.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/App"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ControlBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ControlUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ControlForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ControlNotFound"
          },
          "502": {
            "$ref": "#/components/responses/ControlUpstreamUnavailable"
          }
        }
      }
    },
    "/v1/apps/{app_id}/keys": {
      "parameters": [
        {
          "$ref": "#/components/parameters/AppId"
        }
      ],
      "post": {
        "tags": [
          "Control"
        ],
        "summary": "Mint an ingest key",
        "description": "Mints a new data-plane key for the app and publishes it to the edge.\n\n**The `key` in the response is returned exactly once.** This is the only moment the full material exists outside the caller; every read path returns a masked form. To rotate: mint here, deploy the new key, then `DELETE` the old one.",
        "servers": [
          {
            "url": "https://api.datagauge.dev"
          }
        ],
        "security": [
          {
            "controlBearerAuth": []
          }
        ],
        "x-required-scopes": [
          "keys:write"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "kind"
                ],
                "properties": {
                  "kind": {
                    "type": "string",
                    "enum": [
                      "sk",
                      "pk"
                    ],
                    "description": "`sk` mints a server key (`sk_live_…`, full access). `pk` mints a publishable key (`pk_live_…`, behavioral events only)."
                  }
                }
              },
              "example": {
                "kind": "sk"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. Contains `key`, the full data-plane key material — returned **exactly once** and unrecoverable afterwards.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MintedKey"
                },
                "example": {
                  "key_id": "kd93hf7s8dk2mfj4hs8dk2mfj4",
                  "key": "sk_live_a2K9xQ7mVb3nR8tYc5wZ4dF6gH1jL0pS"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ControlBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ControlUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ControlForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ControlNotFound"
          },
          "502": {
            "$ref": "#/components/responses/ControlUpstreamUnavailable"
          }
        }
      },
      "get": {
        "tags": [
          "Control"
        ],
        "summary": "List an app's ingest keys",
        "description": "Masked forms only, including revoked keys. Full key material is never returned here — it existed only in the response that minted it. An app you own with no keys returns `[]`, so a `404` never confirms that some other organization's app id is real.",
        "servers": [
          {
            "url": "https://api.datagauge.dev"
          }
        ],
        "security": [
          {
            "controlBearerAuth": []
          }
        ],
        "x-required-scopes": [
          "keys:read"
        ],
        "responses": {
          "200": {
            "description": "The app's keys, masked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AppKey"
                  }
                },
                "example": [
                  {
                    "key_id": "kd93hf7s8dk2mfj4hs8dk2mfj4",
                    "kind": "sk",
                    "masked": "sk_live_a2K9…L0pS",
                    "revoked": false,
                    "mirrored_at": 1753142043000,
                    "created_at": 1753142042123
                  }
                ]
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ControlUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ControlForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ControlNotFound"
          },
          "502": {
            "$ref": "#/components/responses/ControlUpstreamUnavailable"
          }
        }
      }
    },
    "/v1/apps/{app_id}/keys/{key_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/AppId"
        },
        {
          "name": "key_id",
          "in": "path",
          "required": true,
          "description": "The `key_id` from `POST .../keys` or `GET .../keys`. A key that exists but belongs to a different app — even one in your own organization — is a `404`, so the API is not an existence oracle across apps either.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": [
          "Control"
        ],
        "summary": "Revoke an ingest key",
        "description": "Revokes the key and publishes the revocation to the edge. Idempotent: revoking an already-revoked key is still `204`. There is no un-revoke — rotation is mint-then-revoke.",
        "servers": [
          {
            "url": "https://api.datagauge.dev"
          }
        ],
        "security": [
          {
            "controlBearerAuth": []
          }
        ],
        "x-required-scopes": [
          "keys:write"
        ],
        "responses": {
          "204": {
            "description": "Revoked. Empty body."
          },
          "401": {
            "$ref": "#/components/responses/ControlUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ControlForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ControlNotFound"
          },
          "502": {
            "$ref": "#/components/responses/ControlUpstreamUnavailable"
          }
        }
      }
    },
    "/v1/apps/{app_id}/webhooks/{provider}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/AppId"
        },
        {
          "name": "provider",
          "in": "path",
          "required": true,
          "description": "Billing provider. Any other value is not a route this API serves and returns `404`.",
          "schema": {
            "type": "string",
            "enum": [
              "stripe",
              "polar"
            ]
          }
        }
      ],
      "put": {
        "tags": [
          "Control"
        ],
        "summary": "Set a billing webhook signing secret",
        "description": "Stores the provider's signing secret for this app, replacing any previous value, and publishes it to the edge.\n\n**This endpoint is write-only by design. There is deliberately no read path** — no `GET` on this route (it returns `404`), and no other response ever contains the secret. App reads report only `has_stripe_webhook` / `has_polar_webhook` booleans. If you need to know the current value, read it from the provider's dashboard; if in doubt, overwrite it.",
        "servers": [
          {
            "url": "https://api.datagauge.dev"
          }
        ],
        "security": [
          {
            "controlBearerAuth": []
          }
        ],
        "x-required-scopes": [
          "webhooks:write"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "secret"
                ],
                "properties": {
                  "secret": {
                    "type": "string",
                    "minLength": 1,
                    "description": "The provider's signing secret. Trimmed before storage; a missing, non-string, empty or whitespace-only value is a `400`.",
                    "example": "whsec_..."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Stored. Empty body — the secret is never echoed back."
          },
          "400": {
            "$ref": "#/components/responses/ControlBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ControlUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ControlForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ControlNotFound"
          },
          "502": {
            "$ref": "#/components/responses/ControlUpstreamUnavailable"
          }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "AppId": {
        "name": "app_id",
        "in": "path",
        "required": true,
        "description": "The `app_id` returned by `POST /v1/apps` or `GET /v1/apps`. An id belonging to another organization returns `404`, not `403` — see the `404` response on any of these operations.",
        "schema": {
          "type": "string"
        }
      }
    },
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key as a bearer token: `Authorization: Bearer sk_live_...`. Server keys are prefixed `sk_live_`; publishable keys `pk_live_` (behavioral events only, rate-limited, never money, never reads). App-scoped, and accepted by the ingest and query planes only — never by the control plane."
      },
      "controlBearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Control-plane token as a bearer token: `Authorization: Bearer dg_live_...`. A **different credential class** from the data-plane `sk_live_`/`pk_live_` keys: `dg_live_` tokens are organization-scoped and manage apps and keys, while `sk_live_` keys are app-scoped and move data. Neither is accepted on the other plane.\n\nMint tokens from the DataGauge dashboard, choosing an explicit scope set (`apps:read`, `apps:write`, `keys:read`, `keys:write`, `webhooks:write`). Scopes never imply one another. Each operation lists what it needs under `x-required-scopes`.\n\nThe `dg_test_` prefix is reserved for a future test environment; it parses today but no such token can be minted yet.\n\nThe token is `dg_live_` followed by a 16-character public lookup id and a 40-character secret, with no separators. Only a hash of the secret is stored, so a lost token cannot be recovered — mint a new one and revoke the old.\n\nA missing, malformed, unknown or revoked token is a `401` with a `WWW-Authenticate: Bearer realm=\"datagauge\"` header. Error bodies never echo any part of the presented credential."
      }
    },
    "schemas": {
      "App": {
        "type": "object",
        "required": [
          "app_id",
          "public_id",
          "name",
          "ownership",
          "type",
          "first_event_at",
          "has_stripe_webhook",
          "has_polar_webhook",
          "created_at"
        ],
        "description": "An app as the control plane returns it. Webhook signing secrets are stripped on every read path and surface only as `has_*` booleans.",
        "properties": {
          "app_id": {
            "type": "string",
            "description": "Opaque app id. Use it in every other control-plane path.",
            "example": "k17b2c3d4e5f6g7h8j9k0m1n2p"
          },
          "public_id": {
            "type": "string",
            "description": "The project id the data plane reports as `project` on metric reads.",
            "example": "proj_abc123"
          },
          "name": {
            "type": "string",
            "description": "Display name. Whitespace-normalized server-side, 1–80 chars.",
            "example": "My App"
          },
          "ownership": {
            "type": "string",
            "enum": [
              "mine",
              "client_operated"
            ],
            "description": "Whether you run this app or a client does."
          },
          "type": {
            "type": "string",
            "enum": [
              "saas",
              "library",
              "desktop",
              "other"
            ]
          },
          "first_event_at": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Epoch milliseconds of the first event ever received, or `null` if the app has never ingested."
          },
          "has_stripe_webhook": {
            "type": "boolean",
            "description": "Whether a Stripe signing secret is set. The secret itself is never readable — see `PUT /v1/apps/{app_id}/webhooks/{provider}`."
          },
          "has_polar_webhook": {
            "type": "boolean",
            "description": "Whether a Polar signing secret is set. Never readable."
          },
          "created_at": {
            "type": "number",
            "description": "Epoch milliseconds."
          }
        }
      },
      "CreatedApp": {
        "type": "object",
        "required": [
          "app_id",
          "public_id",
          "name",
          "ownership",
          "type",
          "first_event_at",
          "has_stripe_webhook",
          "has_polar_webhook",
          "created_at",
          "sk_key"
        ],
        "description": "The `POST /v1/apps` response: a normal `App`, plus the one-time server key minted alongside it.",
        "properties": {
          "app_id": {
            "type": "string",
            "description": "Opaque app id. Use it in every other control-plane path.",
            "example": "k17b2c3d4e5f6g7h8j9k0m1n2p"
          },
          "public_id": {
            "type": "string",
            "description": "The project id the data plane reports as `project` on metric reads.",
            "example": "proj_abc123"
          },
          "name": {
            "type": "string",
            "description": "Display name. Whitespace-normalized server-side, 1–80 chars.",
            "example": "My App"
          },
          "ownership": {
            "type": "string",
            "enum": [
              "mine",
              "client_operated"
            ],
            "description": "Whether you run this app or a client does."
          },
          "type": {
            "type": "string",
            "enum": [
              "saas",
              "library",
              "desktop",
              "other"
            ]
          },
          "first_event_at": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Epoch milliseconds of the first event ever received, or `null` if the app has never ingested."
          },
          "has_stripe_webhook": {
            "type": "boolean",
            "description": "Whether a Stripe signing secret is set. The secret itself is never readable — see `PUT /v1/apps/{app_id}/webhooks/{provider}`."
          },
          "has_polar_webhook": {
            "type": "boolean",
            "description": "Whether a Polar signing secret is set. Never readable."
          },
          "created_at": {
            "type": "number",
            "description": "Epoch milliseconds."
          },
          "sk_key": {
            "type": "string",
            "description": "Full server key material for the app's first key. **Returned exactly once and unrecoverable afterwards** — persist it now. Already live on the ingest and query planes.",
            "example": "sk_live_a2K9xQ7mVb3nR8tYc5wZ4dF6gH1jL0pS"
          }
        }
      },
      "AppKey": {
        "type": "object",
        "required": [
          "key_id",
          "kind",
          "masked",
          "revoked",
          "mirrored_at",
          "created_at"
        ],
        "description": "A data-plane key as read paths return it. There is no `key` field: full material is only ever in the response that minted the key.",
        "properties": {
          "key_id": {
            "type": "string",
            "description": "Opaque key id — what `DELETE .../keys/{key_id}` takes."
          },
          "kind": {
            "type": "string",
            "enum": [
              "sk",
              "pk"
            ],
            "description": "`sk` = server key, `pk` = publishable key."
          },
          "masked": {
            "type": "string",
            "description": "Display form: prefix + first 4 … last 4. Enough to recognize a key, useless to use.",
            "example": "sk_live_a2K9…L0pS"
          },
          "revoked": {
            "type": "boolean",
            "description": "Revoked keys stay listed. Revocation is permanent — there is no un-revoke."
          },
          "mirrored_at": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Epoch milliseconds the key was last published to the edge, or `null` if that has not completed yet. Ingest and query authenticate against the published copy, so a `null` here means the key may not be live for another moment."
          },
          "created_at": {
            "type": "number",
            "description": "Epoch milliseconds."
          }
        }
      },
      "MintedKey": {
        "type": "object",
        "required": [
          "key_id",
          "key"
        ],
        "description": "The `POST /v1/apps/{app_id}/keys` response.",
        "properties": {
          "key_id": {
            "type": "string",
            "description": "Opaque key id — pass it to `DELETE` to revoke."
          },
          "key": {
            "type": "string",
            "description": "Full data-plane key material. **Returned exactly once and unrecoverable afterwards** — persist it now. Already live on the ingest and query planes.",
            "example": "sk_live_a2K9xQ7mVb3nR8tYc5wZ4dF6gH1jL0pS"
          }
        }
      },
      "ControlError": {
        "type": "object",
        "required": [
          "error"
        ],
        "description": "The control plane's error envelope. Note it differs from the data plane's `{ ok: false, error: \"…\" }`: here `error` is an object with a stable machine-readable `code`.",
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "invalid_body",
                  "unauthorized",
                  "insufficient_scope",
                  "not_found"
                ],
                "description": "Stable machine-readable code. Branch on this."
              },
              "message": {
                "type": "string",
                "description": "Human-readable reason. Never contains any part of the presented credential, nor a webhook secret."
              }
            }
          }
        }
      },
      "Event": {
        "type": "object",
        "required": [
          "name"
        ],
        "additionalProperties": true,
        "description": "A single event. `name` is the only required field. Unknown top-level fields are ignored for forward compatibility.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 128,
            "description": "Event name. Reserved: `user_signup` (signups + MAU), `payment` (revenue).",
            "example": "user_signup"
          },
          "user": {
            "type": "string",
            "maxLength": 256,
            "description": "Opaque user identifier — never format-validated. Empty string ≡ absent.",
            "example": "usr_8813"
          },
          "id": {
            "type": "string",
            "maxLength": 256,
            "description": "Idempotency key (7-day dedup window). Required if you retry; required for `payment` events.",
            "example": "evt_01J9XW"
          },
          "at": {
            "oneOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "integer",
                "description": "Epoch milliseconds."
              }
            ],
            "description": "When it happened (client clock). RFC 3339 or epoch ms."
          },
          "sent_at": {
            "oneOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "integer",
                "description": "Epoch milliseconds."
              }
            ],
            "description": "When the client flushed the batch — lets the server cancel clock skew."
          },
          "props": {
            "type": "object",
            "description": "Flat, one-level-deep object (string / number / boolean / null values). Max 100 keys. For `payment`: `amount` (integer minor units) and `currency` (ISO-4217 lowercase) are required.",
            "additionalProperties": {
              "oneOf": [
                {
                  "type": "string",
                  "maxLength": 1024
                },
                {
                  "type": "number"
                },
                {
                  "type": "boolean"
                },
                {
                  "type": "null"
                }
              ]
            },
            "example": {
              "amount": 4900,
              "currency": "usd"
            }
          }
        }
      },
      "SubscriptionObject": {
        "type": "object",
        "required": [
          "type",
          "id",
          "status",
          "mrr",
          "currency"
        ],
        "additionalProperties": true,
        "description": "Mutable subscription state, upserted last-write-wins by `at`.",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "subscription"
            ],
            "description": "v1 supports `subscription` only."
          },
          "id": {
            "type": "string",
            "description": "The subscription's stable id in the biller. Upsert key.",
            "example": "sub_9"
          },
          "status": {
            "type": "string",
            "description": "Free string; only `active` counts toward MRR. Suggested: active, trialing, past_due, canceled.",
            "example": "active"
          },
          "mrr": {
            "type": "integer",
            "minimum": 0,
            "description": "Monthly-normalized recurring revenue in minor units (cents). Annual plan → amount / 12.",
            "example": 4900
          },
          "currency": {
            "type": "string",
            "description": "ISO-4217, lowercased on ingest.",
            "example": "usd"
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "description": "Version timestamp for last-write-wins. Falls back to receive time."
          }
        }
      },
      "IngestResult": {
        "type": "object",
        "required": [
          "ok",
          "accepted",
          "rejected"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "accepted": {
            "type": "integer",
            "description": "Items durably queued (idempotent duplicates count as accepted)."
          },
          "rejected": {
            "type": "integer",
            "description": "Per-item rejections."
          },
          "errors": {
            "type": "array",
            "description": "Present only when `rejected > 0`.",
            "items": {
              "$ref": "#/components/schemas/ItemError"
            }
          }
        }
      },
      "ItemError": {
        "type": "object",
        "required": [
          "index",
          "reason"
        ],
        "properties": {
          "index": {
            "type": "integer",
            "description": "Zero-based index within the batch."
          },
          "reason": {
            "type": "string",
            "example": "payment requires sk key"
          }
        }
      },
      "MetricResult": {
        "type": "object",
        "required": [
          "ok",
          "metric",
          "project",
          "date_from",
          "date_to",
          "data"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "metric": {
            "type": "string",
            "example": "mrr"
          },
          "project": {
            "type": "string",
            "example": "prj_abc"
          },
          "date_from": {
            "type": "string",
            "format": "date"
          },
          "date_to": {
            "type": "string",
            "format": "date"
          },
          "data": {
            "type": "array",
            "description": "Rows whose shape depends on the metric (e.g. `[{currency, mrr}]`, `[{day, dau}]`, `[{day, currency, revenue}]`).",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "ok"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": false
          },
          "error": {
            "type": "string",
            "description": "Human-readable error reason."
          },
          "errors": {
            "type": "array",
            "description": "Per-item errors when an entire batch is invalid.",
            "items": {
              "$ref": "#/components/schemas/ItemError"
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Malformed JSON, or the body is not an object or array.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "malformed JSON"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or unknown/revoked key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "unauthorized"
            }
          }
        }
      },
      "PayloadTooLarge": {
        "description": "More than 500 items or larger than 1 MB.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "body exceeds 1MB"
            }
          }
        }
      },
      "UnsupportedMediaType": {
        "description": "Content-Type is not `application/json`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "error": "content-type must be application/json"
            }
          }
        }
      },
      "Unprocessable": {
        "description": "Every item in the batch was invalid.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "ok": false,
              "errors": [
                {
                  "index": 0,
                  "reason": "conflicting payload for existing id"
                }
              ]
            }
          }
        }
      },
      "ControlBadRequest": {
        "description": "The body is not a JSON object, or a field is missing, of the wrong type, or not one of the allowed values. Nothing is applied — a rejected patch never lands partially.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ControlError"
            },
            "example": {
              "error": {
                "code": "invalid_body",
                "message": "'name' is required"
              }
            }
          }
        }
      },
      "ControlUnauthorized": {
        "description": "Missing, malformed, unknown or revoked `dg_live_` token. Carries `WWW-Authenticate: Bearer realm=\"datagauge\"`. All four cases are the same response, so this is not an oracle for whether a token ever existed.",
        "headers": {
          "WWW-Authenticate": {
            "description": "Always `Bearer realm=\"datagauge\"`.",
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ControlError"
            },
            "example": {
              "error": {
                "code": "unauthorized",
                "message": "Missing or invalid control API credential"
              }
            }
          }
        }
      },
      "ControlForbidden": {
        "description": "The token is valid but was not minted with the scope this operation requires (see `x-required-scopes`). Scopes never imply one another — `apps:write` does not grant `keys:write`, and `apps:read` does not grant `keys:read`. Scopes are fixed at mint time, so the fix is a new token, not a retry.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ControlError"
            },
            "example": {
              "error": {
                "code": "insufficient_scope",
                "message": "Requires the 'keys:write' scope"
              }
            }
          }
        }
      },
      "ControlUpstreamUnavailable": {
        "description": "The control plane could not be reached. `api.datagauge.dev` forwards control requests to the backend that serves them; this status means that hop failed or timed out, not that your request was wrong. It is safe to retry with backoff. `code` is `upstream_unavailable` — a proxy-level code the backend itself never emits, so it always denotes this hop rather than a control operation's own error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ControlError"
            },
            "example": {
              "error": {
                "code": "upstream_unavailable",
                "message": "Control plane is temporarily unreachable"
              }
            }
          }
        }
      },
      "ControlNotFound": {
        "description": "The resource is not visible to this token.\n\n**This is deliberately a `404` and not a `403`.** An app that exists but belongs to another organization, an app id that has never existed, and an id that is not well-formed at all return byte-identical responses — as does a key id that belongs to a different app, even one in your own organization. Returning `403` for the cross-organization case would confirm that the id is real, turning the API into an existence oracle. It will look like a bug when you mistype an id; it is not.\n\nThe same `404` also covers paths this API does not serve at all: an unrecognised webhook provider, `GET` on a webhook secret (deliberately write-only), and any deeper or trailing-slash path under `/v1/apps/`.\n\nIf you are certain the id is right, check that the token belongs to the organization that owns the app.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ControlError"
            },
            "example": {
              "error": {
                "code": "not_found",
                "message": "App not found"
              }
            }
          }
        }
      }
    }
  }
}