# Projects and keys

Source: https://dabadata.com/docs/projects/ (HTML) - https://dabadata.com/docs/projects/index.md (this page as markdown)

A hosted dabadata project is one project id and two environments, live and test. Each environment has its own pair of keys, so you get a project id and four keys. This page is what each of them does, how a call finds its project, and how you manage the project with its secret key or from the [console](https://dabadata.com/docs/console/index.md).

## What you get

When your project is created - from the console, or the first one, `my-first-app`, made when you sign up - it has:

| What | Looks like | Use |
| --- | --- | --- |
| Project id | `prj_example12345` | Names the project. Public; it is not a credential. |
| Live publishable key | `pk_live_EXAMPLEexample0000000000` | Your app in production: browsers, mobile apps, script tags. |
| Live secret key | `sk_live_EXAMPLEexample00000000000000000000000000` | Your production backend only. |
| Test publishable key | `pk_test_EXAMPLEexample0000000000` | Your app in development, staging and CI. |
| Test secret key | `sk_test_EXAMPLEexample00000000000000000000000000` | Your development backend, scripts and CI. |

The keys on this page are fake. A secret key is shown once, when it is made, and never again (only its hash is kept), so store it in your secret manager straight away. A project you create in the console shows its secret keys in that moment; the secret keys of `my-first-app` were made at sign-up without being shown. Either way, a lost secret key is not a dead end: a developer of the workspace [rotates](https://dabadata.com/docs/projects/index.md#rotation) it on the console's **Keys** page and copies the new one. A publishable key is not secret and can be read again at any time, on **Keys** or with [`GET /v1/project`](https://dabadata.com/docs/projects/index.md#project-info).

The examples use the hosted service, `https://dabadata.com`. On a server of your own, use its address ([Self-hosting](https://dabadata.com/docs/self-hosting/index.md)).

## Live and test

The two environments of a project share nothing but the project id and the workspace. Each one has its own:

- **Tables and data**, in a Postgres schema of its own, and its own [definition](https://dabadata.com/docs/tables/index.md#hosted) with its own version history.
- **Trace**: a call to test never shows up in live's trace.
- **Tokens**: user, refresh and view tokens are minted for one environment. A test token sent with the live publishable key is a 401.
- **Keys**, [allowed origins](https://dabadata.com/docs/projects/index.md#cors) and [secrets](https://dabadata.com/docs/projects/index.md#secrets).
- **Live subscriptions**: a change reaches only the sockets of the environment it was made in.

Both environments count against the same plan: connections are counted across the whole workspace, live and test together, and so is storage. See [Plans and limits](https://dabadata.com/docs/limits/index.md).

Test is where you try a new definition, run your end-to-end tests and let developers sign in as anyone. Apply the same definition to live when it is ready; nothing is copied between the two by itself.

## Publishable and secret keys

|   | Publishable key | Secret key |
| --- | --- | --- |
| Format | `pk_live_` or `pk_test_` + 24 letters and digits | `sk_live_` or `sk_test_` + 40 letters and digits |
| Sent as | `X-Api-Key: pk_...` or `?key=pk_...` | `Authorization: Bearer sk_...` |
| Grants | Nothing by itself: it says which environment the call is for. The caller is `anonymous` unless the call also carries a user token or a provider JWT, and the [rules](https://dabadata.com/docs/rules/index.md) decide. | Everything: it is the `service` identity. It passes every rule (traced as `bypass`), mints user and view tokens, applies definitions, reads the trace and manages the project. |
| Goes in | Front-end code, mobile apps, script tags, public repositories. | Your backend's environment or secret manager. Nowhere else. |
| Can be read again | Yes, `GET /v1/project` | No, shown once |

The secret key plays the part the project key plays on a [self-hosted](https://dabadata.com/docs/self-hosting/index.md) server: wherever these docs say "project key", a hosted project uses its secret key.

> **Security:** **A secret key bypasses every rule and belongs on a server, never in a browser.** Anyone who has it can read and change every row of that environment. If one leaks, [rotate it](https://dabadata.com/docs/projects/index.md#rotation) at once. Server code that only needs to read and write through your rules - a payment webhook, a cron job - should use a [restricted key](https://dabadata.com/docs/projects/index.md#restricted) instead. The publishable key is safe to ship: what a browser may do is decided by its user's token and your rules, not by the key.

## Sending a key

A browser sends the publishable key in the `X-Api-Key` header, and the signed-in user's token, as always, in `Authorization: Bearer`:

```js
const res = await fetch("https://dabadata.com/v1/db/tasks", {
  headers: {
    "X-Api-Key": "pk_live_EXAMPLEexample0000000000",
    Authorization: `Bearer ${userToken}`, // leave out for an anonymous call
  },
});
const { rows } = await res.json();
```

Where a header cannot be set - a WebSocket, a link - pass it as `?key=`, next to `?token=`:

```
wss://https://dabadata.com/v1/live?key=pk_live_EXAMPLEexample0000000000&token=<user token>
```

Your backend uses the secret key exactly where the project key used to go, for example to mint a user token after its own sign-in:

```bash
curl -X POST https://dabadata.com/v1/users/token \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"id":"bob","name":"Bob","role":"editor"}'
```

Two mistakes answer 401 rather than being guessed at: anything in `X-Api-Key` or `?key=` that is not a valid publishable key (a secret key put there by accident included), and a publishable key sent as `Authorization: Bearer`, which is not a credential.

## How a call finds its project

One server runs many projects, so every call is placed in one environment before anything else happens. The server only ever takes the environment from a key or a token it issued itself - never from the URL path or the request body, which anyone can edit.

| The call carries | It goes to | The caller is |
| --- | --- | --- |
| `Authorization: Bearer sk_...` | That secret key's environment | `service` (rules bypassed) |
| `Authorization: Bearer rk_...` | That restricted key's environment | The key's role, as `key:<name>` (rules apply) |
| A publishable key alone | The key's environment | `anonymous` |
| A publishable key and a token (user token, view token or provider JWT) | The key's environment; the token must belong to it | The token's user |
| A user or view token, no key | The environment the token was minted in | The token's user |
| A provider JWT, no key | The server's default project, not yours | - |
| Nothing at all | The server's default project, not yours | - |

In practice: a token dabadata minted (a user token, a refresh token, a view token) knows its environment, so it works with or without the key. An **anonymous** call and a call with a **provider JWT** (Google, Auth0, Clerk, your own) carry nothing that names your project, so they need the publishable key. The simplest rule is to send the publishable key on every call from your front end.

- A token of one environment sent with the key of another is a 401, as is an unknown, expired or rotated-out key.
- `POST /v1/users/refresh`: the refresh token names its environment. A publishable key sent with it must be of the same one.
- `POST /v1/users/exchange`: the JWT is checked against the issuers of the publishable key's environment, so send the key with it.

## Rotating keys

To replace a key, call the rotate route with the environment's secret key and say which kind:

```bash
curl -X POST https://dabadata.com/v1/project/keys/rotate \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind":"secret"}'
```

```json
{
  "key": "sk_live_EXAMPLEnewkey000000000000000000000000000",
  "previous": { "prefix": "sk_live_EXAM", "expiresAt": "2026-10-16T09:30:00.000Z" }
}
```

The new key works at once. The previous one keeps working for **24 hours**, so you can roll the new key out to every server (or, for a publishable key, ship a new front end) without downtime; until it expires both are listed in `GET /v1/project`. Rotating again within those 24 hours does not extend the old key: each rotated key keeps the expiry it got when it was replaced. The new secret key, like the first one, is shown only in this answer.

The 24 hours are for a planned change. For a leaked key, [revoke it](https://dabadata.com/docs/projects/index.md#revoke) instead: it stops at once. In the console, **Keys** has **Rotate publishable key**, **Rotate secret key** and a **Revoke** button for each key (developers and up), each asking first; rotating there is also how you get a new secret key when you have lost the old one.

## Revoking a key at once

When a key has leaked, do not wait out the 24 hours: revoke it. It stops working immediately, whether it is the current key or one still inside its rotation grace. Name it by the 12-character `prefix` that [`GET /v1/project`](https://dabadata.com/docs/projects/index.md#project-info) lists:

```bash
curl -X POST https://dabadata.com/v1/project/keys/revoke \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prefix":"sk_live_EXAM"}'
```

```json
{ "ok": true, "key": "sk_live_EXAMPLEnewkey000000000000000000000000000" }
```

- An environment always keeps one current key of each kind. Revoking the current one first makes its successor and answers it, once, as `key`; revoking a rotated key that is still in its grace makes nothing and answers `{ "ok": true }`.
- Revoking the secret key you are calling with is allowed: the call still succeeds, and from then on only the new key in the answer works. Store it before anything else.
- A key that is already revoked or expired, or belongs to another environment, is a 404. Every revocation is in the workspace's [audit log](https://dabadata.com/docs/accounts/index.md#audit).
- Revoke any user tokens a leaked secret key may have minted, too ([Revoking](https://dabadata.com/docs/identity/index.md#revoke)).
- A [restricted key](https://dabadata.com/docs/projects/index.md#restricted) is revoked the same way. No replacement is made: `{ "ok": true }`.

## Restricted keys

A restricted key is a key for one backend job, such as the handler of a payment webhook or a nightly import. Unlike the secret key it bypasses nothing: it acts as one of the roles your definition declares, so your [rules](https://dabadata.com/docs/rules/index.md), column rules and the `allow` rule of each [action](https://dabadata.com/docs/api/index.md#actions) decide what it may do. The trace shows its name on every call it makes.

|   | Restricted key |
| --- | --- |
| Format | `rk_live_` or `rk_test_` + 40 letters and digits |
| Sent as | `Authorization: Bearer rk_...` (with or without the environment's publishable key) |
| Acts as | The user `{ "id": "key:<name>", "role": "<role>" }`: a role rule for that role passes, `authenticated` passes, `owner` compares with `key:<name>`, and `userId` columns left empty on insert get `key:<name>`. |
| Cannot | Manage the project, apply definitions, mint user or view tokens, read the logs, run SQL, call the MCP endpoint, or open a live socket (401 `restricted keys do not open sockets; use a user token`). |
| Can be read again | No, shown once |

Make one with the secret key, or on the console's **Keys** page (**New restricted key**, developers and up). The name is lowercase letters, digits and `-`, unique in the environment; the role must be one your definition declares. An environment holds at most 20.

```bash
curl -X POST https://dabadata.com/v1/project/keys/restricted \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"stripe-webhook","role":"payments"}'
```

```json
{
  "key": "rk_live_EXAMPLEexample00000000000000000000000000",
  "name": "stripe-webhook",
  "role": "payments",
  "prefix": "rk_live_EXAM",
  "createdAt": "2026-10-19T09:30:00.000Z"
}
```

Give the role exactly what the job needs. The cleanest way is an action the role may run, so the job cannot touch anything else:

```ts
roles: ["admin", "member", "payments"],
actions: {
  mark_paid: {
    allow: { role: "payments" },
    params: { order: { type: "ref", table: "orders" } },
    steps: [{ op: "update", table: "orders", id: "$params.order", values: { status: "paid" } }],
  },
},
```

```bash
curl -X POST https://dabadata.com/v1/actions/mark_paid \
  -H "Authorization: Bearer $DABADATA_STRIPE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"params":{"order":"ord_123"}}'
```

- **Rotate** one by name: `POST /v1/project/keys/rotate` with `{ "kind": "restricted", "name": "stripe-webhook" }`. The new key has the same name and role; the old one keeps working for 24 hours.
- **Revoke** one by its prefix, [as any key](https://dabadata.com/docs/projects/index.md#revoke); it stops at once.
- The role of a key cannot be changed: make a new key with the new role, move the job to it, then revoke the old one.
- [`GET /v1/project`](https://dabadata.com/docs/projects/index.md#project-info) lists them among the environment's keys as `{ "kind": "restricted", "prefix", "name", "role", "createdAt", "expiresAt", "lastUsedAt" }`. `lastUsedAt` is updated at most once a minute.
- In the trace a call by the key shows the user `{ "id": "key:stripe-webhook", "role": "payments", "name": "stripe-webhook", "key": "rk_live_EXAM" }`; in the API request log the credential is `restricted`.

## Allowed origins

By default an environment answers browsers on any origin (`Access-Control-Allow-Origin: *`). To restrict it to your own sites, set the list of origins with the secret key; each environment has its own list, so test can allow `http://localhost:5173` while live allows only your domain:

```bash
curl -X PATCH https://dabadata.com/v1/project \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"corsOrigins":["https://shop.example.com","https://www.shop.example.com"]}'
```

- In the console, **Authentication - URL configuration - App URLs** sets an address as an allowed origin and as a sign-in [redirect URL](https://dabadata.com/docs/end-user-auth/index.md#redirects) together, so the two lists cannot drift apart (`http://localhost:5173` and `http://127.0.0.1:5173` are different origins: add the one you open, or both).
- An origin is exactly `scheme://host[:port]`, as a browser sends it: `http` or `https`, lower-case host, no default port, no path and no trailing slash. Anything else is a 400 that suggests the right form. At most 100; duplicates are dropped. An empty list (`[]`) allows every origin again.
- A request from a listed origin gets that origin back in `Access-Control-Allow-Origin` (with `Vary: Origin`). A request from any other origin gets no `Access-Control-Allow-Origin`, so the browser does not let the page read the answer, and a WebSocket from it is refused with 403 `{ "error": "origin_not_allowed" }`.
- Requests without an `Origin` header - your servers, curl, mobile apps - are never blocked.

Allowed origins stop other websites from using your publishable key in their visitors' browsers. They are not access control: anything outside a browser can send any key. Your rules are what protect the data.

## Reading your project

`GET /v1/project` with an environment's secret key answers with the project, which environment the key belongs to, the workspace and its plan limits:

```bash
curl https://dabadata.com/v1/project -H "Authorization: Bearer $DABADATA_SECRET_KEY"
```

```json
{
  "project": {
    "id": "prj_example12345",
    "title": "Shop",
    "workspaceId": "ws_example12345",
    "region": "il-central-1",
    "status": "active",
    "createdAt": "2026-10-15T09:00:00.000Z",
    "envs": {
      "live": {
        "env": "live",
        "status": "active",
        "corsOrigins": ["https://shop.example.com"],
        "publishableKey": "pk_live_EXAMPLEexample0000000000",
        "keys": [
          { "kind": "publishable", "prefix": "pk_live_EXAM", "createdAt": "2026-10-15T09:00:00.000Z", "expiresAt": null },
          { "kind": "secret", "prefix": "sk_live_EXAM", "createdAt": "2026-10-15T09:00:00.000Z", "expiresAt": null }
        ]
      },
      "test": { "env": "test", "status": "active", "corsOrigins": [], "publishableKey": "pk_test_EXAMPLEexample0000000000", "keys": [ ... ] }
    }
  },
  "env": "live",
  "workspace": { "id": "ws_example12345", "name": "Example shop", "plan": "free" },
  "limits": { "plan": "free", "projects": 2, "members": 1, "connections": 50, "dbBytes": 524288000,
              "egressBytes": 2147483648, "traceDays": 3, "logDays": 1, "idlePauseDays": 7, "requestsPerSecond": 20 }
}
```

`keys` lists the keys that work right now, oldest first: one of each kind, plus any rotated key still inside its 24 hours (with its `expiresAt`). `PATCH /v1/project` takes `title` (renames both environments) and `corsOrigins` (this environment only) and answers with the same shape. The limits are explained on [Plans and limits](https://dabadata.com/docs/limits/index.md).

## Paused and deleted projects

An environment is `active`, `paused` or `deleting`; `project.status` sums up both.

- **Paused.** An environment on the Free plan that has had no calls for 7 days is paused by the [nightly job](https://dabadata.com/docs/limits/index.md#nightly). Its open sockets are closed with close code `4001` and reason `project paused`. Nothing is refused: the next call with one of its keys or tokens wakes it up and is answered as usual, and a client that reconnects wakes it too.
- **Deleting.** A deleted project answers every call, in both environments, with `410 { "error": "project_deleted" }` and its sockets are closed with `4001` `project deleted`. For 7 days it can still be restored with all its data; after that both schemas and every row of both environments are removed for good. A wrong key is still a 401, so the 410 tells you only that a real credential reached a deleted project.

## Exporting your data

`GET /v1/project/export` streams every row of every declared table of one environment as NDJSON - one JSON object per line - with the secret key of that environment:

```bash
curl https://dabadata.com/v1/project/export \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -o shop-live.ndjson
```

```
{"type":"project","project":{"id":"prj_example12345","title":"Shop",...},"env":"live","definition":{"name":"prj_example12345","roles":[...],"tables":{...}}}
{"type":"row","table":"tasks","row":{"id":"5f1c...","title":"Order chairs","done":false,"owner":"bob",...}}
{"type":"row","table":"tasks","row":{"id":"7a02...","title":"Book the hall","done":true,"owner":"alice",...}}
{"type":"end","rows":2}
```

- The first line is the project, the environment and the definition in force; then one `row` line per row, table by table; then `end` with the number of rows.
- It is a stream with no size cap and no `Content-Length`. A file that does not finish with the `end` line is incomplete: the export failed midway, so run it again.
- Rows are read in batches without holding a transaction, so rows written while the export runs may or may not be in it. For an exact snapshot, export while nothing writes.
- Each environment exports its own data: use the test secret key for test.

## Project secrets

A hosted project cannot read the server's environment variables, so values a definition needs but must not contain are stored as project secrets: the HS256 key of an [identity provider that signs with a shared secret](https://dabadata.com/docs/identity/index.md#hosted-hs256), the client secret of a [sign-in provider](https://dabadata.com/docs/sign-in-providers/index.md), and the API key of an AI provider (Anthropic, OpenAI, Gemini) that an [action calls](https://dabadata.com/docs/ai/index.md). A secret is set and named with the secret key (or in the console: **Secrets**), encrypted at rest (AES-256-GCM) and never returned:

```bash
# store or replace
curl -X PUT https://dabadata.com/v1/project/secrets/LEGACY_JWT_SECRET \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":"<at least 32 random characters>"}'
# list (names and dates only)
curl https://dabadata.com/v1/project/secrets -H "Authorization: Bearer $DABADATA_SECRET_KEY"
# delete
curl -X DELETE https://dabadata.com/v1/project/secrets/LEGACY_JWT_SECRET \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY"
```

- Names match `^[A-Z][A-Z0-9_]{0,63}$` (upper case, like environment variables). A value is a string of 1 to 4,096 characters.
- Live and test have separate secrets: store the key in each environment that needs it.
- Storing a name that exists replaces the value. Deleting a secret an issuer still uses is allowed, and that issuer then refuses every token until the secret is back; an action's model call that uses it fails with `secret X is not set`.
- If the server was started without its encryption key, the three routes answer `503 { "error": "secrets_unavailable" }`.

**Where a value goes.** The definition holds secret names only. A value is decrypted only to verify a token, to exchange a sign-in code, or for an [AI call in an action](https://dabadata.com/docs/ai/index.md), where it goes only into the auth header of the provider's fixed https host (`api.anthropic.com`, `api.openai.com`; redirects are refused). No response, trace row, log line, export, schema version or error text holds a value, and an error text from a provider is cleaned of the value before it is stored or returned. Each value is encrypted with its own IV and bound to its environment and name, so a ciphertext copied to another row does not decrypt.

Every route of the project API, with its errors, is in the [HTTP API reference](https://dabadata.com/docs/api/index.md#project).
