---
title: "Which API endpoints are RevenueDot extensions?"
description: "RevenueDot-only endpoints: dashboard sign-in, OAuth for MCP clients, project settings, store setup, API keys, webhook deliveries, event log, Test Store, dashboard data and migration import."
url: https://revenuedot.app/docs/api/extensions
---

# Which API endpoints are RevenueDot extensions?

These endpoints exist only in RevenueDot. They use the same auth, errors and list envelope as [REST API v2](https://revenuedot.app/docs/api/rest-v2.md). The dashboard is built on them, so everything the dashboard does, a script or an AI agent can do too.

Base URL: your server, for example `http://localhost:8787` or `https://revenuedot.example.com`. The examples read `REVENUEDOT_URL`, `PUBLIC_KEY`, `SECRET_KEY` and `PROJECT_ID` from your shell.

## Operations on this page (47)

- **Dashboard auth**: [Whether sign-up is open](#whether-sign-up-is-open), [Create a dashboard account](#create-a-dashboard-account), [Sign in](#sign-in), [Sign out](#sign-out), [The signed-in user and their projects](#the-signed-in-user-and-their-projects), [Update account settings](#update-account-settings), [Email a password reset link](#email-a-password-reset-link), [Check a password reset link](#check-a-password-reset-link), [Set a new password from a reset link](#set-a-new-password-from-a-reset-link), [Confirm an email address](#confirm-an-email-address), [Send a new confirmation email](#send-a-new-confirmation-email), [Look up an invite](#look-up-an-invite), [Accept an invite](#accept-an-invite)
- **Members and invites**: [List open invites](#list-open-invites), [Invite someone by email](#invite-someone-by-email), [Resend an invite](#resend-an-invite), [Revoke an invite](#revoke-an-invite), [Change a member's role](#change-a-members-role), [Remove a member, or leave the project](#remove-a-member-or-leave-the-project)
- **Project settings**: [Get a project with its settings](#get-a-project-with-its-settings), [Update a project's name and transfer behaviour](#update-a-projects-name-and-transfer-behaviour), [Delete a project and everything in it](#delete-a-project-and-everything-in-it)
- **Store setup**: [Store setup state of an app](#store-setup-state-of-an-app), [Check store credentials with Apple or Google](#check-store-credentials-with-apple-or-google), [Extend every active App Store subscriber of a product](#extend-every-active-app-store-subscriber-of-a-product), [Status of a mass extension](#status-of-a-mass-extension), [Setup health](#setup-health)
- **API keys**: [List secret keys](#list-secret-keys), [Create a secret key](#create-a-secret-key), [Delete a secret key](#delete-a-secret-key)
- **Webhook deliveries**: [Send a TEST event to one webhook](#send-a-test-event-to-one-webhook), [Whether each webhook is enabled](#whether-each-webhook-is-enabled), [Delivery log of a webhook](#delivery-log-of-a-webhook), [Retry a delivery now](#retry-a-delivery-now)
- **Event log**: [Event log](#event-log), [Transaction feed](#transaction-feed)
- **Test Store**: [Simulate a Test Store purchase or lifecycle](#simulate-a-test-store-purchase-or-lifecycle)
- **Dashboard data**: [Daily history of an overview metric](#daily-history-of-an-overview-metric), [Dashboard rows for customers](#dashboard-rows-for-customers)
- **Migration import**: [Import customers with their purchases](#import-customers-with-their-purchases), [Keep an app's existing SDK key](#keep-an-apps-existing-sdk-key), [What still needs attention after an import](#what-still-needs-attention-after-an-import)
- **OAuth for MCP clients**: [OAuth authorization server metadata](#oauth-authorization-server-metadata), [Register an OAuth client](#register-an-oauth-client), [Consent screen](#consent-screen), [Submit the consent decision](#submit-the-consent-decision), [Exchange a code for an access token](#exchange-a-code-for-an-access-token)

## Dashboard auth

Sign-up, sign-in, password reset, email confirmation, invites and account settings for the dashboard. The session cookie also authorizes REST API v2.

### Whether sign-up is open

`GET /auth/config` · Auth: none · RevenueDot extension

What the sign-in page needs before it shows a form.

**Example request**

```bash
curl -s "$REVENUEDOT_URL/auth/config"
```

**Responses**

- **200**: The config.

Example 200 response:

```json
{
  "edition": "self-hosted",
  "signup": "closed"
}
```

### Create a dashboard account

`POST /auth/signup` · Auth: none · RevenueDot extension

Creates the user and a first project, and sets the `rd_session` cookie (30 days; `Secure` over https). On a self-hosted server only the first account (the owner) can sign up, unless the server runs with `REVENUEDOT_ALLOW_SIGNUP=true`.

With `invite_token` (from an invite link), the account joins the inviting project instead of getting a new one, and sign-up works even where it is closed. The email must be the invited address; the account counts as verified. On RevenueDot Cloud, an account without an invite gets an email with a confirmation link (valid 24 hours).

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | yes |  |
| `password` | string | yes |  |
| `name` | string | no |  |
| `project_name` | string | no | Default: My project. Ignored with `invite_token`. |
| `invite_token` | string | no | RevenueDot extension. The token from an invite link (`/invite?token=...`). |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/auth/signup" \
  -H "Content-Type: application/json" -d '{"email":"dev@example.com","password":"change-me-please","project_name":"My app"}'
```

**Responses**

- **201**: Signed up and signed in. With an invite, `project_id` is the project joined.
- **400**: Invalid email or password, or an invite that is not valid (`invite_invalid`) or for another address (`invite_email_mismatch`).
- **403**: Sign-up is closed: the server has an owner already.
- **409**: The email is taken.

Example 201 response:

```json
{
  "ok": true
}
```

### Sign in

`POST /auth/login` · Auth: none · RevenueDot extension

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | yes |  |
| `password` | string | yes |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/auth/login"
```

**Responses**

- **200**: Signed in; `rd_session` is set.
- **400**: Missing fields.
- **401**: Wrong email or password.

Example 200 response:

```json
{
  "ok": true
}
```

### Sign out

`POST /auth/logout` · Auth: dashboard session · RevenueDot extension

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/auth/logout"
```

**Responses**

- **200**: Signed out.

Example 200 response:

```json
{
  "ok": true
}
```

### The signed-in user and their projects

`GET /auth/me` · Auth: dashboard session · RevenueDot extension

**Example request**

```bash
curl -s "$REVENUEDOT_URL/auth/me"
```

**Responses**

- **200**: The user.
- **401**: Not signed in.

Example 200 response:

```json
{
  "user": {
    "id": "usr_8k2m4q",
    "email": "dev@example.com",
    "name": "Dana",
    "email_verified": true,
    "alert_emails": true
  },
  "account": {
    "edition": "cloud",
    "plan": "free",
    "email_verification_required": false
  },
  "projects": []
}
```

### Update account settings

`POST /auth/me` · Auth: dashboard session · RevenueDot extension

The display name and whether the user gets [alert emails](https://revenuedot.app/docs/guides/alerts.md) for projects they administer. Send only the fields to change. A null or empty `name` clears it.

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string or null | no |  |
| `alert_emails` | boolean | no | False stops alert emails for every project. |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/auth/me" \
  -H "Content-Type: application/json" -d '{"alert_emails":false}'
```

**Responses**

- **200**: The updated user.
- **400**: Invalid field.
- **401**: Not signed in.

Example 200 response:

```json
{
  "user": {
    "id": "usr_8k2m4q",
    "email": "dev@example.com",
    "name": "Dana",
    "email_verified": true,
    "alert_emails": false
  }
}
```

### Email a password reset link

`POST /auth/password/forgot` · Auth: none · RevenueDot extension

Always answers 200 with the same body, whether or not an account uses the address, so the answer does not reveal who has an account. If one does, it gets a link to `/reset-password` that works once and expires after 1 hour.

Limits: 5 requests per IP address per 15 minutes (then 429), and 3 emails per address per hour (further requests answer 200 but send nothing). See [I forgot my password](https://revenuedot.app/docs/help/forgot-password.md).

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | yes |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/auth/password/forgot" \
  -H "Content-Type: application/json" -d '{"email":"dev@example.com"}'
```

**Responses**

- **200**: Accepted.
- **400**: Not a valid email address.
- **429**: Too many requests from this IP address.

Example 200 response:

```json
{
  "ok": true,
  "message": "If an account uses this email, we sent it a link to reset the password. The link expires in 1 hour."
}
```

### Check a password reset link

`POST /auth/password/check` · Auth: none · RevenueDot extension

Tells the reset page whether the link still works before the user types a new password. Does not use up the link.

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `token` | string | yes | The `token` from the reset link. |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/auth/password/check"
```

**Responses**

- **200**: Whether the link works.
- **400**: Missing token.

Example 200 response:

```json
{
  "valid": false,
  "reason": "expired",
  "message": "This link has expired. Ask for a new one."
}
```

### Set a new password from a reset link

`POST /auth/password/reset` · Auth: none · RevenueDot extension

Sets the password, signs the user out on every device, marks the email as confirmed (the link proved the inbox) and signs this browser in with a new `rd_session` cookie. Every other open reset link of the user stops working.

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `token` | string | yes |  |
| `password` | string | yes |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/auth/password/reset" \
  -H "Content-Type: application/json" -d '{"token":"…","password":"a-new-long-password"}'
```

**Responses**

- **200**: Password changed and signed in.
- **400**: The password is too short or too long, or the link is not valid (`token_invalid` with a `reason`).

Example 200 response:

```json
{
  "ok": true
}
```

### Confirm an email address

`POST /auth/email/verify` · Auth: none · RevenueDot extension

RevenueDot Cloud only: the link in the confirmation email sent at sign-up (valid 24 hours, works once). Self-hosted servers treat every account as confirmed.

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `token` | string | yes | The `token` from the confirmation link. |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/auth/email/verify"
```

**Responses**

- **200**: Confirmed.
- **400**: The link is not valid (`token_invalid` with a `reason`).

Example 200 response:

```json
{
  "ok": true,
  "email": "dev@example.com"
}
```

### Send a new confirmation email

`POST /auth/email/verify/resend` · Auth: dashboard session · RevenueDot extension

Up to 5 per user per hour. An account that is already confirmed gets `already_verified: true` and no email.

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/auth/email/verify/resend"
```

**Responses**

- **200**: Sent, or already confirmed.
- **401**: Not signed in.
- **429**: Too many emails this hour.
- **502**: The mail server did not accept the email.

Example 200 response:

```json
{
  "ok": true,
  "email": "dev@example.com"
}
```

### Look up an invite

`GET /auth/invites/{token}` · Auth: none · RevenueDot extension

What the invite page shows: the project, the role, who sent it and whether the invited address has an account already (sign in and accept, or sign up with `invite_token`).

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `token` | string | yes | The `token` from the invite link (`/invite?token=...`). |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/auth/invites/$TOKEN"
```

**Responses**

- **200**: The invite.
- **404**: Not valid, expired, already accepted or revoked.

Example 200 response:

```json
{
  "object": "invite",
  "email": "sam@example.com",
  "role": "developer",
  "project": {
    "id": "proj18pzzkao",
    "name": "My app"
  },
  "invited_by": {
    "name": "Dana",
    "email": "dev@example.com"
  },
  "expires_at": 1791405714000,
  "account_exists": false
}
```

### Accept an invite

`POST /auth/invites/{token}/accept` · Auth: dashboard session · RevenueDot extension

For a user who already has an account, signed in with the invited address. Adds them to the project with the invite's role; someone who is already a member keeps their role. Also marks their email as confirmed.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `token` | string | yes | The `token` from the invite link (`/invite?token=...`). |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/auth/invites/$TOKEN/accept"
```

**Responses**

- **200**: Joined.
- **401**: Not signed in.
- **403**: Signed in with another address.
- **404**: The invite is no longer valid.

Example 200 response:

```json
{
  "ok": true,
  "project_id": "proj18pzzkao"
}
```

## Members and invites

Invite people to a project by email, change their role, remove them. Dashboard session only.

### List open invites

`GET /v2/projects/{project_id}/invites` · Auth: dashboard session · RevenueDot extension · Permissions: `project_configuration:collaborators:read`

Invites nobody has accepted or revoked, oldest first. Expired ones stay listed so an admin can resend them. Needs a dashboard session: secret API keys cannot manage members.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/invites"
```

**Responses**

- **200**: Open invites. Returns a list of [Invite](#invite).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

Example 200 response:

```json
{
  "object": "list",
  "items": [
    {
      "object": "invite",
      "id": "inv_4f8k2m9q1x7z",
      "email": "sam@example.com",
      "role": "developer",
      "status": "pending",
      "invited_by": "usr_8k2m4q",
      "created_at": 1790800914012,
      "last_sent_at": 1790800914012,
      "expires_at": 1791405714012
    }
  ],
  "next_page": null,
  "url": "/v2/projects/proj18pzzkao/invites"
}
```

### Invite someone by email

`POST /v2/projects/{project_id}/invites` · Auth: dashboard session · RevenueDot extension

Admins only. Emails a link that lasts 7 days. Inviting an address that already has an open invite replaces it: the role changes, a new link goes out and the old one stops working. See [Invite your team](https://revenuedot.app/docs/guides/team.md).

On RevenueDot Cloud the admin needs a confirmed email address. A project can send 50 invites (including resends) per day; after that the answer is 429. `email_sent` is false when the mail server refused the email; the invite still exists and can be resent.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | yes |  |
| `role` | `admin`, `developer`, `viewer` | yes |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/invites" \
  -H "Content-Type: application/json" -d '{"email":"sam@example.com","role":"developer"}'
```

**Responses**

- **201**: The invite.
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).
- **409**: It already exists, or it conflicts with another object. Returns [V2Error](#v2error).
- **429**: Too many requests. Retry later. Returns [V2Error](#v2error).

Example 201 response:

```json
{
  "object": "invite",
  "id": "inv_4f8k2m9q1x7z",
  "email": "sam@example.com",
  "role": "developer",
  "status": "pending",
  "invited_by": "usr_8k2m4q",
  "created_at": 1790800914012,
  "last_sent_at": 1790800914012,
  "expires_at": 1791405714012,
  "email_sent": true
}
```

### Resend an invite

`POST /v2/projects/{project_id}/invites/{invite_id}/actions/resend` · Auth: dashboard session · RevenueDot extension

Admins only. Sends a new link valid for 7 more days; the old link stops working. Works on expired invites. Counts toward the 50 invites per project per day.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `invite_id` | string | yes | Invite id (inv_...). |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/invites/$INVITE_ID/actions/resend"
```

**Responses**

- **200**: The invite.
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).
- **429**: Too many requests. Retry later. Returns [V2Error](#v2error).

### Revoke an invite

`DELETE /v2/projects/{project_id}/invites/{invite_id}` · Auth: dashboard session · RevenueDot extension

Admins only. The link stops working at once.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `invite_id` | string | yes | Invite id (inv_...). |

**Example request**

```bash
curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/invites/$INVITE_ID"
```

**Responses**

- **200**: Deleted. Returns [Deleted](#deleted).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

Example 200 response:

```json
{
  "object": "invite",
  "id": "…",
  "deleted_at": 1790801342625
}
```

### Change a member's role

`POST /v2/projects/{project_id}/collaborators/{user_id}` · Auth: dashboard session · RevenueDot extension

Admins only. A project always keeps at least one admin, so the last admin cannot be demoted (400). The response uses RevenueCat's role names: `viewer` comes back as `read_only`.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `user_id` | string | yes | The member's user id (the collaborator `id`). |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `role` | `admin`, `developer`, `viewer` | yes |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/collaborators/$USER_ID" \
  -H "Content-Type: application/json" -d '{"role":"viewer"}'
```

**Responses**

- **200**: The member. Returns [Collaborator](#collaborator).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

Example 200 response:

```json
{
  "object": "collaborator",
  "id": "usr_3n7p1x",
  "name": "Sam",
  "email": "sam@example.com",
  "role": "read_only",
  "accepted_at": 1790800914012,
  "has_mfa": false
}
```

### Remove a member, or leave the project

`DELETE /v2/projects/{project_id}/collaborators/{user_id}` · Auth: dashboard session · RevenueDot extension

Any member can remove themselves. Removing someone else takes an admin. The last admin cannot leave or be removed (422): make someone else an admin first, or delete the project.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `user_id` | string | yes | The member's user id. Your own id leaves the project. |

**Example request**

```bash
curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/collaborators/$USER_ID"
```

**Responses**

- **200**: Deleted. Returns [Deleted](#deleted).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).
- **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](#v2error).

Example 200 response:

```json
{
  "object": "collaborator",
  "id": "…",
  "deleted_at": 1790801342625
}
```

## Project settings

Project name, transfer behaviour and deletion.

### Get a project with its settings

`GET /v2/projects/{project_id}` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:projects:read`

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: The project. Returns [ProjectSettings](#projectsettings).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

Example 200 response:

```json
{
  "object": "project",
  "id": "proj18pzzkao",
  "name": "My app",
  "created_at": 1790800900675,
  "icon_url": null,
  "icon_url_large": null,
  "transfer_behavior": "transfer",
  "sandbox_transfer_behavior": null
}
```

### Update a project's name and transfer behaviour

`POST /v2/projects/{project_id}` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:projects:read_write`

See [who owns a restored purchase](https://revenuedot.app/docs/concepts/customers-and-app-user-ids.md#who-owns-a-restored-purchase).

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | no |  |
| `transfer_behavior` | `transfer`, `transfer_if_no_active`, `keep`, `share` | no |  |
| `sandbox_transfer_behavior` | `transfer`, `transfer_if_no_active`, `keep`, `share`, null | no |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID" -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" -d '{"transfer_behavior":"transfer_if_no_active"}'
```

**Responses**

- **200**: The project. Returns [ProjectSettings](#projectsettings).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

### Delete a project and everything in it

`DELETE /v2/projects/{project_id}` · Auth: dashboard session · RevenueDot extension · Permissions: `project_configuration:projects:read_write`

Only a project admin signed in to the dashboard can do this. Apps, catalog, customers, purchases, events and webhooks are deleted. Cannot be undone.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Example request**

```bash
curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID"
```

**Responses**

- **200**: Deleted. Returns [Deleted](#deleted).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

Example 200 response:

```json
{
  "object": "project",
  "id": "…",
  "deleted_at": 1790801342625
}
```

## Store setup

Notification URLs, credential checks, setup health and App Store mass extensions.

### Store setup state of an app

`GET /v2/projects/{project_id}/apps/{app_id}/store_settings` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:apps:read`

The notification URL to paste into App Store Connect or Pub/Sub, the notification status, the forwarding URL and which credentials are set. Never a secret.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `app_id` | string | yes | App id (app...). |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/store_settings" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: The settings. Returns [StoreSettings](#storesettings).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

### Check store credentials with Apple or Google

`POST /v2/projects/{project_id}/apps/{app_id}/actions/verify_credentials` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:apps:read`

Makes one harmless call to the App Store Server API or the Play Developer API. Values in the body are checked before you save them; missing values fall back to the saved ones.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `app_id` | string | yes | App id (app...). |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `app_store` | object | no |  |
| `app_store.bundle_id` | string or null | no |  |
| `app_store.subscription_private_key` | string or null | no |  |
| `app_store.subscription_key_id` | string or null | no |  |
| `app_store.subscription_key_issuer` | string or null | no |  |
| `mac_app_store` | object | no |  |
| `mac_app_store.bundle_id` | string or null | no |  |
| `mac_app_store.subscription_private_key` | string or null | no |  |
| `mac_app_store.subscription_key_id` | string or null | no |  |
| `mac_app_store.subscription_key_issuer` | string or null | no |  |
| `play_store` | object | no |  |
| `play_store.package_name` | string or null | no |  |
| `play_store.play_service_account_credentials_json` | string or object or null | no |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/actions/verify_credentials" -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" -d '{}'
```

**Responses**

- **200**: The result. Returns [CredentialsCheck](#credentialscheck).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

Example 200 response:

```json
{
  "object": "credentials_check",
  "app_id": "appugfw01uy",
  "store": "app_store",
  "status": "invalid",
  "valid": false,
  "message": "No in-app purchase key yet. Add the .p8 file, the key ID and the issuer ID.",
  "checked_at": 1790801342700
}
```

### Extend every active App Store subscriber of a product

`POST /v2/projects/{project_id}/apps/{app_id}/actions/mass_extend` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:subscriptions:read_write`

Asks Apple to extend renewal dates for all active subscribers of `product_id`. Apple then sends one notification per subscription, which records SUBSCRIPTION_EXTENDED.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `app_id` | string | yes | App id (app...). |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `product_id` | string | yes |  |
| `extend_by_days` | integer | yes |  |
| `extend_reason_code` | `undeclared`, `customer_satisfaction`, `other`, `service_issue_or_outage` | yes |  |
| `storefront_country_codes` | array of string | no |  |
| `environment` | `production`, `sandbox` | no |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/actions/mass_extend" -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" -d '{"product_id":"pro_monthly","extend_by_days":3,"extend_reason_code":"service_issue_or_outage"}'
```

**Responses**

- **202**: Accepted by Apple. Returns [MassExtension](#massextension).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).
- **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](#v2error).
- **503**: The store could not be reached. Retry later. Returns [V2Error](#v2error).

### Status of a mass extension

`GET /v2/projects/{project_id}/apps/{app_id}/mass_extensions/{request_id}` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:subscriptions:read`

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `app_id` | string | yes | App id (app...). |
| `request_id` | string | yes | The `id` from the mass extend answer. |

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `product_id` | string | yes |  |
| `environment` | `production`, `sandbox` | no |  |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/apps/$APP_ID/mass_extensions/$REQUEST_ID" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: The status. Returns [MassExtension](#massextension).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).
- **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](#v2error).
- **503**: The store could not be reached. Retry later. Returns [V2Error](#v2error).

### Setup health

`GET /v2/projects/{project_id}/setup_health` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:apps:read`

Per app: the notification URL and whether notifications arrive. For webhooks: deliveries in the last 24 hours and failing endpoints. Also the SDK versions calling the server.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/setup_health" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: Setup health. Returns [SetupHealth](#setuphealth).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

## API keys

Secret keys for the REST API.

### List secret keys

`GET /v2/projects/{project_id}/api_keys` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:api_keys:read`

Never returns the key itself.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. |
| `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/api_keys" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: A page of results. Returns a list of [ApiKey](#apikey).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

### Create a secret key

`POST /v2/projects/{project_id}/api_keys` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:api_keys:read_write`

The answer includes `key` once. `permissions` default to `["*"]`. A key cannot create a key with permissions it does not hold.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | yes |  |
| `permissions` | array of string | no |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/api_keys" -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" -d '{"name":"Backend (read only)","permissions":["customer_information:customers:read"]}'
```

**Responses**

- **201**: The key. Returns [ApiKey](#apikey).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

Example 201 response:

```json
{
  "object": "api_key",
  "id": "key_08ec817fce",
  "name": "Backend (read only)",
  "prefix": "sk_08ec",
  "permissions": [
    "customer_information:customers:read"
  ],
  "created_at": 1790801342634,
  "last_used_at": null,
  "key": "sk_08ec817fceead27005772bb943c2bd225850eb931c9835f0"
}
```

### Delete a secret key

`DELETE /v2/projects/{project_id}/api_keys/{key_id}` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:api_keys:read_write`

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `key_id` | string | yes | Key id (key_...). |

**Example request**

```bash
curl -s -X DELETE "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/api_keys/$KEY_ID" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: Deleted. Returns [Deleted](#deleted).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

Example 200 response:

```json
{
  "object": "api_key",
  "id": "…",
  "deleted_at": 1790801342625
}
```

## Webhook deliveries

Delivery log, manual retry and test events.

### Send a TEST event to one webhook

`POST /v2/projects/{project_id}/integrations/webhooks/{webhook_integration_id}/test` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:integrations:read_write`

Queues a purchase-shaped TEST event, signed and retried like any delivery. The webhook's filters do not apply. A paused webhook (`enabled` false) answers 422.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `webhook_integration_id` | string | yes | Webhook id. |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/integrations/webhooks/$WEBHOOK_INTEGRATION_ID/test" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **201**: The queued delivery. Returns [WebhookDelivery](#webhookdelivery).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).
- **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](#v2error).

### Whether each webhook is enabled

`GET /v2/projects/{project_id}/webhooks` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:integrations:read`

RevenueCat's webhook object has no `enabled` field, so it is read here. Set it with `POST .../integrations/webhooks/{id}`.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/webhooks" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: A page of results. Returns a list of [WebhookState](#webhookstate).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

### Delivery log of a webhook

`GET /v2/projects/{project_id}/webhooks/{webhook_id}/deliveries` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:integrations:read`

Newest first.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `webhook_id` | string | yes | Webhook id (wh_...). |

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `status` | `pending`, `delivered`, `failed` | no |  |
| `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. |
| `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/webhooks/$WEBHOOK_ID/deliveries" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: A page of results. Returns a list of [WebhookDelivery](#webhookdelivery).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

### Retry a delivery now

`POST /v2/projects/{project_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:integrations:read_write`

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `webhook_id` | string | yes | Webhook id. |
| `delivery_id` | string | yes | Delivery id. |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/retry" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: The delivery, queued. Returns [WebhookDelivery](#webhookdelivery).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

## Event log

Every recorded event and money movement.

### Event log

`GET /v2/projects/{project_id}/events` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:customers:read`

Every event the project recorded, newest first. `body` is exactly what webhooks receive.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `type` | array of string | no | Event types (any case); repeat or comma-separate. |
| `customer` | string | no | Any app user id of the customer. |
| `environment` | `production`, `sandbox` | no | Only this environment. Default: both. |
| `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. |
| `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/events" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: A page of results. Returns a list of [Event](#event).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

### Transaction feed

`GET /v2/projects/{project_id}/transactions` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:purchases:read`

Every purchase, renewal, trial start, refund and refund reversal, newest first.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `customer` | string | no | Any app user id of the customer. |
| `environment` | `production`, `sandbox` | no | Only this environment. Default: both. |
| `limit` | integer | no | Page size. Values outside 1-100 are clamped, not rejected. |
| `starting_after` | string | no | Id of the last item of the previous page. Use `next_page` instead of building it. |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/transactions" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: A page of results. Returns a list of [Transaction](#transaction).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

## Test Store

Simulated purchases and lifecycles for development.

### Simulate a Test Store purchase or lifecycle

`POST /v2/projects/{project_id}/test_purchases` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:purchases:read_write`

Runs a purchase through the same pipeline as an SDK receipt, so events, the transaction ledger and webhooks come out as they would. Scenarios:
`purchase`, `trial`, `trial_conversion`, `renewal`, `cancel`, `billing_issue`, `refund`, `expire`. See [the Test Store guide](https://revenuedot.app/docs/guides/test-store.md).

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `app_user_id` | string | yes |  |
| `product_id` | string | yes | Product id or store identifier of a Test Store product. |
| `app_id` | string | no | Test Store app; default the project's first. |
| `price` | number | no |  |
| `currency` | string | no | Three letters; default USD. |
| `purchased_at` | integer | no | Start, epoch milliseconds. Not with offset_days. |
| `presented_offering_id` | string | no |  |
| `scenario` | `purchase`, `trial`, `trial_conversion`, `renewal`, `cancel`, `billing_issue`, `refund`, `expire` | no |  |
| `offset_days` | number | no | Days ago the scenario starts (0 to 730). |
| `country_code` | string | no | ISO 3166-1 alpha-2, upper case. |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/test_purchases" -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" -d '{"app_user_id":"user_renewal","product_id":"pro_monthly","scenario":"renewal","price":9.99}'
```

**Responses**

- **201**: What happened. Returns [TestPurchase](#testpurchase).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).
- **422**: The request is valid but cannot be done in this state or for this store. Returns [V2Error](#v2error).

## Dashboard data

Series and rows the dashboard shows.

### Daily history of an overview metric

`GET /v2/projects/{project_id}/metrics/history` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `charts_metrics:overview:read`

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `metric` | `active_trials`, `active_subscriptions`, `mrr`, `revenue`, `new_customers`, `active_users` | yes |  |
| `days` | integer | no |  |
| `environment` | `production`, `sandbox` | no |  |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/metrics/history" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: The history. Returns [MetricHistory](#metrichistory).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

### Dashboard rows for customers

`GET /v2/projects/{project_id}/customer_summaries` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:customers:read`

Revenue, entitlement names and prices per customer. Unknown ids are left out.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `ids` | string | yes | Up to 100 app user ids, comma separated or repeated. |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/customer_summaries" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: A page of results. Returns a list of [CustomerSummary](#customersummary).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

## Migration import

Bulk import from RevenueCat, used by the `revenuedot import` CLI.

### Import customers with their purchases

`POST /v2/projects/{project_id}/import/customers` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:customers:read_write`

Up to 100 RevenueCat-shaped customers per call, each with aliases, attributes, subscriptions and one-time purchases. Writes state directly: no events and no webhooks unless `emit_events` is true.
Keeps first-seen dates, original purchase dates and store transaction ids, and keys each subscription like the store adapters do (Apple original transaction id, Google purchase token), so later receipts and notifications update the imported row. Running the same import twice changes nothing.
Google subscriptions without `purchase_token` are keyed `needs_token_refresh:<order id>` until a token is found. The `revenuedot import` CLI calls this; see [the importer](https://revenuedot.app/docs/migrate/importer.md).

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `customers` | array of object | yes |  |
| `customers[].id` | string | yes |  |
| `customers[].aliases` | array of string | no |  |
| `customers[].first_seen_at` | integer | no |  |
| `customers[].last_seen_at` | integer | no |  |
| `customers[].last_seen_app_version` | string or null | no |  |
| `customers[].last_seen_country` | string or null | no |  |
| `customers[].last_seen_platform` | string or null | no |  |
| `customers[].attributes` | array of object | no |  |
| `customers[].attributes[].name` | string | yes |  |
| `customers[].attributes[].value` | string or null | yes |  |
| `customers[].attributes[].updated_at` | integer | no |  |
| `customers[].subscriptions` | array of object | no |  |
| `customers[].subscriptions[].source_id` | string | no |  |
| `customers[].subscriptions[].app_id` | string or null | no |  |
| `customers[].subscriptions[].store` | string | yes |  |
| `customers[].subscriptions[].product_identifier` | string | yes |  |
| `customers[].subscriptions[].environment` | `production`, `sandbox` | no |  |
| `customers[].subscriptions[].ownership` | `purchased`, `family_shared` | no |  |
| `customers[].subscriptions[].starts_at` | integer | yes |  |
| `customers[].subscriptions[].current_period_starts_at` | integer | yes |  |
| `customers[].subscriptions[].current_period_ends_at` | integer or null | no |  |
| `customers[].subscriptions[].status` | `trialing`, `active`, `expired`, `in_grace_period`, `in_billing_retry`, `paused`, `unknown`, `incomplete` | yes |  |
| `customers[].subscriptions[].auto_renewal_status` | `will_renew`, `will_not_renew`, `will_change_product`, `will_pause`, `requires_price_increase_consent`, `has_already_renewed` | no |  |
| `customers[].subscriptions[].store_subscription_identifier` | string | yes |  |
| `customers[].subscriptions[].original_transaction_id` | string or null | no |  |
| `customers[].subscriptions[].original_transaction_id_confirmed` | boolean | no |  |
| `customers[].subscriptions[].purchase_token` | string or null | no |  |
| `customers[].subscriptions[].period_type` | `normal`, `trial`, `intro`, `promotional`, `prepaid` | no |  |
| `customers[].subscriptions[].country` | string or null | no |  |
| `customers[].subscriptions[].price` | object or null | no |  |
| `customers[].subscriptions[].total_revenue_usd` | number or null | no |  |
| `customers[].subscriptions[].unsubscribe_detected_at` | integer or null | no |  |
| `customers[].subscriptions[].billing_issues_detected_at` | integer or null | no |  |
| `customers[].subscriptions[].grace_period_expires_at` | integer or null | no |  |
| `customers[].subscriptions[].refunded_at` | integer or null | no |  |
| `customers[].subscriptions[].auto_resume_at` | integer or null | no |  |
| `customers[].subscriptions[].entitlement_lookup_keys` | array of string | no |  |
| `customers[].subscriptions[].auto_renew_product_identifier` | string or null | no |  |
| `customers[].subscriptions[].transactions` | array of object | no |  |
| `customers[].purchases` | array of object | no |  |
| `customers[].purchases[].source_id` | string | no |  |
| `customers[].purchases[].app_id` | string or null | no |  |
| `customers[].purchases[].store` | string | yes |  |
| `customers[].purchases[].product_identifier` | string | yes |  |
| `customers[].purchases[].environment` | `production`, `sandbox` | no |  |
| `customers[].purchases[].purchased_at` | integer | yes |  |
| `customers[].purchases[].store_purchase_identifier` | string | yes |  |
| `customers[].purchases[].status` | `owned`, `refunded` | no |  |
| `customers[].purchases[].refunded_at` | integer or null | no |  |
| `customers[].purchases[].consumable` | boolean | no |  |
| `customers[].purchases[].price` | object or null | no |  |
| `customers[].purchases[].revenue_usd` | number or null | no |  |
| `customers[].purchases[].country` | string or null | no |  |
| `emit_events` | boolean | no | Default false. |
| `resolve_store_ids` | boolean | no | Default true: use the app's store credentials to confirm Apple ids and find Google tokens. |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/import/customers" -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" -d '{"customers":[{"id":"imported_1","aliases":["$RCAnonymousID:0f1e2d"],"first_seen_at":1735689600000,"attributes":[{"name":"$email","value":"ana@example.com"}],"subscriptions":[{"store":"test_store","app_id":"appvnrm0a5h","product_identifier":"pro_annual","starts_at":1735689600000,"current_period_starts_at":1767225600000,"current_period_ends_at":1798761600000,"status":"active","auto_renewal_status":"will_renew","store_subscription_identifier":"test_1767225600000_imported"}]}]}'
```

**Responses**

- **200**: A report per customer. Returns [ImportResult](#importresult).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

Example 200 response:

```json
{
  "object": "import_result",
  "emit_events": false,
  "customers": [
    {
      "id": "imported_1",
      "status": "created",
      "subscriptions": 1,
      "purchases": 0,
      "needs_token_refresh": 0,
      "notes": []
    }
  ]
}
```

### Keep an app's existing SDK key

`POST /v2/projects/{project_id}/import/apps/{app_id}/public_key` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `project_configuration:apps:read_write`

Sets the app's public key to the one your shipped app binaries already send (appl_..., goog_...), so old app versions work against RevenueDot. The prefix must match the app's store.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |
| `app_id` | string | yes | App id (app...). |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `public_key` | string | yes |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/import/apps/$APP_ID/public_key" -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" -d '{"public_key":"appl_AbCdEfGhIjKlMnOpQrStUvWxYz"}'
```

**Responses**

- **200**: The key. Returns [PublicApiKey](#publicapikey).
- **400**: The request is invalid. Returns [V2Error](#v2error).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).
- **409**: It already exists, or it conflicts with another object. Returns [V2Error](#v2error).

### What still needs attention after an import

`GET /v2/projects/{project_id}/import/status` · Auth: secret key or dashboard session · RevenueDot extension · Permissions: `customer_information:customers:read`

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `project_id` | string | yes | Project id (proj...). |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/v2/projects/$PROJECT_ID/import/status" -H "Authorization: Bearer $SECRET_KEY"
```

**Responses**

- **200**: Counts. Returns [ImportStatus](#importstatus).
- **401**: No API key, or an unknown one. Returns [V2Error](#v2error).
- **403**: The key lacks a permission, or a public key was used. Returns [V2Error](#v2error).
- **404**: Not found in this project (another project's ids also answer 404). Returns [V2Error](#v2error).

Example 200 response:

```json
{
  "object": "import_status",
  "customers": 13,
  "subscriptions": 10,
  "needs_token_refresh": 0,
  "needs_token_refresh_by_app": {}
}
```

## OAuth for MCP clients

OAuth 2.1 with PKCE so MCP clients can connect to one project without copying a key.

### OAuth authorization server metadata

`GET /.well-known/oauth-authorization-server` · Auth: none · RevenueDot extension

RFC 8414 metadata for MCP clients (Claude, ChatGPT, Cursor ...). Endpoints are built from this server's public origin.

**Example request**

```bash
curl -s "$REVENUEDOT_URL/.well-known/oauth-authorization-server"
```

**Responses**

- **200**: Metadata.

Example 200 response:

```json
{
  "issuer": "https://revenuedot.example.com",
  "authorization_endpoint": "https://revenuedot.example.com/oauth/authorize",
  "token_endpoint": "https://revenuedot.example.com/oauth/token",
  "registration_endpoint": "https://revenuedot.example.com/oauth/register",
  "scopes_supported": [
    "project:read",
    "project:write"
  ],
  "response_types_supported": [
    "code"
  ],
  "response_modes_supported": [
    "query"
  ],
  "grant_types_supported": [
    "authorization_code"
  ],
  "token_endpoint_auth_methods_supported": [
    "none"
  ],
  "code_challenge_methods_supported": [
    "S256"
  ],
  "service_documentation": "https://revenuedot.app/docs/mcp"
}
```

### Register an OAuth client

`POST /oauth/register` · Auth: none · RevenueDot extension

Dynamic client registration (RFC 7591), public clients only. Redirect URIs must be https, http on localhost, or an app scheme such as cursor://.

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `redirect_uris` | array of string | yes |  |
| `client_name` | string | no |  |
| `grant_types` | array of string | no |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/oauth/register" \
  -H "Content-Type: application/json" -d '{"client_name":"Claude","redirect_uris":["https://claude.ai/api/mcp/auth_callback"]}'
```

**Responses**

- **201**: The client.
- **400**: Invalid metadata or redirect URI.

### Consent screen

`GET /oauth/authorize` · Auth: none · RevenueDot extension

An HTML page. The user signs in to the dashboard (the session cookie is reused), picks one project and read or read-write access. PKCE with S256 is required.

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `response_type` | string | no |  |
| `client_id` | string | no |  |
| `redirect_uri` | string | no |  |
| `state` | string | no |  |
| `scope` | string | no |  |
| `code_challenge` | string | no |  |
| `code_challenge_method` | string | no |  |
| `resource` | string | no |  |

**Example request**

```bash
curl -s "$REVENUEDOT_URL/oauth/authorize"
```

**Responses**

- **200**: The consent page.
- **302**: Back to the client with an error.
- **400**: Unknown client or redirect URI.

### Submit the consent decision

`POST /oauth/authorize` · Auth: dashboard session · RevenueDot extension

The consent form posts here. On allow, redirects to the client's redirect URI with a one-time `code` (valid 10 minutes).

**Request body** (`application/x-www-form-urlencoded`)

| Field | Type | Required | Description |
|---|---|---|---|
| `decision` | `allow`, `deny` | no |  |
| `project_id` | string | no |  |
| `access` | `project:read`, `project:write` | no |  |
| `csrf` | string | no |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/oauth/authorize"
```

**Responses**

- **302**: Redirect with `code` and `state`, or with `error`.
- **403**: The form expired.

### Exchange a code for an access token

`POST /oauth/token` · Auth: none · RevenueDot extension

authorization_code grant with PKCE. The access token is a secret API key (sk_...) bound to the chosen project with the approved permissions. It does not expire; revoke it on the project's API keys page.

**Request body** (`application/x-www-form-urlencoded`)

| Field | Type | Required | Description |
|---|---|---|---|
| `grant_type` | `"authorization_code"` | yes |  |
| `code` | string | yes |  |
| `code_verifier` | string | yes |  |
| `client_id` | string | no |  |
| `redirect_uri` | string | no |  |

**Example request**

```bash
curl -s -X POST "$REVENUEDOT_URL/oauth/token"
```

**Responses**

- **200**: The token.
- **400**: Invalid grant or request.

## Objects

The shapes the operations above send and return.

### ActiveEntitlement

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"customer.active_entitlement"` | yes |  |
| `entitlement_id` | string | yes | Entitlement id (entl...), not the lookup key. |
| `expires_at` | integer or null | yes | When access ends. Epoch milliseconds, or null. |

### ApiKey

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"api_key"` | yes |  |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `prefix` | string | yes | First 7 characters of the key. |
| `permissions` | array of string | yes | Scopes; `*` is every scope. |
| `created_at` | integer | yes | Creation time. Epoch milliseconds. |
| `last_used_at` | integer or null | yes | Last use, updated at most once a minute. Epoch milliseconds, or null. |
| `key` | string | no | The secret key (sk_...). Only in the answer that creates it. |

### App

Only the object for the app's own `type` is present. Store secrets are never returned.

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"app"` | yes |  |
| `id` | string | yes | App id (app...). |
| `name` | string | yes |  |
| `created_at` | integer | yes | Creation time. Epoch milliseconds. |
| `type` | `amazon`, `app_store`, `mac_app_store`, `play_store`, `stripe`, `rc_billing`, `roku`, `paddle`, `test_store` | yes |  |
| `project_id` | string | yes |  |
| `custom_url_scheme` | string | no | Derived from the public key. |
| `app_store` | object | no |  |
| `app_store.bundle_id` | string | no |  |
| `app_store.app_store_connect_api_key_configured` | boolean | no |  |
| `app_store.subscription_key_configured` | boolean | no | True when the in-app purchase key (.p8, key id, issuer id) is set. |
| `app_store.app_store_connect_vendor_number` | string or null | no |  |
| `mac_app_store` | object | no |  |
| `mac_app_store.bundle_id` | string | no |  |
| `play_store` | object | no |  |
| `play_store.package_name` | string | no |  |
| `play_store.play_service_account_credentials_configured` | boolean | no |  |
| `amazon` | object | no |  |
| `amazon.package_name` | string | no |  |
| `stripe` | object | no |  |
| `stripe.stripe_account_id` | string or null | no |  |
| `rc_billing` | object | no |  |
| `rc_billing.stripe_account_id` | string or null | no |  |
| `rc_billing.seller_company_name` | string | no |  |
| `rc_billing.app_name` | string | no |  |
| `rc_billing.support_email` | string or null | no |  |
| `rc_billing.default_currency` | string | no |  |
| `roku` | object | no |  |
| `roku.roku_channel_id` | string or null | no |  |
| `roku.roku_channel_name` | string or null | no |  |
| `paddle` | object | no |  |
| `paddle.paddle_is_sandbox` | boolean | no |  |
| `paddle.paddle_api_key` | null | no |  |

### Collaborator

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"collaborator"` | yes |  |
| `id` | string | yes |  |
| `name` | string or null | no |  |
| `email` | string | yes |  |
| `role` | `admin`, `developer`, `read_only` | yes | RevenueCat's role names. `read_only` is the dashboard's Viewer role. |
| `accepted_at` | integer | no | When the user joined. Epoch milliseconds. |
| `has_mfa` | boolean | no | Always false. |

### CredentialsCheck

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"credentials_check"` | yes |  |
| `app_id` | string | yes |  |
| `store` | string | yes |  |
| `status` | `valid`, `invalid`, `unreachable` | yes |  |
| `valid` | boolean | yes |  |
| `message` | string | yes | What to do next, in plain words. |
| `checked_at` | integer | yes | Checked at. Epoch milliseconds. |
| `key_id` | string | no |  |
| `client_email` | string or null | no |  |

### Customer

`active_entitlements` and `experiment` are present on single-customer answers; `attributes` only with `expand=attributes`.

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"customer"` | yes |  |
| `id` | string | yes | The customer's original app user id. |
| `project_id` | string | yes |  |
| `first_seen_at` | integer | yes | First seen. Epoch milliseconds. |
| `last_seen_at` | integer or null | yes | Last seen. Epoch milliseconds, or null. |
| `last_seen_app_version` | string or null | no |  |
| `last_seen_country` | string or null | no |  |
| `last_seen_platform` | string or null | no |  |
| `last_seen_platform_version` | null | no |  |
| `active_entitlements` | object | no |  |
| `active_entitlements.object` | `"list"` | yes |  |
| `active_entitlements.items` | array of ActiveEntitlement | yes |  |
| `active_entitlements.next_page` | string or null | yes | Path of the next page, or null on the last page. |
| `active_entitlements.url` | string | yes | Path of this list. |
| `experiment` | null | no |  |
| `attributes` | object | no |  |
| `attributes.object` | `"list"` | yes |  |
| `attributes.items` | array of CustomerAttribute | yes |  |
| `attributes.next_page` | string or null | yes | Path of the next page, or null on the last page. |
| `attributes.url` | string | yes | Path of this list. |

### CustomerAttribute

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"customer.attribute"` | yes |  |
| `name` | string | yes |  |
| `value` | string | yes |  |
| `updated_at` | integer | yes | Last update. Epoch milliseconds. |

### CustomerSummary

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"customer_summary"` | yes |  |
| `id` | string | yes | The id you asked for. |
| `original_app_user_id` | string | yes |  |
| `aliases` | array of string | no |  |
| `total_revenue_in_usd` | number | no |  |
| `sandbox_revenue_in_usd` | number | no |  |
| `country` | string or null | no |  |
| `platform` | string or null | no |  |
| `stores` | array of string | no |  |
| `offering_override` | string or null | no |  |
| `active_entitlements` | array of object | no |  |
| `granted_entitlements` | array of object | no |  |
| `subscriptions` | array of object | no |  |
| `purchases` | array of object | no |  |

### Deleted

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | string | yes | The deleted object's type. |
| `id` | string | yes |  |
| `deleted_at` | integer | yes | When it was deleted. Epoch milliseconds. |

### Entitlement

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"entitlement"` | yes |  |
| `id` | string | yes | Entitlement id (entl...). |
| `project_id` | string | yes |  |
| `lookup_key` | string | yes | What apps check, for example `pro`. |
| `display_name` | string | yes |  |
| `created_at` | integer | yes | Creation time. Epoch milliseconds. |
| `state` | `active`, `inactive` | yes |  |
| `products` | object | no |  |
| `products.object` | `"list"` | yes |  |
| `products.items` | array of Product | yes |  |
| `products.next_page` | string or null | yes | Path of the next page, or null on the last page. |
| `products.url` | string | yes | Path of this list. |

### Event

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"event"` | yes |  |
| `id` | string | yes |  |
| `type` | string | yes |  |
| `environment` | `production`, `sandbox` | yes |  |
| `app_id` | string or null | no |  |
| `customer_id` | string or null | no | Original app user id. |
| `app_user_id` | string or null | no |  |
| `occurred_at` | integer | yes | When it happened. Epoch milliseconds. |
| `created_at` | integer | no | Recorded. Epoch milliseconds. |
| `body` | object | yes | The webhook `event` object, exactly as webhooks receive it. |

### ImportResult

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"import_result"` | yes |  |
| `emit_events` | boolean | yes |  |
| `customers` | array of object | yes |  |
| `customers[].id` | string | no |  |
| `customers[].status` | `created`, `updated`, `merged` | no |  |
| `customers[].subscriptions` | integer | no |  |
| `customers[].purchases` | integer | no |  |
| `customers[].needs_token_refresh` | integer | no |  |
| `customers[].notes` | array of string | no |  |

### ImportStatus

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"import_status"` | yes |  |
| `customers` | integer | yes |  |
| `subscriptions` | integer | yes |  |
| `needs_token_refresh` | integer | yes | Google Play subscriptions still waiting for their purchase token. |
| `needs_token_refresh_by_app` | object | yes |  |

### IndicativePrice

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"indicative_price"` | yes |  |
| `currency` | string | yes | ISO 4217 code. |
| `country` | null | yes |  |
| `amount_micros` | integer | yes | Price in micros: 9.99 is 9990000. |

### Invite

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"invite"` | yes |  |
| `id` | string | yes | inv_... |
| `email` | string | yes | The invited address, lowercased. |
| `role` | `admin`, `developer`, `viewer` | yes | The role the person gets when they accept. |
| `status` | `pending`, `expired`, `accepted`, `revoked` | yes | Lists only show `pending` and `expired`. An expired invite can be resent. |
| `invited_by` | string or null | no | User id of the admin who last sent it. |
| `created_at` | integer | no | When it was created. Epoch milliseconds. |
| `last_sent_at` | integer | no | When the last email went out. Epoch milliseconds. |
| `expires_at` | integer | yes | When the link stops working: 7 days after it was last sent. Epoch milliseconds. |

### MassExtension

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"subscription_mass_extension"` | yes |  |
| `id` | string | yes | Request id. |
| `app_id` | string | yes |  |
| `product_id` | string | yes |  |
| `environment` | `production`, `sandbox` | yes |  |
| `extend_by_days` | integer | no |  |
| `extend_reason_code` | string | no |  |
| `storefront_country_codes` | array of string | no |  |
| `complete` | boolean | yes |  |
| `completed_at` | integer or null | no |  |
| `succeeded_count` | integer or null | no |  |
| `failed_count` | integer or null | no |  |
| `requested_at` | integer | no |  |

### MetricHistory

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"metric_history"` | yes |  |
| `id` | string | yes |  |
| `currency` | `"USD"` | no |  |
| `days` | integer | yes |  |
| `environment` | `production`, `sandbox` | yes |  |
| `resolution` | `"day"` | no |  |
| `value` | number | no |  |
| `previous_value` | number or null | no |  |
| `values` | array of object | yes |  |
| `values[].date` | string | no | YYYY-MM-DD |
| `values[].value` | number | no |  |
| `last_updated_at` | integer | no | Computed at. Epoch milliseconds. |

### MonetaryAmount

| Field | Type | Required | Description |
|---|---|---|---|
| `currency` | string | yes | ISO 4217 currency code. |
| `gross` | number | yes | Gross amount. |
| `commission` | number | yes | Estimated store commission. |
| `tax` | number | yes | Tax. Always 0 today. |
| `proceeds` | number | yes | Gross minus commission. |

### Price

| Field | Type | Required | Description |
|---|---|---|---|
| `amount` | number | yes | Price in the purchase currency. |
| `currency` | string | yes | ISO 4217 currency code. |

### Product

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"product"` | yes |  |
| `id` | string | yes | Product id (prod...). |
| `store_identifier` | string | yes | The store's product id. Google Play subscriptions use `subscriptionId:basePlanId`. |
| `type` | `subscription`, `one_time`, `consumable`, `non_consumable`, `non_renewing_subscription` | yes |  |
| `state` | `active`, `inactive` | yes |  |
| `subscription` | object | no |  |
| `subscription.duration` | string or null | no | ISO 8601 period such as P1M. |
| `subscription.grace_period_duration` | null | no |  |
| `subscription.trial_duration` | null | no |  |
| `one_time` | object | no |  |
| `one_time.is_consumable` | boolean or null | no |  |
| `created_at` | integer | yes | Creation time. Epoch milliseconds. |
| `app_id` | string | yes |  |
| `display_name` | string or null | yes |  |
| `app` | App | no | Only the object for the app's own `type` is present. Store secrets are never returned. |
| `indicative_price` | IndicativePrice or null | no | With `expand=indicative_price`: the Test Store price, or null. |

### Project

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"project"` | yes |  |
| `id` | string | yes | Project id (proj...). |
| `name` | string | yes |  |
| `created_at` | integer | yes | Creation time. Epoch milliseconds. |
| `icon_url` | string or null | no | Always null. |
| `icon_url_large` | string or null | no | Always null. |

### ProjectSettings

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"project"` | yes |  |
| `id` | string | yes | Project id (proj...). |
| `name` | string | yes |  |
| `created_at` | integer | yes | Creation time. Epoch milliseconds. |
| `icon_url` | string or null | no | Always null. |
| `icon_url_large` | string or null | no | Always null. |
| `transfer_behavior` | `transfer`, `transfer_if_no_active`, `keep`, `share` | yes | What happens when a purchase already owned by another customer is restored. Default transfer. |
| `sandbox_transfer_behavior` | `transfer`, `transfer_if_no_active`, `keep`, `share`, null | yes | Override for sandbox purchases; null uses transfer_behavior. |

### PublicApiKey

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"public_api_key"` | yes |  |
| `id` | string | yes |  |
| `key` | string | yes | The key the SDK sends (appl_, goog_, test_ ...). |
| `environment` | `production`, `sandbox` | yes |  |
| `app_id` | string | yes |  |
| `created_at` | integer | yes | Creation time. Epoch milliseconds. |

### Purchase

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"purchase"` | yes |  |
| `id` | string | yes |  |
| `customer_id` | string | yes |  |
| `original_customer_id` | string | no |  |
| `product_id` | string | yes |  |
| `purchased_at` | integer | yes | Purchase time. Epoch milliseconds. |
| `revenue_in_usd` | MonetaryAmount | no |  |
| `quantity` | integer | no |  |
| `status` | `owned`, `refunded` | yes |  |
| `presented_offering_id` | string or null | no | Offering the purchase was made from (its id, or the identifier the SDK sent when no such offering exists). |
| `entitlements` | object | no |  |
| `entitlements.object` | `"list"` | yes |  |
| `entitlements.items` | array of Entitlement | yes |  |
| `entitlements.next_page` | string or null | yes | Path of the next page, or null on the last page. |
| `entitlements.url` | string | yes | Path of this list. |
| `environment` | `production`, `sandbox` | yes |  |
| `store` | string | yes |  |
| `store_purchase_identifier` | string | no |  |
| `ownership` | `purchased` | no |  |
| `country` | string | no |  |

### SetupHealth

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"setup_health"` | yes |  |
| `project_id` | string | yes |  |
| `checked_at` | integer | yes | Computed at. Epoch milliseconds. |
| `apps` | array of object | yes |  |
| `apps[].id` | string | no |  |
| `apps[].name` | string | no |  |
| `apps[].type` | string | no |  |
| `apps[].notification_url` | string or null | no | Where the store must send notifications. |
| `apps[].last_notification_at` | integer or null | no | Last notification processed for a known purchase (or the store's test). Epoch milliseconds, or null. |
| `apps[].last_notification_received_at` | integer or null | no | Last notification received at all. Epoch milliseconds, or null. |
| `apps[].last_notification_error` | object or null | no |  |
| `apps[].last_notification_error.at` | integer | no |  |
| `apps[].last_notification_error.type` | string or null | no |  |
| `apps[].last_notification_error.message` | string | no |  |
| `apps[].notification_status` | `ready`, `failing`, `received`, `waiting` | no |  |
| `apps[].credentials_configured` | boolean | no |  |
| `webhooks` | object | yes |  |
| `webhooks.total` | integer | no |  |
| `webhooks.attempted_24h` | integer | no |  |
| `webhooks.delivered_24h` | integer | no |  |
| `webhooks.failed_24h` | integer | no |  |
| `webhooks.pending` | integer | no |  |
| `webhooks.delivered_percent_24h` | number or null | no |  |
| `webhooks.failing` | array of object | no |  |
| `webhooks.failing[].id` | string | no |  |
| `webhooks.failing[].name` | string | no |  |
| `webhooks.failing[].url` | string | no |  |
| `webhooks.failing[].last_status` | integer or null | no |  |
| `webhooks.failing[].last_error` | string or null | no |  |
| `webhooks.failing[].last_attempt_at` | integer | no |  |
| `webhooks.failing[].delivery_status` | string | no |  |
| `sdk_versions` | array of object | yes |  |

### StoreSettings

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"app_store_settings"` | yes |  |
| `app_id` | string | yes |  |
| `type` | string | yes |  |
| `api_origin` | string | yes | This server as the outside world reaches it: the SDK's proxy URL. |
| `notification_url` | string or null | no | App Store or Google Play notification URL for this app. |
| `notification_forward_url` | string or null | no | Where notifications are copied during a dual run. |
| `last_notification_at` | integer or null | no | Last notification processed for a known purchase. Epoch milliseconds, or null. |
| `last_notification_error` | string or null | no |  |
| `last_notification_received_at` | integer or null | no | Last notification received. Epoch milliseconds, or null. |
| `notification_status` | `ready`, `failing`, `received`, `waiting` | yes |  |
| `last_forward` | object or null | no |  |
| `last_forward.status` | integer | no | HTTP status of the forward; 0 means no answer. |
| `last_forward.at` | integer | no |  |
| `track_new_purchases` | boolean | no | Apply notifications about purchases this server has never seen. |
| `allow_unsigned_receipts` | boolean | no | Accept StoreKit 1 receipts without the in-app purchase key. Development only. |
| `credentials` | object | yes |  |
| `credentials.subscription_key` | object | no |  |
| `credentials.subscription_key.configured` | boolean | no |  |
| `credentials.subscription_key.key_id` | string or null | no |  |
| `credentials.subscription_key.issuer_id` | string or null | no |  |
| `credentials.app_store_connect_api_key` | object | no |  |
| `credentials.app_store_connect_api_key.configured` | boolean | no |  |
| `credentials.app_store_connect_api_key.key_id` | string or null | no |  |
| `credentials.app_store_connect_api_key.issuer_id` | string or null | no |  |
| `credentials.app_store_connect_api_key.vendor_number` | string or null | no |  |
| `credentials.shared_secret` | object | no |  |
| `credentials.shared_secret.configured` | boolean | no |  |
| `credentials.play_service_account` | object | no |  |
| `credentials.play_service_account.configured` | boolean | no |  |
| `credentials.play_service_account.client_email` | string or null | no |  |
| `credentials.xcode_certificate` | object | no |  |
| `credentials.xcode_certificate.configured` | boolean | no |  |

### Subscription

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"subscription"` | yes |  |
| `id` | string | yes | Subscription id (sub_...). |
| `customer_id` | string | yes |  |
| `original_customer_id` | string | no |  |
| `product_id` | string or null | no | Product id (prod...), null for promotional grants. |
| `starts_at` | integer | yes | Start of the subscription. Epoch milliseconds. |
| `current_period_starts_at` | integer | no | Start of the current period. Epoch milliseconds. |
| `current_period_ends_at` | integer or null | no | End of the current period. Epoch milliseconds, or null. |
| `ends_at` | integer or null | no | End of access. Epoch milliseconds, or null. |
| `gives_access` | boolean | yes |  |
| `pending_payment` | boolean | no |  |
| `auto_renewal_status` | `will_renew`, `will_not_renew`, `will_change_product`, `will_pause` | yes |  |
| `status` | `trialing`, `active`, `in_grace_period`, `in_billing_retry`, `paused`, `expired` | yes |  |
| `total_revenue_in_usd` | MonetaryAmount | no |  |
| `presented_offering_id` | string or null | no | Offering the purchase was made from (its id, or the identifier the SDK sent when no such offering exists). |
| `entitlements` | object | no |  |
| `entitlements.object` | `"list"` | yes |  |
| `entitlements.items` | array of Entitlement | yes |  |
| `entitlements.next_page` | string or null | yes | Path of the next page, or null on the last page. |
| `entitlements.url` | string | yes | Path of this list. |
| `environment` | `production`, `sandbox` | yes |  |
| `store` | string | yes |  |
| `store_subscription_identifier` | string | no | Latest store transaction id, order id or token. |
| `ownership` | `purchased`, `family_shared` | no |  |
| `country` | string | no | ISO 3166-1 alpha-2, when known. |
| `management_url` | null | no |  |

### TestPurchase

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"test_purchase"` | yes |  |
| `scenario` | string | yes |  |
| `store_transaction_id` | string | yes | The Test Store token (test_<ms>_<uuid>). |
| `event_types` | array of string | yes | Events recorded, in order. |
| `customer` | Customer | yes | `active_entitlements` and `experiment` are present on single-customer answers; `attributes` only with `expand=attributes`. |
| `subscription` | Subscription or null | no |  |
| `purchase` | Purchase or null | no |  |

### Transaction

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"transaction"` | yes |  |
| `id` | string | yes |  |
| `customer_id` | string | yes |  |
| `app_id` | string or null | no |  |
| `store` | string | yes |  |
| `store_transaction_id` | string or null | no |  |
| `product_identifier` | string | yes |  |
| `kind` | `purchase`, `renewal`, `trial`, `one_time`, `refund`, `refund_reversal` | yes |  |
| `environment` | `production`, `sandbox` | yes |  |
| `purchased_at` | integer | yes | When the money moved. Epoch milliseconds. |
| `expires_at` | integer or null | no | End of the period. Epoch milliseconds, or null. |
| `revenue_in_usd` | number | yes | USD; negative for refunds. |
| `price` | Price or null | no |  |
| `country` | string or null | no |  |

### V2Error

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"error"` | yes |  |
| `type` | `parameter_error`, `resource_already_exists`, `resource_missing`, `idempotency_error`, `rate_limit_error`, `authentication_error`, `authorization_error`, `store_error`, `server_error`, `resource_locked_error`, `unprocessable_entity_error`, `invalid_request`, `entity_references_archived_entities` | yes |  |
| `message` | string | yes | What went wrong. |
| `param` | string | no | The request field at fault, when there is one. |
| `doc_url` | string | yes | Link to the error's section of the errors page. |
| `retryable` | boolean | yes | True when retrying the same request can succeed. |

### WebhookDelivery

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"webhook_delivery"` | yes |  |
| `id` | string | yes |  |
| `webhook_integration_id` | string | yes |  |
| `event_id` | string | yes |  |
| `event_type` | string | yes |  |
| `status` | `pending`, `delivered`, `failed` | yes |  |
| `attempts` | integer | yes |  |
| `next_attempt_at` | integer or null | no | Next retry, when pending. Epoch milliseconds, or null. |
| `response_status` | integer or null | no | HTTP status of the last attempt. |
| `response_ms` | integer or null | no | Duration of the last attempt. |
| `last_error` | string or null | no |  |
| `created_at` | integer | no | Queued at. Epoch milliseconds. |

### WebhookState

| Field | Type | Required | Description |
|---|---|---|---|
| `object` | `"webhook_state"` | yes |  |
| `id` | string | yes | Webhook id (wh_...). |
| `enabled` | boolean | yes | False while deliveries are paused. |

## Related

- [API overview](https://revenuedot.app/docs/api.md)
- [Authentication](https://revenuedot.app/docs/api/authentication.md)
- [Errors](https://revenuedot.app/docs/api/errors.md)
- [OpenAPI document](https://revenuedot.app/docs/api/openapi.yaml)
