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
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 and X-CommunityId.
Course
- Section (if there isn't one yet):
POST /api/sectionswithtitleandvisibility. Keepdata.idas thesectionId. - Courses space:
POST /api/spaceswithsectionId,titleandmodule: "courses". Keep thespaceId. - Course:
POST /api/courseswithcommunityId(the same as in the header),spaceId,title,slugandlevel(beginner,intermediate,advanced). It starts as a draft. Keep thecourseId. - Modules:
POST /api/courses/modulewithcourseIdandtitle, one per module. Keep eachmoduleId. - Lessons:
POST /api/courses/lessonwithmoduleId,title,type(text,image,video,link) and the content:body(formatted text) for a text lesson,contentUrlfor video, link and image lessons. Video accepts only YouTube, Vimeo and Loom (another provider becomestype: link), anddurationismm:ssorhh:mm:ss. - Publish:
PUT /api/courses/{id}withstatus: "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 (without spaceId, for owner and admin). See Courses.
Batch order. PUT /api/courses/{courseId}/modules/order and PUT /api/courses/module/{moduleId}/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, 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 and DELETE /api/courses/module/{moduleId} rename, reorder and delete a module (along with its lessons); PUT and DELETE /api/courses/lesson/{lessonId} 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 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 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, steps 4 and 5, which work the same way).
Mentoring
A cohort with its own board, sessions and monthly billing. (With contract length, the option in step 3 gains commitmentMonths.)
- The cohort's private space:
POST /api/spaceswithmodule: "feed"andvisibility: "private". Keep thespaceId. - Sessions:
POST /api/events, one per session, withspaceId,title,slug,type: "online",startTime,endTimeandpublish: true(without it, the session stays a draft). - Billing option:
POST /api/productswithtype: "SUBSCRIPTION",title,price,billingInterval: "MONTHLY"andallowedPaymentMethods. Keep the product'sid. - Plan:
POST /api/subscription-groupswithnameandproductIds: [<id from step 3>]. Keep the plan'sid. - Open the space to the plan:
PUT /api/spaces/{id}withaccess: { "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.
- Events space:
POST /api/spaceswithmodule: "events". - Event:
POST /api/eventswithspaceId,title,type: "in_person",startTime,endTimeand 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 withPATCH /api/events/{id}(send theupdatedAtof the last answer). - Publish:
POST /api/events/{id}/publishwithnotifyByEmail(truee-mails whoever can see the event). If something is missing,422with the fields inerrors[]. 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, sendpublish: truein step 2 (thenslugis required too). - Ticket:
POST /api/productswithtype: "ONE_TIME",price,hasStock: trueandstockQuantity. Then publish it. - Open the space to buyers:
PUT /api/spaces/{id}withvisibility: "private"andaccess: { "productIds": [<ticket id>] }. - Pinned announcement:
POST /api/contentin the Feed space, thenPUT /api/feed/{type}/{id}/pinwith 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.
Plan with add-ons
- The plan's billing options: one
POST .../productsper option (Monthly, Annual),type: "SUBSCRIPTION". - The add-on: another
POST .../products,type: "SUBSCRIPTION",billingInterval: "MONTHLY". It does not go into any plan'sproductIds. - Plan:
POST .../subscription-groupswith theproductIdsfrom step 1. - Add-ons on the plan:
PUT .../subscription-groups/{id}withaddOnProductIds: [<id from step 2>].
Before selling: payouts
An order that applies to any sale:
POST .../business-informationand.../submit.POST .../payout-settingsand.../submit.- Wait for both approvals (up to 7 business days).
GET .../payout-settings/prerequisitestells you what's missing. - Turn on the community's sales:
PATCH /api/communities/{id}/settingswithpayoutEnabled: true. The route checks the approved business and payout details and the active payout account, and refuses with400on 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, 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.
| 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:
PUT .../custom-domainwith thehostname.- Create the
pending.recordrecord in DNS:type(CNAME, orALIASon@for a root domain),nameandtarget. Thetargetis unique to each domain: use the one the response returns. - Follow
pending.statuswith theGET:PENDING_DNS→VERIFYING→CERT_ISSUED→ACTIVE(orFAILED). Each step has its date (requestedAt,checkedAt,dnsVerifiedAt,certIssuedAt,activeAt,failedAt), andexpiresAtis the 7-day deadline. Checks run on their own (every minute for the first 10, then every 5);POST .../checkspeeds it up. - At
ACTIVE, the domain moves toactiveand becomes theprimaryDomain. 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.
- Turn the channel on (owner or admin):
PUT /api/sms/settingsorPUT /api/whatsapp/settingswith{ "enabled": true, "dailyCap": 500 }. WithoutdailyCap(from 1 to 50,000),422withparam: "dailyCap". SMS comes from Growth up and WhatsApp on Business and Scale (availableOnPlanon read). On SMS,personDailyCap(1 to 20, 5 by default) limits workflow messages per person per day; on WhatsApp,momentsturns platform messages off for the community. - The person's consent:
smsConsentonPOST /api/auth/register({ "service": true, "marketing": false, "whatsapp": { "service": true, "marketing": false } }, withphoneandphoneCountry) or onPOST /api/auth/login; or the import'ssmsConsent(orwhatsappConsent) column, with the attestation, for new people and for existing members; orPOST /api/contact-consent/attestations(owner and admin), withattestation: 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 hassendableper member); orPUT /api/members/me/notice-preferenceswithsms.enabled: true(orwhatsapp.enabled: true) andsms.textVersion(orwhatsapp.textVersion) set to the version ofsms.all(orwhatsapp.all): one switch per channel, recording notices and news together;enabled: falsewithdraws both. The texts the person sees come fromGET /api/contact-consent/texts. - Verify the phone (the person):
POST /api/members/me/phone-verification(withphoneor the account's phone) andPOST /api/members/me/phone-verification/confirmwith thecode. The code goes on WhatsApp when the community has WhatsApp on, and by SMS when it doesn't;data.sentViatells the channel (whatsapporsms). With neither on,409sms.otpNoChannel. - Test:
POST /api/sms/testorPOST /api/whatsapp/testsends to your phone and answers202withqueued(and when it goes out) orskippedand the reason. - Use: the
sendSmsandsendWhatsappblocks of workflows (sendWhatsappwithtemplate,variablesandlink: 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 | 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
communityIdin 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, the composite tools will follow this order on their own.