# Responses and errors

> The envelope of every response, the errors array that always comes back, the HTTP status codes and what to do with each, validation and business messages, payout and receiving refusals, refund refusals, API key errors (with the stable code), the language of messages and deleted items.

Every API response comes in the same envelope. That lets a single handler serve every call.

## Success

```json
{
  "success": true,
  "message": "Login successful.",
  "data": { },
  "pagination": { "page": 1, "limit": 20, "total": 137, "totalPages": 7 }
}
```

`pagination` only appears in lists. See [Pagination](/api/paginacao).

## Error

```json
{
  "success": false,
  "message": "Valid email is required",
  "errors": [{ "param": "email", "message": "Valid email is required" }]
}
```

- The `errors` array **always** comes back, even with a single error.
- `param` says which field caused the error, when there is one.
- `message` is the message to show.
- `code`, when present, is a stable identifier to handle in code (like the [API key errors](#api-key-errors)); the message changes with the language, the `code` doesn't.

This way, validation and business rules are handled the same way: show `errors[].message` next to the `param` field; without a `param`, show it at the top.

## The two kinds of error

| Kind | Example | How to recognize it |
|---|---|---|
| **Validation** | A field missing or in the wrong format | `400`, `param` = the field, a short message (*"Title is required"*) |
| **Business rule** | An action the current state doesn't allow | `400`, `403`, `404` or `409`, a message that explains (*"Insufficient balance…"*) |

## Status codes

| Code | Means | What to do |
|---|---|---|
| `200` / `201` | It worked (`201` when something is created) | — |
| `204` | It worked, no body (deletions) | — |
| `400` | Validation failed, or `X-CommunityId` is missing (`errors[0].param = "X-CommunityId"`) | Read `errors` and fix it |
| `401` | Token missing, invalid or expired; token from another community; token issued before a password change; API key invalid, revoked, expired or from another community | Sign in again, in the `X-CommunityId` community; with a key, check the key in **Integrations & APIs** |
| `402` | The payment was declined | Show the message to the buyer |
| `403` | No permission: insufficient role, the space restricts who can create, or the [API key](#api-key-errors) doesn't reach the action | Check the role, the `X-CommunityId` and the `code` |
| `404` | Doesn't exist, was deleted, belongs to another community, or the path is one of the old ones with `/communities/{communityId}/` | Check the id, the community and the [path](/api/x-community-id#the-paths-that-changed) |
| `409` | Conflict with the current state | Read the message |
| `422` | The payment provider declined the operation (e.g. a refund), with the reason in the message; or, with `errors[0].param = "altcha"`, the [security check](/api/autenticacao#security-check) for sign-in, sign-up or password recovery was missing, expired, already used or lighter than required | Show the reason; for `altcha`, ask for a new challenge and resend |
| `429` | Too many attempts (e.g. 10 invalid coupon codes in 15 minutes, more than 120 calls per minute with an API key, or too many sign-in, sign-up, password recovery and challenge attempts) | Wait; with an API key and on the `/api/auth` routes, the `Retry-After` header says how many seconds |
| `500` | Internal error | Try again; if it persists, write to support |
| `502` | A service the operation depends on didn't answer (e.g. the payout account balance, on a payout). Nothing was recorded | Try again in a few minutes |
| `503` | The security check is unavailable right now (`param = "altcha"`) | Try again in a moment |

## Real examples

| Situation | Response |
|---|---|
| Listing products without the `X-CommunityId` header | `400` · *Send the community in the X-CommunityId header.*, with `param: "X-CommunityId"` |
| Signing in without the `altcha` field | `422` · *Wait for the security check to finish and try again.*, with `param: "altcha"` |
| Creating a space in a section that doesn't exist | `400` · *Seção não encontrada ou não pertence a esta comunidade.* (section not found or not in this community) |
| Creating a course module in a course that doesn't exist | `404` · *Curso não encontrado.* (course not found) |
| Creating a video lesson with an address that is not YouTube, Vimeo or Loom | `400` · *Videos can only come from YouTube, Vimeo or Loom. For another provider, use a link lesson*, with `param: contentUrl` |
| Creating a second course in a space in A single course mode | `409` · *This space holds a single course and already has one. Edit the existing course or switch the space to "Course list".*, with `param: "spaceId"` |
| Switching a space with 2 or more courses to A single course | `409` · the message says how many courses there are and asks you to delete them until one is left, with `param: "moduleConfig.mode"` |
| Creating a course with an address (`slug`) that already exists | `400` · translated message, with `param: "slug"` |
| Turning on the course certificate with a template from another community | `404` · translated message, with `param: "certificate.templateId"` |
| Turning on the course certificate with an archived or deleted template | `400` · translated message, with `param: "certificate.templateId"` |
| Certificate text with HTML or over 5,000 characters | `400` · translated message, with `param: "certificate.text"` |
| Enrolling again in a course you are already in | `200` · *the enrollment that already exists*, with no extra points |
| A member posting in a "Staff" space | `403` · *Only staff members (admin, owner, moderator) can perform this action* |
| Opening a padlocked space (`GET /api/spaces/{id}` and the space's routes) | `403`, with `data: { reason, actions, lockReason, space, … }`. When `actions` has `buy` or `seePlans`, it also carries `data.offers: { plans, addOns, products }`, in the same shapes as the Store (`GET /api/storefront/spaces/{spaceId}/products`), only with what opens the space and checkout sells to the caller; never for a group-only space. See [Visibility and access](/conceitos/visibilidade-e-acesso#in-the-api) |
| An admin changing an owner | `403` · *Only an owner or a SuperAdmin can remove, demote or change an owner.* |
| Taking an add-on with subscribers off a plan | `409` · *This add-on cannot be taken off the plan…* |
| Creating a billing option (`POST /api/products`, `SUBSCRIPTION`) without a plan | `400` · *Every billing option must belong to a plan. Choose the plan.*, with `param: "subscriptionGroupIds"` |
| Deleting a plan that still has billing options | `400` · *The plan cannot be deleted: it still has these billing options: Growth Monthly, Growth Annual. Move or delete them first.*, with `param: "productIds"` |
| Buying an add-on as the main product (`POST /api/checkout`) | `400` · *This add-on can only be bought through its plan: together with the plan subscription or added to your current subscription.*, with `param: "productId"` |
| Buying a billing option that isn't in any plan | `400` · *This billing option is not in any plan and is not for sale.*, with `param: "productId"` |

Errors for product kinds, free products, seats, Downloads and hosted media:

| Situation | Response |
|---|---|
| Creating a product with `kind` `event`, `course` or `download` (`POST /api/products`) | `400` · *Course and event products are created together with the course or the event.* (for `download`: *Download products are created together with the download item, not through the products route.*), with `param: "kind"` |
| Changing a product's `kind` | `400` · *The product kind cannot be changed after it is created.* |
| A `physical` product without `hasStock` and `stockQuantity` | `400` · *A physical product needs stock control with a quantity (zero is allowed).*, with `param: "hasStock"` |
| Installments (`INSTALLMENT`) with a zero price | `400` · *An installment product needs a price above zero.* |
| Deleting the product of a course, event or download | `409` · *This product belongs to a course or an event. Delete the course or the event instead; the product is archived with it.* |
| Deleting a course, event or item with a paid purchase that still gives access | `409` · *There are N paid purchase(s) with active access. Refund them or wait for the access to end before deleting.* |
| Enrolling in or attending a paid course or event without having bought it | `403` · *This course is paid. Buy it, or subscribe to a plan that includes it, to enroll.* (or *This event is paid…*), with `data: { productId, price, currency }` |
| Enrolling, attending or buying with the seats sold out | `409` · *Sold out*, with `data.soldOut: true`; join the line with `POST /api/products/{id}/waitlist` |
| Joining the waitlist while there are seats | `409` · *There are places available, so there is no need to join the waitlist.* |
| `POST /api/checkout` for a physical product without an address | `400` · *The delivery address is required for physical products.*, with `param` in `shippingAddress` |
| `POST /api/checkout` without `paymentMethod` for a priced product | `400` · *paymentMethod is required*, with `param: "paymentMethod"` |
| Reprocessing a video that didn't fail (`POST /api/media/{id}/reprocess`) | `409`; more than 20 requests per hour, per person: `429` |
| Claiming a free product many times in a short time | `429` · *Too many attempts in a short time. Try again in a few minutes.* |
| Refunding a free purchase | `409` · *A free claim has nothing to refund.* |
| Downloading a Downloads file without access (paid item, not bought) | `403` · *You do not have access to this download yet.*, with the price and the product; sold out: `409` |
| A second item or second file in a Downloads space in `single` mode | `409` |
| Downloads or media file: type outside the list, content that doesn't match the type, size that doesn't match or over the limit | `400` · translated message (*The file content does not match the declared type.*, *The file is over the N MB limit.*) |
| Image, video or document refused on any upload route (`/api/upload/…`): file type not accepted, unexpected field, too many files or a failed upload | `400` · message in the sender's language, with the field in `errors[].param` (*This file type isn't accepted. Send a JPEG, PNG, GIF, WebP or ICO image.*) |
| Linking media from another space, for another purpose or already in use | `400` or `409` · translated message |
| Asking for a media link without being able to open the space or the item (a private or unpublished post only opens for the author and the team; an event's recording follows the event's visibility) | `403` · *You do not have access to this media.* |
| More than 60 media upload or confirmation requests (`POST /api/media/upload-url` and `/api/media/confirm`) in one hour, per person | `429` · *Too many requests. Try again in a few minutes.* |

Going over the plan's **storage** refuses only new uploads: asking for the upload URL returns `409` `storageLimitReached`, in media, Downloads and lessons; what's already stored stays. The month's upload volume only warns. See [Plan and extensions](/configuracoes/plano#plan-quotas).

**hosted media.** A file type outside the list, a size over the limit (500 MB or 50 MB), a size that does not match, content that does not match the format, or a lesson that is neither video nor file: `400`; the lesson's file changed during the upload: `409`; reading the media without being enrolled, with the lesson locked or the course unpublished: `403`, with no URL; a lesson with no hosted file: `404`; a member, moderator or finance trying to upload or remove: `403`; storage unavailable: `503`. Going over the storage limit is never an error: the upload continues and the response carries a warning.

`quiz.maxAttempts` (1 to 100; empty = unlimited) when creating and editing the quiz lesson, and `certificateMinGrade` (0 to 100) on the course. The attempt returns `maxAttempts`, `attemptsUsed`, `attemptsLeft`, `bestScorePercent` and `closedWithoutPassing`; an attempt after they run out: `409`. The course returns `userFinalGrade` to the student and `finalGrade` in the student list; below the minimum grade, `userCertificate.status` is `BELOW_MIN_GRADE`, with `finalGrade` and `minGrade`.

**quiz and campaigns.** `POST /api/courses/lesson` and `PUT /api/courses/lesson/{lessonId}` accept the `quiz` block (`passPercent` 1 to 100, 1 to 30 `questions`, 2 to 6 `options` with exactly one `isCorrect`, optional `explanation`), owner and admin only; a format error: `400`, with `errors[].param` on the field (for example `quiz.questions[0].options`); changing the type of a quiz with attempts: `409`. `POST /api/courses/{courseId}/lesson/{lessonId}/quiz/attempts` sends the answers (all of them) and returns the score, passed and, only when passed, the correct answers; more than 30 attempts per hour on the lesson: `429`, with `Retry-After`; `GET` on the same route lists the last 10. `POST .../progress` with `completed: true` on a quiz lesson: `409`. Campaigns: `POST /api/campaigns/{id}/schedule` (`scheduleKind` `DAILY` or `WEEKLY`, `timeOfDay`, `weekdays`; a past or invalid time: `422` on `sendAt`), `POST .../pause`, `.../resume` and `.../cancel` (owner and admin only), `GET .../runs`, `GET .../report` and `GET .../preview` (team), `GET /api/campaigns/automatic` and `PUT /api/campaigns/automatic/{systemKey}` (turning on requires `consent`), `POST /api/upload/campaigns/image` and `DELETE` (the image of a campaign that left draft: `409`). An audience over 500 people on the community's first send: `422`; a fixed item in a block that not everyone sees: `422` on `blocks[i]`; sending, scheduling, editing or deleting an automatic through the ordinary routes: `409`.

**course community.** `POST /api/courses/{courseId}/leave` on a completed course: `409`; without being enrolled: `404`. Course or lesson discussion without being enrolled, or writing without being allowed: `403`; writing or reacting in a draft or archived course: `409`; replying to a reply, empty text or over 2,000 characters, an invalid reaction: `400`, with `param` on the field; a comment or lesson that does not exist (or belongs to another course): `404`. Deleting someone else's comment without being on the team: `403`. `GET /api/courses/{courseId}/leaderboard` with the leaderboard off: `403`. An instructor whose profile is not owner, admin, moderator or finance: `400`; `learningPoints` with more than 8 items or an item over 140 characters: `400`; a member sending `instructorMemberId`, `learningPoints` or `leaderboardEnabled`: `403`.

**e-mail campaigns.** `POST /api/campaigns/{id}/send` without `consent: true` (`param: "consent"`), an empty subject or content, an invalid block, an empty audience or one over 20,000 people, or an unsubscribed person chosen by hand (`param: "audience.memberIds"`): `422`; a moderator or finance trying to send: `403`; a campaign already sent: `409`; daily, weekly or monthly quota exhausted: `429`, saying the limit and when it frees up, before any e-mail goes out. Editing or deleting a campaign already sent: `409`. A campaign from another community: `404`.

**locked lesson.** `POST /api/courses/{courseId}/lesson/{lessonId}/progress` on a lesson the sequence or a date has not opened yet: `409` · *Finish "…" before this lesson.* or *This module opens on …*, with `param: "lessonId"`. The lesson gets no progress or points. A `requireSequential` that is not true or false: `400`; both release fields together, or an invalid date or days: `400`, with the message on the field; a member or moderator sending these fields: `403`.

**events: RSVP and times.** `POST /api/events/{id}/participate` with `going` or `maybe` after the end: `409` · *This event has already ended. You can no longer confirm attendance.*, with `param: "status"` (`not_going` still works). `POST` and `PUT /api/events` with an end equal to or before the start: `400` · *End time must be after start time*, with `param: "endTime"`. Turning on the event certificate without `endTime`: `400` · *Set an end time to issue a certificate…*, with `param: "certificate.enabled"`. Template from another community: `404`; archived or deleted template: `400`, with `param: "certificate.templateId"`; text with HTML or too long: `400`, with `param: "certificate.text"`. A moderator or member sending the `certificate` block: `403`.

**events: draft and publishing.** `PATCH /api/events/{id}` with an `updatedAt` (or `If-Unmodified-Since`) older than the last save: `409`, with `param: "updatedAt"` and the current `data.updatedAt`; nothing changes. `POST /api/events/{id}/publish` with something missing: `422`, one `errors[]` item per field (`title`, `startTime`, `endTime`, `location` for in-person or hybrid, `ticketTypes`) and `data.readiness`; an event already published: `409`. RSVP, ticket purchase, registration, waitlist or streaming on a draft: `409` for the team and `404` for everyone else.

**events: who reads**. `GET /api/events/{identifier}` without `X-CommunityId`: `400`. A **Members** event signed out: `401`; signed in without being in the community: `403`. An event that does not exist, belongs to another community, or is a draft, private, awaiting moderation or rejected: `404`. A plan event or one sold through a paid product, for someone without access, is not an error: `200`, with `requiresPlan` or `requiresPurchase` set to `true` and the livestream, recording and address set to `null`.

**workflows: role of whoever saves.** Creating, editing, turning on or resuming a workflow with an action above the role of whoever does it (a moderator never saves workflows; an admin acts like the owner, except for an owner-only action, and none exists today; or a called workflow that does something above their role): `422` · `workflows.graph.actionAboveRole` or `subWorkflowAboveRole`, on the block. Running one of these now or calling its webhook address: `403` `workflows.actionsAboveRole`. Deleting a run still under way from the history: `409` · *This run is still under way. Stop it before deleting.* `POST /api/workflows/{id}/simulate` with `sendEmailsToMe: true`: more than 10 in an hour, `429`; an account without e-mail, `422` `emails.noEmail`. `PUT /api/vault/ai-limits` out of bounds, or with per call above per run or per run above per day: `422`.

**SMS and WhatsApp.** `PUT /api/sms/settings` or `/api/whatsapp/settings` turning on without a daily cap: `422`, with `param: "dailyCap"`; outside 1 to 50,000: `422`. Turning on a channel the plan doesn't have: `409` · *SMS is not available on this community's plan.* (on WhatsApp below Business, `403` `whatsapp.notOnPlan`). `personDailyCap` outside 1 to 20: `422`. `POST /api/members/me/phone-verification` with SMS off in the community: `409`; a phone from a blocked or unknown country (today, the United States on SMS): `422`; too many codes in the community on the same day: `429` · *This community reached today's code limit. Try again tomorrow.*; another code within 1 minute: `429` with `Retry-After` and `data.retryAfterSeconds`, and more than 3 an hour or 6 a day for the same phone, 6 a day per account or 10 an hour per IP: `429`. `.../confirm` with the wrong code: `400`, with the tries left; expired or with the 5 tries used: `400`, ask for another. `PUT /api/members/me/notice-preferences` turning SMS or WhatsApp on without a verified phone: `409`. The tests (`POST /api/sms/test`, `/api/whatsapp/test`) never fail on a sending rule: they answer `202` with `skipped` and the reason (`smsPhoneUnverified`, `whatsappTemplateNotApproved`…).

**Plan quotas.** A campaign whose audience goes over the recipients left in the period: `429` `quota_recipients`. Reprocessing a video without conversion minutes: `409` `transcodeQuotaReached` (or `transcodeNotOnPlan` on Starter). See [Plan and extensions](/configuracoes/plano#plan-quotas).

More examples, in the order you run into them while building something, in [Creation order](/api/ordem-de-criacao).

## Version conflict

The editors check the version before saving, so one save never wipes out another without warning. Send the version you read in one of these ways:

- `updatedAt` in the body, with the value the `GET` returned (on the community row, `settingsUpdatedAt`);
- or the `If-Unmodified-Since` header.

If another save came first, the answer is `409`, nothing changes, and `data` carries the current version:

```json
{
  "success": false,
  "message": "Someone else saved this after you opened it. Reload to see the current version and make your change again.",
  "errors": [{ "param": "updatedAt", "message": "…" }],
  "data": { "updatedAt": "2026-10-28T14:05:00.000Z" }
}
```

Read again and repeat the change, or send the save with the new version to write over it on purpose. Every success answer carries the new version. **It is optional:** without the field and the header, the save goes as before.

| Where | Version |
|---|---|
| Event (`PUT`, `PATCH`), campaign, library e-mail and each language version, workflow, course (with modules and lessons), form, survey, certificate template | the item's `updatedAt` |
| Default e-mails (`PATCH /api/email-overrides/{emailKey}`; `PUT` and `DELETE` of `/{language}`) | `updatedAt` of the e-mail or of that language's version |
| Community row: profile, brand, images, directory, workflow senders, custom domain | `settingsUpdatedAt` |
| Chat, notifications, SMS and WhatsApp (one version for both), credits, business information, payout settings | the setting's `updatedAt` (empty before the first save: no check) |

Whoever cannot edit the item gets the usual `403` or `404`, never the version. To know right away that someone saved, listen to `item.saved` in [Realtime](/api/tempo-real#editing-and-item-saved).

## Chat limits

The chat ([Messages](/membros/mensagens#limits-against-spam)) has anti-spam limits per person and per community, for the app, the API and the MCP alike. Going over a limit answers `429` (`ChatRateLimited` in the reference), with the translated message saying when it frees, in the community's time zone:

```json
{
  "success": false,
  "message": "…",
  "errors": [{ "param": "limit", "message": "…", "code": "limitNewConversationsHourly", "limit": 10, "retryAt": "2026-10-22T18:42:10.000Z" }]
}
```

| `errors[0].code` | Limit |
|---|---|
| `limitConnectionsDaily` | 20 connection requests a day |
| `limitConnectionsWeekly` | 100 connection requests a week |
| `limitPendingRequests` | 200 requests waiting for an answer; frees by canceling requests (no `retryAt`) |
| `limitNewConversationsHourly` | 10 new conversations an hour (existing ones don't count) |
| `limitNewConversationsDaily` | 30 new conversations a day |
| `limitMessagesPerMinute` | 30 messages a minute |
| `limitDuplicateMessage` | The same text to at most 5 different people an hour |

`limit` is the maximum that applies to the caller: half of the request and new-conversation limits for someone who joined less than 24 hours ago, and double every limit for the team (owner, admin, moderator and finance). When time frees it, `retryAt` (ISO 8601) and the `Retry-After` header, in seconds, come along. The windows slide: a slot frees when the oldest action in the window completes the period.

## Person name

Each person has one name (see [Account and profile › Name](/conceitos/conta-e-perfil#name)). In the API:

- `POST /api/members/invite` accepts `people: [{ email, name }]` besides `emails` (one of the two is enough): the new account is created with the `name`, and the invitation email goes out with it.
- `PUT /api/members/{id}` accepts `name` (the older `displayName` still works) and `PUT /api/members/me` accepts only `name`. The name changes only in this community; on someone else's, the display name changes, and the full name only in the provisional-name case.
- The responses of `GET` and `PUT /api/members/{id}`, `GET /api/members` and `PUT /api/members/me` include `name`, the name that's shown.
- Sending the same name that's already shown changes nothing and skips the rules below.

| Situation | Response |
|---|---|
| Changing the name of someone with a role equal to or above yours | `403` · *You can only change the name of someone whose role is below yours.*, with `param: "name"` |
| Changing the name of a member from another community (the `X-CommunityId` isn't their community) | `404` · *Profile not found*, the same as an id that doesn't exist |
| Empty `name` | `400`, with `param: "name"` |

## Stock

A one-off product with `hasStock` has `stockQuantity`, the number **available right now**.

| Situation | Response |
|---|---|
| Creating or editing a product with `hasStock: true` and no `stockQuantity` | `400` · *Enter the stock quantity.*, with `param: "stockQuantity"` |
| Turning `hasStock` on for a subscription (`SUBSCRIPTION`) | `400` · *Stock only applies to a one-off product, not to a subscription.*, with `param: "hasStock"` |
| Buying with no unit left (`POST /api/checkout` or `POST /api/checkout/calculate-price`) | `409` · *Sold out*, with `param: "productId"`. Nothing is recorded or sent to the payment provider |
| Two buyers on the last unit | One gets `201`; the other, `409` |
| Buying again with an open PIX or boleto for the product | `409` · *You already have an open payment for this plan. Pay the same PIX or boleto, or cancel it to choose another payment method.*, with `param: "productId"` and the open payment in `data.openPayment`. No other unit is reserved |

The unit is reserved on the order and comes back only once (payment declined, PIX or boleto expired, a refund that revokes access). The direct-link catalog, `GET /api/checkout/products`, returns `soldOut` (true or false, without the quantity), like the Store. The management routes (`GET /api/products` and `GET /api/products/{idOrSlug}`) return `stockQuantity` to the owner, the admin and finance; other roles get only the active products, with `soldOut`.

## Appearance and themes

The community's appearance is written only through themes (`/api/community-themes`, see [Theme and colors](/configuracoes/cores#in-the-api)).

| Situation | Response |
|---|---|
| `PATCH /api/communities/appearance` with `mode` or `navigation` | `400`, with `param` on the field: the mode and the menu belong to the theme (use `PATCH /api/community-themes/active` or activate another theme). `darkLogoUrl` stays here |
| `PUT /api/communities/{id}` with `themePalette` or `themeColor` (even `null`) | `400`, with `param: "themePalette"` or `"themeColor"`: the colors belong to the theme (`values.light` in `/api/community-themes`). On community creation (`POST`), they are still accepted and become the colors of the initial **Light** theme |
| Saving the 11th theme | `422` · *The community already has 10 saved themes. Delete one to save another.* |
| Deleting the active theme | `422` · *The active theme cannot be deleted. Activate another theme first.* |
| Font, corners, density or menu outside the list, or a color that isn't hexadecimal | `422`, with `param` on the field (for example `values.font.heading`) |
| Saving or activating a dark or automatic theme without the dark theme enabled | `422`, with `param: "values.mode"` |
| `POST /api/communities/appearance/theme-from-image` with SVG, a file that isn't an image or one over 5 MB | `422` · *SVG isn't accepted here. Upload a PNG, JPG or WebP.* (or the message for the case) |
| The same route with `source: "logo"` and a community without a logo | `422` · *The community has no logo yet. Upload an image.* |
| The same route with the plan's AI quota used up | `429`, with the same message as the other AI features |
| Moderator, finance or member on any theme route | `403` |

## Payouts and receiving payments

| Situation | Response |
|---|---|
| Checkout (`POST /api/checkout`) or price calculation in a community without an active payout account | `400` · *This community cannot receive payments yet: its payout account is not active.*, with `param: "productId"`. Nothing is charged |
| Request a payout (`POST /api/payouts`) without an active payout account | `400` · the same message |
| Request a payout above the maximum | `400` · *Not enough balance for this payout. You can withdraw up to US$ 997.26 now: a payout of US$ 1,000.00 plus the US$ 2.75 payout fee comes to US$ 1,002.75, and the balance available for payouts is US$ 1,000.00.*, with `param: "amount"` |
| Request a payout below the minimum | `400` · *The minimum payout is US$ 10.00.*, with `param: "amount"` |
| Request a payout with more than 2 decimal places (`1.005`) | `400` · *Enter the payout amount with at most 2 decimal places.*, with `param: "amount"`; the amount is never rounded |
| Publish a product (`POST /api/products/{id}/publish`, or `PUT` with `status: ACTIVE`) with an approved country that has no payment provider | `400` · *We don't sell in PT yet: the business information's country has no active payment provider.*, with no `param`. The product stays a draft |
| Request a payout when the payout account balance can't be checked | `502` · *The balance available for payouts cannot be checked right now. Please try again in a few minutes.* Nothing is recorded |
| Payout sent, but refused by the bank | `201`, with `data.status: "FAILED"`, `data.failureCode` and `data.failureMessage` (*The bank did not accept the transfer. The amount and the fee are back in your balance.*) |
| Request a payout above the maximum with refunds waiting for balance | `400` · the same refusal, plus *US$ 92.01 is kept for refunds waiting for balance.* |
| Cancel a payout that isn't pending | `400` · *Cannot cancel payout with status PROCESSING* |

The maximum payout is the smaller of the statement's available amount and the payout account balance, with the amount **plus the fee** fitting in it. To avoid a refusal, read `GET /api/payouts/balance` first: `withdrawableBalance` is the balance you can pay out, `transferFee` the fee and `maxPayoutAmount` the largest amount you can request. Refunds waiting for balance also come out of what can be paid out: `pendingRefunds` says how much is reserved for them. An accepted request answers `201` with the payout in the state it ended in: `PROCESSING` when it was sent to the bank, `FAILED` when it was refused right away. See [Payouts](/pagamentos/saque). `minPayoutAmount` is the smallest amount accepted. Only `available` counts towards what can be paid out: `pending` (sales before their release date) and `retained` (the chargeback reserve) never do. With a balance in more than one currency, `balances[]` has one line per currency, and `?currency=` picks the currency of the answer.

**Memberfy team routes.** Two SuperAdmin routes follow payouts and, like every `/api/admin` route, stay out of the public reference: `GET /api/admin/payouts` lists the payouts of every community (filters `communityId`, `status`, `from` and `to`), and `POST /api/admin/payouts/{payoutId}/retry` retries a failed payout as a **new payout**, linked to the previous one, only once. Retrying a payout that didn't fail, or that was already retried, answers `409`. For the community, the new payout shows up in `GET /api/payouts` like any other.

## Refund

Three routes, all through the `X-CommunityId` community, for **owner**, **admin** and **finance** (moderator and member: `403`; a purchase from another community: `404`):

| Route | What it does |
|---|---|
| `GET /api/manage/purchases` | The community's sales, newest first. Filters `situation` (one or more, comma separated; the total and the pages count only what matches), `status`, `productId`, `paymentMethod`, `refunded=true`, `from`, `to`, `search` (buyer name or email), `page` and `limit` |
| `GET /api/manage/purchases/{purchaseId}` | One sale, with `refunds` (all refunds, newest first) and the `timeline`, the sale's history |
| `POST /api/manage/purchases/{purchaseId}/refunds` | Refunds the purchase and emails the buyer |

Besides the amount, the platform fee and the net (`netAmount`), each sale carries `refundedAmount` (what has already been refunded or is on its way), `refundableAmount` (what still can be), `refundMethod` (`PROVIDER`: Memberfy gives it back through the card or PIX; `SETTLED_OUTSIDE`: boleto, the community returns it directly), `lastRefund` and **`refundBlockedReason`**, with the translated sentence in `refundBlockedMessage`, when the purchase can't be refunded through Memberfy yet:

| `refundBlockedReason` | Meaning |
|---|---|
| `NO_CHARGE` | The purchase has no charge at the payment provider |
| `null` | Nothing prevents it |

Read `refundBlockedReason` before offering the refund: the `POST` refuses these purchases with the same message. On card and PIX, a refund can be **partial**, from 0.01 up to `refundableAmount` (`partialRefundAvailable` is always `true`), and in an order with several purchases (a plan and its add-ons) each purchase is refunded on its own. A partial one usually comes back as `PENDING` until the provider confirms it.

**Status and timeline.** Every sale carries `situation`, the same word as the **Status** column and filter of the Sales tab: `PAID`, `PROCESSING`, `AWAITING_BALANCE`, `REFUNDED`, `PARTIALLY_REFUNDED`, `SETTLED_OUTSIDE`, `IN_REVIEW`, `DECLINED`, `DISPUTED`, `AWAITING_PAYMENT` and `FAILED`. The detail also carries `timeline`, the sale's timeline, oldest first. Each event has `type`, `at` (the date; `future: true` on a date that hasn't come yet, such as a release), `actor` (`BUYER`, `STAFF` with name and role, `MEMBERFY`, `PROVIDER` or `SYSTEM`, with `label` already translated), `amount`, `currency`, `params` and `text`, the event already translated by `Accept-Language`. The `type` values:

| Group | `type` |
|---|---|
| Purchase and payment | `PURCHASE_CREATED`, `PAYMENT_CONFIRMED`, `PAYMENT_CONFIRMED_INSTALLMENTS`, `PAYMENT_FAILED`, `PAYMENT_CANCELED` |
| Balance release | `BALANCE_RELEASE_SCHEDULED`, `BALANCE_RELEASED`, `BALANCE_RELEASE_SCHEDULED_INSTALLMENT`, `BALANCE_RELEASED_INSTALLMENT` |
| Chargeback reserve | `RESERVE_HELD`, `RESERVE_RETURN_SCHEDULED`, `RESERVE_RETURNED`, `RESERVE_RETURNED_REFUND`, `RESERVE_RETURNED_CHARGEBACK` |
| Refund | `REFUND_REQUESTED`, `REFUND_REQUESTED_REASON`, `REFUND_SETTLED_OUTSIDE`, `REFUND_SETTLED_OUTSIDE_NOTE`, `REFUND_PROCESSING`, `REFUND_AWAITING_BALANCE`, `REFUND_IN_REVIEW`, `REFUND_SUCCEEDED`, `REFUND_DECLINED`, `REFUND_AT_PROVIDER`, `REFUND_AT_PROVIDER_IN_REVIEW`, `REFUND_REVIEWED_SUCCEEDED`, `REFUND_REVIEWED_DECLINED` |
| Access and subscription | `ACCESS_KEPT`, `ACCESS_REVOKED`, `SUBSCRIPTION_CANCELED` |
| Chargeback | `CHARGEBACK_OPENED`, `CHARGEBACK_WON`, `CHARGEBACK_LOST` |
| E-mails to the buyer | `EMAIL_SENT`, `EMAIL_DELIVERED`, `EMAIL_NOT_DELIVERED` |

The timeline never carries provider cost or provider ids.

**The `POST` body:** `access` (`KEEP` keeps access; `REVOKE` removes access and, on a subscription, **cancels the subscription right away**), `idempotencyKey` (required: generate one when opening the dialog and resend the same one on retries), `amount` (default: what is left to refund), `reason` and, on boleto only, `settledOutside` (`at` and `note`), which records the money returned directly by the community.

**The response** is the refund (`PurchaseRefund`): `201` when it is new, `200` on a repeat with the same key, never a second refund. `status` says where it stands: `SUCCEEDED` (done), `PENDING` (the provider still has to confirm), `AWAITING_BALANCE` (no balance in the receiving account for the community's part: it goes out on its own, in order of arrival, and the access choice already applies), `DECLINED` (declined, with `failureReason`) or `NEEDS_REVIEW` (under review: Memberfy reviews it and completes or declines it). It also carries `accessDecision`, `source` (`MEMBERFY`, or `PROVIDER` when it was made outside Memberfy), `communityAmount` (what left the community's balance) and `platformFeeReturned` (the platform fee returned).

| Situation | Response |
|---|---|
| A purchase that doesn't exist or belongs to another community | `404` · *Purchase not found in this community.* |
| Zero or negative amount | `400` · *Enter an amount greater than zero.* |
| Amount above what is left | `400` · *The amount is above what is left to refund (97.00).* |
| Boleto without `settledOutside` | `400` · *Memberfy cannot give back a paid boleto. Contact the buyer, return the money directly (PIX or bank transfer) and record it here as "already returned directly to the buyer".* |
| `settledOutside` on card or PIX | `400` · *Only a boleto can be recorded as returned directly. Card and PIX refunds are always made by Memberfy.* |
| Pending or disputed purchase | `409` · *This purchase cannot be refunded (status: …).* |
| Nothing to refund | `409` · *This purchase has nothing left to refund.* |
| Purchase without a charge at the provider (`NO_CHARGE`) | `409` · *This purchase has no charge at the payment provider to refund.* |
| Refunds turned off in the environment (outside production) | `409` · *Refunds are off in this environment.* |
| `idempotencyKey` already used on another purchase | `409` · *This refund key was already used for another purchase. Open the refund dialog again.* |
| The provider declined | `422` · *The payment provider declined the refund: …*, with the reason. The refund stays `DECLINED` and nothing changes in the statement; a new request takes a new key |

On card and PIX, the statement debits only the community's part (`communityAmount`), and the platform fee goes back in proportion. On a boleto returned directly, `communityAmount` and `platformFeeReturned` are zero. A purchase refunded with `KEEP` stays `COMPLETED` and active; the refund shows in `refundedAmount` and `lastRefund`. For the buyer, `GET /api/purchases/me` returns `refundedAmount`, `refundedAt` and `refunds`; on a subscription, each charge in `GET /api/manage/subscriptions/{subscriptionId}` carries the `purchaseId` the refund needs. See [Refunds and disputes](/pagamentos/reembolsos-e-contestacoes).

**Memberfy team route.** `GET /api/admin/refunds` (SuperAdmin only, out of the public reference) lists the refunds of every community; `?status=NEEDS_REVIEW` are the ones under review, which Memberfy completes or declines.

## Plan change

`POST /api/subscriptions/{id}/change-tier` charges the card, so it accepts an **idempotency key**: `idempotencyKey` in the body or the `Idempotency-Key` header, 8 to 100 characters (`A-Z`, `a-z`, `0-9`, `_`, `.`, `:` and `-`). Generate **one per attempt by the person** (for example, when they confirm a combination of plan and add-ons) and resend the same one on retries, such as after a timeout.

| Situation | Response |
|---|---|
| First time | `200` with the result of the change (`mode`, `subscription`, `scheduledChange`, `charge`) |
| The same key again | `200` with `data.replayed = true` and the first result; nothing is charged or changed again |
| A change charge for the same subscription still in progress (with any key, or no key) | `409` · *Your plan change is already being processed. Wait a moment and check your subscription.* |
| The subscription switched options in the middle of the change (a renewal applying another change) | `409`; if the change had already been charged, the amount is refunded in full and nothing changes |
| Card declined | `402`; the key is released, and a new attempt (with another card) can use another one |

Two changes at the same time on the same subscription (two tabs, the app and the assistant) never charge twice: one goes through, the other gets the `409`. The same rule applies to the preview and to the change with `addOnProductIds` (the add-ons in the same charge) and with `cardToken` (a new card, which becomes the subscription's card when the charge goes through). The route does not accept an API key. See [Change plan](/pagamentos/faturamento-do-membro#change-plan).

### PIX and boleto

`POST /api/subscriptions/{id}/change-tier` takes `paymentMethod`: `CREDIT_CARD` (the default), `PIX` or `BOLETO`, one of the preview's `allowedMethods`.

| Field | Where | What it is |
|---|---|---|
| `allowedMethods` | Preview (`/change-tier/quote`) | The payment methods for the charge now, the ones the target option accepts at checkout: `[{ method, totalAmount, cashAmount, installmentOptions? }]`, with installments only by card. The up-front total is the same for all (the platform fee doesn't depend on the payment method). Boleto only with **more than 4 days** to the renewal. With no charge now (scheduled change or below the minimum), `[]` |
| `pendingChange` | Preview and change | The change waiting for its PIX or boleto: `purchaseId`, `orderId`, `paymentMethod`, `newProductId`, `addOnProductIds`, `amount`, `currency`, `pixCode` (copy and paste), `qrCode`, `boletoUrl`, `boletoBarcode`, `expiresAt`, `createdAt`; or `null` |
| `mode: "PENDING_PAYMENT"` | Change | With PIX or boleto, the answer is `200` with `mode = PENDING_PAYMENT`, `charge` (the order) and `pendingChange` (what to pay). **The plan doesn't change yet**: the change and the add-ons apply once the payment is confirmed |

The amount is the prorated one calculated when the order is created and doesn't change until it is paid. On confirmation, if the subscription is no longer on the option and period the order was priced for, or the target or an add-on is no longer valid, the payment is **refunded** and nothing changes. Expired or canceled order: nothing changes and the subscription is free again; a PIX paid after it expired is refunded. The same `idempotencyKey` while the order waits returns the same payment data (`replayed = true`, `mode = PENDING_PAYMENT`).

| Situation | Response |
|---|---|
| A change is already waiting for its PIX or boleto (with any key) | `409` · `monetization.subscription.changeTierPendingPayment` · *A plan change is waiting for its payment. Pay it or wait for it to expire before starting another one.* The preview carries `pendingChange` with what to pay |
| `BOLETO` with 4 days or less to the renewal | `400` · `param = paymentMethod` · *There is not enough time to pay a boleto before the renewal. Choose PIX or card.* |
| `paymentMethod` not in the list, not in `allowedMethods`, or PIX/boleto together with `cardToken` or `paymentMethodId` | `400` · `param = paymentMethod` · *Invalid payment method* |

## Undoing is DELETE

Taking back a reaction or removing an event recording uses the `DELETE` method, on the same path used to add it. `POST` only adds or changes: sending the same reaction again no longer takes it back.

| Add or change | Take back |
|---|---|
| `POST /api/status/{id}/react` | `DELETE /api/status/{id}/react` |
| `POST /api/content/{id}/react` | `DELETE /api/content/{id}/react` |
| `POST /api/feed/{type}/{id}/react` | `DELETE /api/feed/{type}/{id}/react` |
| `POST /api/status/{id}/comment/{commentId}/react` and `/api/content/{id}/comment/{commentId}/react` | `DELETE` on the same paths |
| `POST /api/gallery/images/{imageId}/react` | `DELETE /api/gallery/images/{imageId}/react` |
| `POST /api/courses/{courseId}/react` (lesson: `?lessonId=`) and `/api/courses/{courseId}/comments/{commentId}/react` | `DELETE` on the same paths |
| `PUT /api/events/{id}/recording` (sets the recording) | `DELETE /api/events/{id}/recording`. `mediaId: null` on the `PUT` answers `400` |

Clearing a field inside an edit (the event banner or highlight, for example) is still a `PUT` with the field empty, and state changes (cancel, archive, decline, pin to the feed, leave a course) keep their own routes.

## API key errors

With the `X-Api-Key` header (see [Authentication › API key](/api/autenticacao#api-key)), the key's refusals carry a `code` in `errors[0]`:

| `code` | Status | When | What to do |
|---|---|---|---|
| `API_KEY_SCOPE_MISSING` | `403` | The route requires sign-in and the key doesn't have the scope. `errors[0]` carries `module`, `level` and `scope` (`"feed:write"`) | Create another key with the scope (scopes can't be edited) |
| `API_KEY_BLOCKED` | `403` | The action doesn't accept a key, with any scope (money, account, SuperAdmin, OAuth and MCP, key management, `X-MemberId`) | Do it on screen, signed in |
| `API_KEY_ROUTE_NOT_COVERED` | `403` | The route requires sign-in and belongs to no module | Use the sign-in token |
| `API_KEY_IP_NOT_ALLOWED` | `403` | The caller's IP isn't in the key's list; the message carries the IP | Add the IP to the key, in **Integrations & APIs** |
| `API_KEY_RATE_LIMITED` | `429` | More than 120 calls per minute from the same person in this community | Wait the `Retry-After` seconds |
| `API_KEY_WITH_AUTHORIZATION` | `400` | `X-Api-Key` and `Authorization` on the same call (`param: "Authorization"`) | Send only one of the two |
| `API_KEY_PATH_INVALID` | `400` | The path has a `.` or `..` segment, raw or encoded, or an encoded character that changes the route's module, like `/api/communities/%70lans` (`param: "path"`) | Call the route's path without `.` or `..` and without encoding what is not a parameter |

A key that is invalid, revoked, expired, from another community or belongs to someone who left the community gets `401`, with no `code`, and the reason in the message (*"This API key was revoked."*).

```json
{
  "success": false,
  "message": "This action does not accept an API key.",
  "errors": [{ "param": "X-Api-Key", "message": "This action does not accept an API key.", "code": "API_KEY_BLOCKED" }]
}
```

## Language of messages

Messages come in the language of the `Accept-Language` header (`pt-BR`, `en-US`, `es-ES`, `it-IT`, `de-DE` and the other variants). Without a recognized language, they come in English. This also holds for authentication and role errors (`401` and `403`), which use the same envelope, with `errors[]`.

## Item from another community

An item that belongs to another community answers **404**, the same as an id that doesn't exist. The API doesn't reveal that the item exists elsewhere: for the caller, it doesn't exist in the `X-CommunityId` community.

| When | Response |
|---|---|
| The route names a space from another community (`?spaceId=`, the space of a course or a call for papers) | `404` · `space.notFound` (*Space not found*) |
| The route names an item from another community (reading a post, its comments, an event's participants) | `404` · `common.notFound` (*Not found*) |
| Editing, deleting, commenting on, reacting to or pinning content, an event or a status from another community | `404` · the module's message (`content.notFound`, `event.notFound`, `status.notFound`, `course.notFound`) |
| Reading, editing or deleting a member from another community, changing their role or picture (`/api/members/{id}`, `/api/members/{id}/role`, `/api/upload/avatar/member` with `memberId`) | `404` · `profile.notFound` (*Profile not found*) |

Being an owner, admin or moderator in one community gives no access to anything in another, even when sending your own community's header. If the item is from the community and still answers 404, check that `X-CommunityId` is the right community.

## Deleted items

The API doesn't truly delete: deleted items are marked as removed and stop showing up. That's why a deleted id answers **404**, as if it never existed.

## With the SDK

The SDK throws a `MemberfyError` with `.status`, `.body` (the whole envelope) and `.errors`. See [JavaScript SDK](/api/sdk-js).

## AI assistant turned off

If the owner turned off the AI assistant for your role, OAuth authorization answers `403` with *"The owner has turned off the AI assistant for your role in this community…"*, and `/mcp` refuses every call with *"The owner of {community} has turned off the AI assistant for {group}. Talk to the community owner to turn access back on."* Anyone who tries to change the switches without being the owner gets `403`; a value that is not a boolean, `400`. See [Authentication](/api/autenticacao#ai-assistant-mcp-access).

## Related

- [Authentication](/api/autenticacao)
- [X-CommunityId](/api/x-community-id)
- [Creation order](/api/ordem-de-criacao)
