# Creation order

> What has to exist before each call, where each id comes from, the sequence of common flows and the errors that show up when a step is skipped.

Almost everything on Memberfy depends on something created earlier: a lesson needs a module, a module needs a course, a course needs a space. Calling the API out of order doesn't break anything, but every call made too early is refused. This guide shows the right order and the id that passes from one call to the next.

## The dependency map

<div class="flow">
<div class="flow-row"><span class="flow-label">Structure and content</span><span class="flow-node">Community</span><span class="flow-arrow">→</span><span class="flow-node">Section</span><span class="flow-arrow">→</span><span class="flow-node">Space (module)</span><span class="flow-arrow">→</span><span class="flow-node">Post · Event · Image</span></div>
<div class="flow-row"><span class="flow-label">Courses</span><span class="flow-node">Courses space</span><span class="flow-arrow">→</span><span class="flow-node">Course</span><span class="flow-arrow">→</span><span class="flow-node">Module</span><span class="flow-arrow">→</span><span class="flow-node">Lesson</span></div>
<div class="flow-row"><span class="flow-label">Sales</span><span class="flow-node">Payouts approved</span><span class="flow-arrow">→</span><span class="flow-node">Sell and withdraw</span></div>
<div class="flow-row"><span class="flow-label">Subscription</span><span class="flow-node">Billing option</span><span class="flow-arrow">→</span><span class="flow-node">Plan</span><span class="flow-arrow">→</span><span class="flow-node">Plan add-ons</span><span class="flow-arrow">→</span><span class="flow-node">Downsell</span></div>
<div class="flow-row"><span class="flow-label">Access</span><span class="flow-node">Plan or product</span><span class="flow-arrow">→</span><span class="flow-node">Space access</span></div>
<div class="flow-row"><span class="flow-label">Discount</span><span class="flow-node">Products</span><span class="flow-arrow">→</span><span class="flow-node">Coupon</span></div>
</div>

Put another way:

| To create… | You first need… | And you pass |
|---|---|---|
| A space | a section | `sectionId` |
| A post, event, course, image | a space | `spaceId` |
| A course module | a course | `courseId` |
| A lesson | a module | `moduleId` |
| A plan | the billing options (`SUBSCRIPTION` products) | `productIds` |
| An add-on on the plan | the plan, and a monthly product that isn't a plan option | `addOnProductIds` |
| Access to a space by plan or product | the plan or the product | `access.subscriptionGroupIds`, `access.productIds` |
| A coupon for specific products only | the products | `applicableProducts` |
| Any sale or payout | Business Information and payout account approved | — |

Every call below carries `Authorization` and `X-CommunityId`. See [Authentication](/api/autenticacao) and [X-CommunityId](/api/x-community-id).

## Course

1. **Section** (if there isn't one yet): [`POST /api/sections`](/api/referencia/sections-spaces/post-sections) with `title` and `visibility`. Keep `data.id` as the `sectionId`.
2. **Courses space**: [`POST /api/spaces`](/api/referencia/sections-spaces/post-spaces) with `sectionId`, `title` and `module: "courses"`. Keep the `spaceId`.
3. **Course**: [`POST /api/courses`](/api/referencia/courses/post-courses) with `communityId` (the same as in the header), `spaceId`, `title`, `slug` and `level` (`beginner`, `intermediate`, `advanced`). It starts as a draft. Keep the `courseId`.
4. **Modules**: [`POST /api/courses/module`](/api/referencia/courses/post-courses-module) with `courseId` and `title`, one per module. Keep each `moduleId`.
5. **Lessons**: [`POST /api/courses/lesson`](/api/referencia/courses/post-courses-lesson) with `moduleId`, `title`, `type` (`text`, `image`, `video`, `link`) and the content: `body` (formatted text) for a text lesson, `contentUrl` for video, link and image lessons. Video accepts only YouTube, Vimeo and Loom (another provider becomes `type: link`), and `duration` is `mm:ss` or `hh:mm:ss`.
6. **Publish**: [`PUT /api/courses/{id}`](/api/referencia/courses/put-courses-by-id) with `status: "published"`. Only a published course accepts enrollments.

**A single course.** In step 2, send `moduleConfig: { "mode": "single" }` and, with it, `course` (`title`, `level`, `description`): the space and the course are created in one transaction (nothing is left created if the course fails), and the course comes back in `data.course`, as a draft. In a `single` space that already has a course, step 3 returns `409` on the `spaceId` field (drafts and archived courses count). Switching a space from list to `single` with 2 or more courses also returns `409`, on the `moduleConfig.mode` field; from `single` to list always goes through.

**Certificate on completion**. Step 3 and the `PUT` accept the `certificate` block (`enabled`, `templateId`, `text`); `templateId` must be an active template of the same community, found in [`GET /api/certificates/templates`](/api/referencia/certificates/get-certificates-templates) (without `spaceId`, for owner and admin). See [Courses](/espacos/cursos#certificate-on-completion).

**Batch order.** [`PUT /api/courses/{courseId}/modules/order`](/api/referencia/courses/put-courses-by-course-id-modules-order) and [`PUT /api/courses/module/{moduleId}/lessons/order`](/api/referencia/courses/put-courses-module-by-module-id-lessons-order) record the order of all modules (or of the lessons of a module) in one call. The list is the complete one; if an item is missing or repeated, `400` (`moduleIds`/`lessonIds`) and nothing changes.

**Students.** [`GET /api/courses/{id}/enrollments`](/api/referencia/courses/get-courses-by-id-enrollments), paginated, for owner, admin and moderator. `POST /api/courses/{id}/enrollments` (with `memberId`) enrolls someone else in a free course, for the same roles; on a paid course: `403`.

To adjust it later: [`PUT`](/api/referencia/courses/put-courses-module-by-module-id) and [`DELETE /api/courses/module/{moduleId}`](/api/referencia/courses/delete-courses-module-by-module-id) rename, reorder and delete a module (along with its lessons); [`PUT`](/api/referencia/courses/put-courses-lesson-by-lesson-id) and [`DELETE /api/courses/lesson/{lessonId}`](/api/referencia/courses/delete-courses-lesson-by-lesson-id) change a lesson's title, type, duration and order, move it to another module of the same course (`moduleId`) and delete it. Deleting keeps the progress of whoever already watched it.

On the student side: [`POST /api/courses/{courseId}/enroll`](/api/referencia/courses/post-courses-by-course-id-enroll) enrolls the caller (`201` the first time; afterwards **`200` with the enrollment that already exists**, with no extra points) and [`POST /api/courses/{courseId}/lesson/{lessonId}/progress`](/api/referencia/courses/post-courses-by-course-id-lesson-by-lesson-id-progress) with `completed: true` completes the lesson (`completed: false` only records the access and never un-completes). The course `GET` returns each lesson's `body` and `contentUrl` only to owner, admin, SuperAdmin or an enrolled student; everyone else gets the outline, with those keys absent.

To sell the course, continue with a product that opens the space (see [Event](#event), steps 4 and 5, which work the same way).

## Mentoring

A cohort with its own board, sessions and monthly billing. (With [contract length](/monetizacao/duracao-do-contrato), the option in step 3 gains `commitmentMonths`.)

1. **The cohort's private space**: [`POST /api/spaces`](/api/referencia/sections-spaces/post-spaces) with `module: "feed"` and `visibility: "private"`. Keep the `spaceId`.
2. **Sessions**: [`POST /api/events`](/api/referencia/events/post-events), one per session, with `spaceId`, `title`, `slug`, `type: "online"`, `startTime`, `endTime` and `publish: true` (without it, the session stays a draft).
3. **Billing option**: [`POST /api/products`](/api/referencia/products/post-communities-by-community-id-products) with `type: "SUBSCRIPTION"`, `title`, `price`, `billingInterval: "MONTHLY"` and `allowedPaymentMethods`. Keep the product's `id`.
4. **Plan**: [`POST /api/subscription-groups`](/api/referencia/subscriptions/post-communities-by-community-id-subscription-groups) with `name` and `productIds: [<id from step 3>]`. Keep the plan's `id`.
5. **Open the space to the plan**: [`PUT /api/spaces/{id}`](/api/referencia/sections-spaces/put-spaces-by-id) with `access: { "subscriptionGroupIds": [<plan id>] }`.

Step 5 only works after step 4: the plan has to exist before access can refer to it.

## Event

An in-person event with a paid ticket and an announcement in the Feed.

1. **Events space**: [`POST /api/spaces`](/api/referencia/sections-spaces/post-spaces) with `module: "events"`.
2. **Event**: [`POST /api/events`](/api/referencia/events/post-events) with `spaceId`, `title`, `type: "in_person"`, `startTime`, `endTime` and the address (`street`, `number`, `city`…). The event is created as a **draft**: only the team sees it, and nothing is announced. Adjust whatever is missing with `PATCH /api/events/{id}` (send the `updatedAt` of the last answer).
3. **Publish**: `POST /api/events/{id}/publish` with `notifyByEmail` (`true` e-mails whoever can see the event). If something is missing, `422` with the fields in `errors[]`. Publishing puts the draft ticket types on sale; a paid type only with approved Business Information. To create and publish in a single call, as before, send `publish: true` in step 2 (then `slug` is required too).
4. **Ticket**: [`POST /api/products`](/api/referencia/products/post-communities-by-community-id-products) with `type: "ONE_TIME"`, `price`, `hasStock: true` and `stockQuantity`. Then [publish it](/api/referencia/products/post-communities-by-community-id-products-by-id-publish).
5. **Open the space to buyers**: [`PUT /api/spaces/{id}`](/api/referencia/sections-spaces/put-spaces-by-id) with `visibility: "private"` and `access: { "productIds": [<ticket id>] }`.
6. **Pinned announcement**: [`POST /api/content`](/api/referencia/content/post-content) in the Feed space, then [`PUT /api/feed/{type}/{id}/pin`](/api/referencia/feed/put-feed-by-type-by-id-pin) with the post's id.

To register someone for a free event on behalf of the team: `POST /api/events/{id}/registrations` (with `memberId`; owner, admin and moderator), with the rules of the person's own registration; on an event that charges them: `403`. To notify up to 100 people in the bell: `POST /api/notifications/send` (`memberIds`, `title`, `body`; same roles).

For the event to issue a certificate, send the `certificate` block with `enabled: true` in step 2 (or later, in a `PATCH`); `endTime` is required and must be after `startTime`. Without `templateId`, the **Platform default** applies. See [Attendance certificate](/espacos/eventos#attendance-certificate).

## Plan with add-ons

1. **The plan's billing options**: one [`POST .../products`](/api/referencia/products/post-communities-by-community-id-products) per option (Monthly, Annual), `type: "SUBSCRIPTION"`.
2. **The add-on**: another `POST .../products`, `type: "SUBSCRIPTION"`, `billingInterval: "MONTHLY"`. It does **not** go into any plan's `productIds`.
3. **Plan**: [`POST .../subscription-groups`](/api/referencia/subscriptions/post-communities-by-community-id-subscription-groups) with the `productIds` from step 1.
4. **Add-ons on the plan**: [`PUT .../subscription-groups/{id}`](/api/referencia/subscriptions/put-communities-by-community-id-subscription-groups-by-id) with `addOnProductIds: [<id from step 2>]`.

## Before selling: payouts

An order that applies to any sale:

1. [`POST .../business-information`](/api/referencia/business/post-communities-by-community-id-business-information) and [`.../submit`](/api/referencia/business/post-communities-by-community-id-business-information-submit).
2. [`POST .../payout-settings`](/api/referencia/payouts/post-communities-by-community-id-payout-settings) and [`.../submit`](/api/referencia/payouts/post-communities-by-community-id-payout-settings-submit).
3. Wait for both approvals (up to 7 business days). [`GET .../payout-settings/prerequisites`](/api/referencia/payouts/get-communities-by-community-id-payout-settings-prerequisites) tells you what's missing.
4. Turn on the community's sales: `PATCH /api/communities/{id}/settings` with `payoutEnabled: true`. The route checks the approved business and payout details and the active payout account, and refuses with `400` on the field for whatever is missing. The sign-up model (`signupModel`) also changes only here.

`PUT /api/communities/{id}` stops accepting `payoutEnabled` and `signupModel`. Sending them returns `400` with `param` on the field itself (*"This field is changed in the community settings (PATCH /api/communities/{id}/settings)."*) and nothing is saved. In the same `PUT`, `signupEnabled`, `guestAccess`, `contentModeration`, `globalRanking` and `active` must be `true` or `false`; any other type returns `400`. Anyone who turns on sales or changes the sign-up model through the `PUT` moves to `PATCH .../settings`.

Products, billing options, plans, add-ons, coupons and downsell offers can be created and edited before approval: the product starts as `DRAFT`, in the currency of the Business Information's country (in any status), or `BRL` without it. Publishing ([`POST .../products/{id}/publish`](/api/referencia/products/post-communities-by-community-id-products-by-id-publish), or `PUT .../products/{id}` with `status: ACTIVE`), checkout and withdrawals wait for approval. An approved Business Information that gets edited goes back to `PENDING` and needs `.../submit` again; until it's approved again, checkout refuses.

## Custom domain

The community's address no longer changes through `PUT /api/communities/{id}`. The `domain` field is now the **primary address**, written only by Memberfy: the active custom domain or, without one, the Memberfy subdomain. The `PUT` accepts a `domain` equal to the current value (ignoring case and spaces) and ignores it, because a form usually sends everything back; a different value returns `400` with `param: "domain"` (*The community address can't be changed here. Use the custom domain settings.*) and nothing is saved. In `POST /api/communities`, `domain` is ignored.

The custom domain has its own routes, for the owner and the admin of the `X-CommunityId` community (moderator, finance and member get `403`). See [Custom domain](/configuracoes/dominio-proprio).

| Route | What it does |
|---|---|
| `GET /api/communities/custom-domain` | The address: `subdomain` (Memberfy's, which never changes here), `primaryDomain` (the one for links and emails), `entitled` (the plan includes the feature), `active` and `pending` |
| `PUT /api/communities/custom-domain` | With `{ "hostname": "community.papodesysadmin.org" }` (up to 64 characters, no `https://`), adds the domain or replaces the current one. Returns the request in `pending`, already with the DNS record to create |
| `POST /api/communities/custom-domain/check` | The **Check now**: checks DNS, certificate and HTTPS right away. A `FAILED` request starts over, with another 7 days |
| `DELETE /api/communities/custom-domain` | `?scope=all` (default) removes the active and the pending one; the community stays on the subdomain, which right away becomes the primary again. `?scope=pending` only gives up the request in progress |

The order:

1. `PUT .../custom-domain` with the `hostname`.
2. Create the `pending.record` record in DNS: `type` (`CNAME`, or `ALIAS` on `@` for a root domain), `name` and `target`. The `target` is unique to each domain: use the one the response returns.
3. Follow `pending.status` with the `GET`: `PENDING_DNS` → `VERIFYING` → `CERT_ISSUED` → `ACTIVE` (or `FAILED`). Each step has its date (`requestedAt`, `checkedAt`, `dnsVerifiedAt`, `certIssuedAt`, `activeAt`, `failedAt`), and `expiresAt` is the 7-day deadline. Checks run on their own (every minute for the first 10, then every 5); `POST .../check` speeds it up.
4. At `ACTIVE`, the domain moves to `active` and becomes the `primaryDomain`. Before that, it doesn't open the community.

With an active domain, a `PUT` with another `hostname` is a **change without downtime**: the old one stays in `active` until the new one becomes active, and only then is it removed. Sending the active one's `hostname` gives up the change; sending another one in place of a pending one replaces it.

**The check error** comes in `pending.error`, with a `code` and the translated message (show it as it comes):

| `code` | When |
|---|---|
| `dnsNotFound` | The name doesn't exist in DNS yet |
| `dnsWrongTarget` | The name points to another destination |
| `dnsARecord` | The name is an `A` record, not a `CNAME` |
| `dnsProxied` | The DNS provider's proxy is on |
| `dnsApex` | Root domain without `ALIAS`/`ANAME` |
| `caaBlocked` | A CAA record doesn't authorize the certificate to be issued |
| `acmRateLimited` | Certificate issuing limit for now; it retries on its own |
| `acmFailing` | The certificate couldn't be issued |
| `httpsNotReady` | The certificate is out; the address starts answering in minutes |
| `providerError` | The check couldn't run now; it retries on its own |
| `expired` | 7 days without becoming active: the request expired and the name was released |

**The `PUT` refusals** (with `param: "hostname"`):

| Response | When |
|---|---|
| `400` · *Enter a valid domain, like community.yourdomain.com (up to 64 characters).* | Invalid format |
| `400` · *Use a public domain: IP addresses, localhost and local network names do not work.* | IP, `localhost` or local name |
| `400` · *That address belongs to Memberfy. Use a domain of your own.* | `memberfy.net`, `memberfy.dev` or another platform domain |
| `403` · *The community's plan does not include a custom domain.* | The plan doesn't include the feature |
| `409` · *That domain is already used (or requested) by another community.* | Another community has or requested the domain |
| `409` · *That domain is already in use on another service. Remove it there or use another name.* | The domain is on another hosting service |
| `409` · *That domain was used by another community before. To bring it here, contact support.* | Another community used the domain before; only Memberfy support moves it on |
| `409` · *The community has no Memberfy address yet. Contact support.* | The community doesn't have the subdomain yet |
| `502` · *We could not add the domain right now. Try again in a few minutes.* | The hosting didn't respond; nothing was saved |

`DELETE` and `POST .../check` with no domain to remove or check return `404` (*The community has no custom domain being set up.*). The Memberfy team has the same routes for any community, at `/api/admin/communities/{communityId}/custom-domain`, which, like every `/api/admin` route, stay out of the public reference.

## SMS and WhatsApp

For a notice to reach the phone, this is the order. See [SMS and WhatsApp notices](/notificacoes/avisos-por-sms-e-whatsapp).

1. **Turn the channel on** (owner or admin): `PUT /api/sms/settings` or `PUT /api/whatsapp/settings` with `{ "enabled": true, "dailyCap": 500 }`. Without `dailyCap` (from 1 to 50,000), `422` with `param: "dailyCap"`. SMS comes from Growth up and WhatsApp on Business and Scale (`availableOnPlan` on read). On SMS, `personDailyCap` (1 to 20, 5 by default) limits workflow messages per person per day; on WhatsApp, `moments` turns platform messages off for the community.
2. **The person's consent:** `smsConsent` on `POST /api/auth/register` (`{ "service": true, "marketing": false, "whatsapp": { "service": true, "marketing": false } }`, with `phone` and `phoneCountry`) or on `POST /api/auth/login`; or the import's `smsConsent` (or `whatsappConsent`) column, with the attestation, for new people and for existing members; or `POST /api/contact-consent/attestations` (owner and admin), with `attestation: true`, for consent the community declares it collected from existing members (the phone provided can receive SMS and WhatsApp right away, without being verified; the response has `sendable` per member); or `PUT /api/members/me/notice-preferences` with `sms.enabled: true` (or `whatsapp.enabled: true`) and `sms.textVersion` (or `whatsapp.textVersion`) set to the version of `sms.all` (or `whatsapp.all`): one switch per channel, recording notices and news together; `enabled: false` withdraws both. The texts the person sees come from `GET /api/contact-consent/texts`.
3. **Verify the phone** (the person): `POST /api/members/me/phone-verification` (with `phone` or the account's phone) and `POST /api/members/me/phone-verification/confirm` with the `code`. The code goes on WhatsApp when the community has WhatsApp on, and by SMS when it doesn't; `data.sentVia` tells the channel (`whatsapp` or `sms`). With neither on, `409` `sms.otpNoChannel`.
4. **Test:** `POST /api/sms/test` or `POST /api/whatsapp/test` sends to your phone and answers `202` with `queued` (and when it goes out) or `skipped` and the reason.
5. **Use:** the `sendSms` and `sendWhatsapp` blocks of [workflows](/notificacoes/workflows#send-sms-and-send-whatsapp) (`sendWhatsapp` with `template`, `variables` and `link`: always an approved template) and, on WhatsApp, the platform messages.

| Route | What it does |
|---|---|
| `GET` and `PUT /api/sms/settings`, `GET` and `PUT /api/whatsapp/settings` | The channel: `enabled`, `dailyCap`, `personDailyCap`, `moments` (WhatsApp), `blockedCountries`, `availableOnPlan`, the plan's quota (`quota`, with the annual carry-over) and today's and this month's usage (owner and admin) |
| `POST /api/sms/test`, `POST /api/whatsapp/test` | A test notice to your own phone, under every rule (owner and admin) |
| `GET /api/whatsapp/templates` | Memberfy's WhatsApp templates and each one's approval (owner and admin) |
| `POST /api/sms/segments` | The parts and encoding of a text, alone and as sent (the team); sends nothing |
| `POST /api/members/me/phone-verification` and `.../confirm` | The 6-digit code and the confirmation (any role, own account) |
| `GET` and `PUT /api/members/me/notice-preferences` | Notices per channel: `channels.email`, `channels.sms`, `channels.whatsapp`, with `whatsapp.moments` and `whatsapp.marketing` (any role, own) |
| `GET /api/members/me/contact-consents` | Your own consents |
| `GET /api/members/{memberId}/contact-consents` | A member's consents, without the IP (owner and admin) |
| `POST /api/contact-consent/attestations` | Records the SMS or WhatsApp consent the community declares it collected, for up to 200 members, with each one's `status` (owner and admin) |
| `GET /api/contact-consent/texts` | The sign-up and login consent texts, current version |
| `GET` and `POST /s/{token}`, `POST /s/{token}/undo` | The SMS opt-out link: the page, the one-click opt-out from this community and the undo (no sign-in) |

## The errors you get when you skip a step

| Call | What was missing | Response |
|---|---|---|
| `POST /api/spaces` | the section | `400` · *Seção não encontrada ou não pertence a esta comunidade.* (section not found or not in this community) |
| `POST /api/spaces` (or `PUT`) | the section is more restricted | `400` · *This space cannot be more open than the section "…", which is …* |
| `PUT /api/spaces/{id}` with `access` | the plan, product or group referred to | `400` · *The access grant "…" does not exist in this community.* (`param: access`) |
| `POST /api/courses` | the space | `400` · *ID do espaço é obrigatório.* (space ID is required) or *Espaço não encontrado ou não pertence a esta comunidade.* (space not found or not in this community) |
| `POST /api/courses/module` | the course | `404` · *Curso não encontrado.* (course not found) |
| `POST /api/courses/lesson` | the module | `404` · *Module not found* |
| `POST` or `PUT /api/courses/lesson` with content | the type and the content match | `400` · *Enter the text of the lesson* (`param: body`), *Enter the address (URL) for this lesson* or *Enter a valid http or https address (URL)* (`param: contentUrl`), *Videos can only come from YouTube, Vimeo or Loom. For another provider, use a link lesson* (`param: contentUrl`), *Use the format mm:ss or hh:mm:ss (for example 05:30)* (`param: duration`) |
| `POST .../subscription-groups` | the billing options | `400` · *Product not found* |
| `PUT .../subscription-groups/{id}` with `addOnProductIds` | the add-on's product, or it's already a plan option | `400` · *One of the add-ons does not exist in this community or was deleted.* / *A product that is a plan's billing option cannot be another plan's add-on.* |
| Checkout configuration | approved payouts | `400` · *Payment configuration has not been set up for this community* |
| `POST .../products/{id}/publish` (or `PUT` with `status: ACTIVE`) | approved Business Information | `403` · *To publish and start selling, the community needs approved business information…* |
| `POST .../products/{id}/publish` | the product in the approved country's currency | `400` · *This product is priced in …, but the approved business information uses …* (`param: currency`) |
| `POST /api/checkout` | approved Business Information | `403` · *Community must have approved business information to enable paid products* |
| `PUT /api/communities/{id}` with `payoutEnabled` or `signupModel` | use the settings route | `400` · *This field is changed in the community settings (PATCH /api/communities/{id}/settings).* (`param` on the field) |
| `PUT /api/communities/{id}` with another `domain` | use the [custom domain](#custom-domain) | `400` · *The community address can't be changed here. Use the custom domain settings.* (`param: "domain"`) |
| `POST .../payouts` | balance for the amount plus the fee | `400` · *Insufficient balance. Available: …* |
| Any call | the header | `400` · *X-CommunityId é obrigatório* (X-CommunityId is required) |

Some format validations still answer in English regardless of language (like *Valid course ID is required* when the id isn't a UUID).

## Tips

- **Keep every id** that comes back in `data.id`: it's what the next call asks for.
- **Send the same `communityId`** in the body (when the route asks for it) and in the header.
- **Repeating doesn't undo**: if a sequence stops halfway, continue from the step that failed instead of starting over (starting over creates duplicates).
- In the [MCP](/mcp/ferramentas), the composite tools will follow this order on their own.
