---
title: "Create a project"
method: POST
path: "/v1/projects"
tags: ["Projects"]
---

# Create a project

`POST /v1/projects`

## Request

- Base URL: `https://api.subscriby.net`
- URL: `https://api.subscriby.net/v1/projects`
- Auth: HTTP bearer

```bash
curl -X POST https://api.subscriby.net/v1/projects \
  -H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Research Premium",
    "description": "Weekly deep-dive research notes.",
    "handle": "research-premium",
    "metrics": true
  }'
```

Banner and photo are optional image uploads. Send `multipart/form-data` when you want the API to accept the raw image:

```bash
curl -X POST https://api.subscriby.net/v1/projects \
  -H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "name=Research Premium" \
  -F "description=Weekly deep-dive research notes." \
  -F "banner=@./banner.jpg" \
  -F "photo=@./avatar.jpg"
```

Answers `201` with the project and emits `project.created`. Creating a project enforces the creator's tier project limit; exceeding it is `403 TEAM_TIER_REQUIRED` with a `required_capability` hint in `error.context`.

### Validation rules

Enforced on both create and update (every field is optional on update):

- `name`: required on create. String, 5–255 characters.
- `description`: optional. String, max 1,000 characters. HTML is filtered to a safe subset before storage.
- `terms`, `privacy`: optional. URLs, max 255 characters each.
- `banner`, `photo`: optional. Image uploads, max **5 MB** each, sent as `multipart/form-data`.
- `handle`: optional. Lowercase alpha-dash ASCII, 5–255 characters, unique. Requires a Subscriby tier that includes the custom-handle capability; Free-tier tokens receive `TEAM_TIER_REQUIRED` when a handle is supplied.
- `metrics`, `active`: optional booleans.
- `outage_compensations`: optional boolean, `true` on every new project. **Outage Compensation**: when a platform refuses the project's connector for an hour or more and later answers again, every member whose paid access overlapped the gap gets the lost time added to the end of their access automatically, so nobody pays for days they could not use. Set it to `false` when the creator would rather settle outages themselves, for example with refunds or coupons. See [Connector outages](https://docs.subscriby.net/disaster-recovery/connector-outages).
- `team_id`: optional UUID; must be a team the token's owner belongs to. Defaults to the token's scoped team.

> **Artwork is processed after the request.** The response body returns the project's metadata only, not the hosted image URLs. Image processing (resize, re-encode, upload to storage) runs on the server after a successful create or update; clients that need the hosted URL should fetch the project once upload is complete.

## Headers

- `Idempotency-Key` string, uuid, required

## Response `201`

The new project.

- object
  - `data` ProjectResource, required: A project: the top-level container for a membership business. Every plan, subscriber, payment method and resource lives under one.
    - `id` string, required: The project's id, the value every project-scoped endpoint takes in its path.
    - `team_id` string, nullable, required: The team the project belongs to.
    - `user_id` string, required: The creator who owns the project.
    - `name` string, required: The project's name, 5 to 255 characters.
    - `handle` string, nullable, required: The project's handle: lowercase letters, digits and dashes, unique across Subscriby, the last segment of the portal URL. Null until one is chosen; choosing one needs a tier with the custom-handle capability.
    - `description` string, nullable, required: What the project offers, up to 1,000 characters of HTML filtered to a safe subset; null when none.
    - `terms` string, nullable, required: The URL of the terms members are asked to accept; null when none.
    - `privacy` string, nullable, required: The URL of the privacy policy members are asked to accept; null when none.
    - `banner_url` string, nullable, required: The hosted banner artwork; null until uploaded and processed.
    - `photo_url` string, nullable, required: The hosted photo artwork; null until uploaded and processed.
    - `metrics` boolean, required: The **Enable Public Metrics** switch: whether the portal shows the project's member counts.
    - `outage_compensations` boolean, required: The **Outage Compensation** switch: when a platform refuses the project's connector for an hour or more and later answers again, every member whose paid access overlapped the gap gets the lost time added to the end of their access automatically. True on every new project.
    - `active` boolean, required: Whether the project is live. Archiving flips it to false and keeps everything; restoring flips it back.
    - `created_at` string, nullable, required: When the project was created, ISO 8601.
    - `updated_at` string, nullable, required: When the project last changed, ISO 8601.

## Other responses

- `400`: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- `401`: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- `403`: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description. On this endpoint: `TEAM_TIER_REQUIRED`: when the creator's tier project limit is reached, or a `handle` is sent without the custom-handle capability; `error.context.required_capability` names it.
- `409`: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- `422`: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: with per-field messages under `error.fields`.
- `425`: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- `429`: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.

---

[API](https://skmtc.dev/memberpass/apis/subscriby-rest-api.md) · [All operations](https://skmtc.dev/memberpass/apis/subscriby-rest-api/llms.txt) · [OpenAPI document](https://skmtc.dev/memberpass/apis/subscriby-rest-api/revisions/59c4df91d764?raw)
