# Create a product in Stripe and link it

`POST /v1/admin/plans/products/stripe`

URL: https://docs.spacerun.co/docs/platform/reference/plans-and-pricing/createPlanStripeProduct

- **Operation ID:** `createPlanStripeProduct`
- **Tags:** Plans and pricing
- **Base URL:** `http://localhost:8787`

Creates the Stripe product, then upserts the local product mapped to it. Creates or changes the object in Stripe first (source of truth), then audits the change. If the local write fails the Stripe product remains unmapped and should be archived; retries create another product. Audited as `plans.product.stripe_create`.

**Workflow:** [Integration guide](https://docs.spacerun.co/docs/platform/guides/billing-checkout).

**Authentication:** staff Bearer JWT with permission `plans.write`.

## Authentication

- **Auth:** staff Bearer JWT (`Authorization: Bearer <access_token>`) with the permissions below.
- **Required permissions:** `plans.write`

## Request body

Required: yes.

Content type: `application/json`

Schema: `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | `string` | yes | Pattern: `^[a-z][a-z0-9_-]{0,63}$` |
| `name` | `string` | yes | Min length: 1. Max length: 200 |
| `description` | `string` | no | Max length: 1000 |
| `type` | `string` | yes | One of: `"subscription"`, `"credit_pack"` |
| `tierKey` | `string` | no | Default: `"none"` |
| `creditsIncluded` | `integer` | no | Default: `0`. Min: 0 |
| `features` | `string[]` | no | Default: `[]` |
| `sortOrder` | `integer` | no | Default: `0` |
| `isPublic` | `boolean` | no | Default: `false` |
| `highlight` | `boolean` | no | Default: `false` |
| `badge` | `string` | no | - |
| `isActive` | `boolean` | no | Default: `true` |
| `metadata` | `object` | no | Default: `{}` |
| `images` | `string<uri>[]` | no | Default: `[]`. Max items: 8 |

## Responses

### 201

Product created.

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string<uuid>` | yes | - |
| `stripeProductId` | `string` | yes | - |

## Errors

### 400

Request validation failed.

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object (ValidationErrorBody)`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `success` | `boolean` | yes | One of: `false` |
| `error` | `object` | yes | - |
| `error.name` | `string` | yes | One of: `"ZodError"` |
| `error.issues` | `object[]` | yes | - |
| `error.issues[].code` | `string` | yes | - |
| `error.issues[].path` | `(string \| number)[]` | yes | - |
| `error.issues[].message` | `string` | yes | - |

**Request validation**

```json
{
  "success": false,
  "error": {
    "name": "ZodError",
    "issues": [
      {
        "code": "custom",
        "path": [
          "field"
        ],
        "message": "Invalid value"
      }
    ]
  }
}
```

### 401

Missing, malformed, expired or wrong-audience Bearer token (`missing_token`, `invalid_token`, `invalid_audience`).

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object (ErrorBody)`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | `string` | yes | Stable machine-readable error code, e.g. `forbidden`, `not_found`. |
| `details` | `any` | no | Optional structured context. For 403 it is `{ perm }`. |

**Error response**

```json
{
  "error": "missing_token"
}
```

### 403

Authenticated but lacking the required permission. `details.perm` lists the accepted permission keys.

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object (ErrorBody)`, fields as described above.

**Error response**

```json
{
  "error": "forbidden",
  "details": {
    "perm": "plans.write"
  }
}
```

### 500

Unexpected server error (`internal`). No details are returned.

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object (ErrorBody)`, fields as described above.

**Error response**

```json
{
  "error": "internal"
}
```

## Examples

### curl

```bash
curl -X POST 'http://localhost:8787/v1/admin/plans/products/stripe' \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"key":"example","name":"example","description":"example","type":"subscription","tierKey":"none","creditsIncluded":0,"features":[],"sortOrder":0,"isPublic":false,"highlight":false,"badge":"example","isActive":true,"metadata":{},"images":[]}'
```

### TypeScript

```ts
const accessToken = process.env.ACCESS_TOKEN; // user JWT from your sign-in flow

const response = await fetch("http://localhost:8787/v1/admin/plans/products/stripe", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${accessToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "key": "example",
    "name": "example",
    "description": "example",
    "type": "subscription",
    "tierKey": "none",
    "creditsIncluded": 0,
    "features": [],
    "sortOrder": 0,
    "isPublic": false,
    "highlight": false,
    "badge": "example",
    "isActive": true,
    "metadata": {},
    "images": []
  }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
const data = response.status === 204 || response.status === 205
  ? undefined
  : response.headers.get("content-type")?.includes("json")
    ? await response.json()
    : await response.text();
```
