Reference
REST API

REST API reference

Every route of the public API v1. Base URL https://api.cobuntu.com; community routes live under /api/v1/communities/{communityTag}, shortened below. Open an endpoint for its parameters, schemas, error codes and samples. Filters and search are kept in the URL, so a filtered view can be shared. Keys, sessions, errors and limits are explained under Concepts.

Download openapi.yamlTry requests in the interactive console

180 of 180 endpoints

Auth

Sign members in and up from your own site (server-side, secret key with MEMBER_AUTH).

How Auth works

Member sign-in, sign-up, emailed sign-in links, password recovery, email verification and Continue with Google, all called from your server with a secret key holding MEMBER_AUTH. Each returns a member session bound to this community; see Member sessions and user keys for how to store it and Sign in and recover accounts for the full flow.

POST/auth/sign-inSign a member in with email and passwordSecret
POST/auth/sign-upCreate a member accountSecret
POST/auth/email-link/verifyExchange an emailed sign-in link for a sessionSecret
GET/auth/meWho is signed inSecretMember session
POST/auth/oauth/exchangeExchange a Continue with Google code for a sessionSecret
POST/auth/password-resetEmail a password-reset linkSecret
GET/auth/password-reset/validateCheck a password-reset tokenSecret
POST/auth/password-reset/confirmSet a new passwordSecret
POST/auth/verify-emailConfirm an email addressSecret

Members

The signed-in member's own data (`/me`), the member directory and authoritative membership, membership tiers, application forms and review, and community rules.

How Members works

Use it to show and manage the community's members from your own backend.

List members

Scope READ_PUBLIC, or MEMBERS_ADMIN for the full roster

Without MEMBERS_ADMIN (any key type, no member session), both routes follow the community's Members page setting:

Members pageResponse
Switched off404 { code: "PAGE_DISABLED" }
Members-only or private403 { code: "MEMBERS_ONLY", reason, hint }
PublicThe roster, without members who chose to hide from the directory. A hidden member's detail is 404.

To show the directory to a signed-in member, use GET /me/directory with their member session instead. For an admin integration that needs everyone, use a secret key holding MEMBERS_ADMIN (or ADMIN): it returns the full roster, hidden members included. MEMBERS_ADMIN is never available on a publishable key and is never granted to apps.

Membership standing

Scope READ_PUBLIC

Whether this person is a member of this community, and in what state. An app that gates its own content asks here.

It follows the same Members page rules as the roster above: members who hide from the directory are 404, and a members-only or private community answers 403 unless the key holds MEMBERS_ADMIN.

permissions and segments (paid tiers) are entitlements, so they are returned only to a secret key holding APP_ONBOARDING or MEMBERS_ADMIN. For any other key the fields are left out: absent means "not granted to this key", an empty array means "none". Read them as optional:

const m = await cobuntu.members.membership(userId);
if (m.permissions === undefined) {
  // This key may not see entitlements. Do not treat it as "no permissions".
}

Applications

Use it to keep your own apply form and push the result in. You own the apply page, any scoring and any review screen; Cobuntu is the member home — directory, events, chat.

Make these calls non-blocking: if Cobuntu is unreachable, save the application to your own store anyway and retry later.

Read a tier's form schema

Scope READ_PUBLIC

The fields the community configured for that tier, so your form asks the same questions. Read it rather than hard-coding, or your form silently drifts from what the community expects the moment a leader edits it.

  • An unknown :communityTag is 404, and a key from another community is 403. The form is only ever looked up inside the key's community.
  • A hidden (invite-only) tier's form is served only with ?invitationToken=<token> for that tier. Without it the tier looks like it does not exist (404).

Submit an application

Scope WRITE_APPLICATIONS — carried by the publishable key.

{ "answers": { "email": "…", "name": "…" }, "source": "your_site" }

source tags where the application came from. Worth setting: it is how a community tells site applications apart from ones made on Cobuntu.

For this to land as PENDING rather than admitting someone immediately, the community must be in application mode.

Which tiers can be submitted

Only an active, non-deleted tier. A hidden tier also needs invitationToken in the body: a pending, unexpired invitation to that tier with uses left. A personal invitation must also match the submitted email. Every failure answers the same 404 "Segment not found" as a missing tier.

{ "answers": { "email": "…" }, "invitationToken": "…" }
Email confirmation on browser submits

On the publishable key nobody has proved they own the submitted address. So a submit that would otherwise be admitted at once (a free tier on an open, live community) stays PENDING and answers:

{ "success": true, "requestId": "…", "awaitingEmailConfirmation": true }

The applicant is emailed a confirmation link (valid 48 hours). Clicking it runs the normal approval and sends them to the community's sign-in page. Show a "check your inbox" state when you see awaitingEmailConfirmation, not a "welcome in" one.

No confirmation is needed when:

  • you submit with a secret key from your server (you vouch for the address), or
  • a signed-in applicant submits their own account email.

Submits are limited to 10 per hour per IP (the real client IP, not a header the caller sets).

Approve or reject

Scope WRITE_MEMBERS

Server side only. WRITE_MEMBERS is deliberately absent from the publishable key, because it lets the caller name any email in the body. A browser-exposed key holding it would let anyone admit anyone.

Paid tiers

Scope WRITE_APPLICATIONS

Chain this after /segments/:id/submit when the tier is paid: submit returns a requestId, and this turns it into a checkout.

Same base URL and same key as every other endpoint here.

POST/me/verify-email/resendResend the verification emailSecretMember session
GET/me/ticketsThe member's ticketsSecretMember session
GET/me/onboardingThe member's onboarding statusSecretMember session
POST/me/onboarding/dataSubmit onboarding form answersSecretMember session
POST/me/onboarding/rulesAccept the community rulesSecretMember session
GET/me/profileThe member's profileSecretMember session
PATCH/me/profileUpdate the member's profileSecretMember session
GET/me/profile/usertag-availableCheck a usertagSecretMember session
PUT/me/profile/avatarUpload the avatarSecretMember session
DELETE/me/profile/avatarRemove the avatarSecretMember session
GET/me/preferencesThe member's preferencesSecretMember session
PATCH/me/preferencesUpdate the member's preferencesSecretMember session
POST/me/passwordChange the passwordSecretMember session
GET/me/exportExport the member's dataSecretMember session
DELETE/me/accountDelete the accountSecretMember session
GET/me/friendsThe member's friendsSecretMember session
GET/me/notesThe member's notesSecretMember session
POST/me/notesAdd a noteSecretMember session
DELETE/me/notes/{noteId}Delete a noteSecretMember session
GET/me/directoryThe members directorySecretMember session
GET/me/members/{usertag}A member's profileSecretMember session
GET/me/notificationsThe member's notificationsSecretMember session
GET/me/notifications/unread-countUnread notification countSecretMember session
POST/me/notifications/readMark notifications readSecretMember session
POST/me/notifications/mute-threadMute a post threadSecretMember session
DELETE/me/notifications/mute-threadUnmute a post threadSecretMember session
GET/me/friends/requestsPending friend requestsSecretMember session
POST/me/friends/requestsSend a friend requestSecretMember session
POST/me/friends/requests/{requestId}/acceptAccept a friend requestSecretMember session
POST/me/friends/requests/{requestId}/declineDecline a friend requestSecretMember session
DELETE/me/friends/requests/{requestId}Cancel an outgoing friend requestSecretMember session
DELETE/me/friends/{usertag}UnfriendSecretMember session
GET/me/members/{usertag}/eventsA member's eventsSecretMember session
GET/me/profile/eventsThe member's own profile eventsSecretMember session
GET/me/members/{usertag}/friendsA member's friendsSecretMember session
GET/me/referralsThe member's referral programSecretMember session
GET/me/referrals/earningsThe member's referral earningsSecretMember session
GET/me/emailsThe member's email addressesSecretMember session
POST/me/emailsAdd an email addressSecretMember session
POST/me/emails/{emailId}/resend-verificationResend an email verification linkSecretMember session
POST/me/emails/{emailId}/make-primaryMake an email primarySecretMember session
DELETE/me/emails/{emailId}Remove an emailSecretMember session
POST/me/phone/send-otpSend a phone verification codeSecretMember session
POST/me/phone/verifyVerify and set the phone numberSecretMember session
GET/me/connected-accountsConnected sign-in accountsSecretMember session
DELETE/me/connected-accounts/googleDisconnect GoogleSecretMember session
POST/me/connected-accounts/google/confirmFinish connecting GoogleSecretMember session
GET/me/seller-vatThe member's seller VAT detailsSecretMember session
PUT/me/seller-vatUpdate the member's seller VAT detailsSecretMember session
POST/me/segments/{segmentId}/applyApply to (or join) a membership tierSecretMember session
GET/me/applicationsThe person's applicationsSecretMember session
POST/me/applications/{requestId}/checkoutPay for an applicationSecretMember session
GET/me/reminder-preferencesEvent reminder preferencesSecretMember session
PATCH/me/reminder-preferencesUpdate event reminder preferencesSecretMember session
GET/me/membershipThe member's paid membershipSecretMember session
POST/me/membership/checkoutStart a paid membership checkoutSecretMember session
POST/me/membership/change-tierChange to another tierSecretMember session
POST/me/membership/cancelCancel at period endSecretMember session
POST/me/membership/reactivateUndo a pending cancellationSecretMember session
POST/me/membership/billing-portalOpen the billing portalSecretMember session
GET/me/sales/{saleId}/refund-eligibilityCan this purchase be refundedSecretMember session
GET/segments/publicList public membership tiersPublishable
GET/segments/{segmentId}/check-emailCheck an email against a tierPublishable
GET/segments/{segmentId}/formA tier's application formPublishable
POST/segments/{segmentId}/submitSubmit an applicationPublishable
PATCH/segments/{segmentId}Update a membership tierSecret
POST/applications/{requestId}/approveApprove an applicationSecret
POST/applications/{requestId}/rejectReject an applicationSecret
GET/rulesCommunity rulesPublishable
GET/co-creationCo-creation eligibilityPublishable
POST/rules/acceptRecord rules acceptance by emailSecret
GET/membersList membersPublishable
GET/members/{userId}Get a memberPublishable
GET/members/{userId}/membershipA member's authoritative membershipPublishable
GET/me/purchasesThe member's product purchasesSecretMember session
GET/me/purchases/{saleId}/attachments/{attachmentId}/downloadDownload a purchased fileSecretMember session
GET/me/sales/{saleId}/invoiceInvoice for a saleSecretMember session
GET/me/sales/{saleId}/receiptReceipt PDF for a saleSecretMember session
GET/me/eventsThe member's eventsSecretMember session
POST/me/eventsCreate an event as the memberSecretMember session
GET/me/ledgerThe member's ledgerSecretMember session
GET/me/libraryThe member's librarySecretMember session
GET/me/draftsThe member's event draftsSecretMember session
GET/me/drafts/event/{id}Read an event draftSecretMember session
PUT/me/drafts/event/{id}Save an event draftSecretMember session
DELETE/me/drafts/event/{id}Discard an event draftSecretMember session
GET/me/packagesFee packages the member may host underSecretMember session
GET/me/stripeThe member's own StripeSecretMember session
GET/me/permissionsThe member's create permissionsSecretMember session
PUT/me/events/{eventId}/donationsSet donations on a hosted eventSecretMember session
GET/me/tagsSearch event tagsSecretMember session
POST/me/tagsCreate an event tagSecretMember session

Events

Event listing, detail, tiers, free RSVP, invitations, self-service and the event import bridge.

How Events works

Events

Scope READ_PUBLIC

Use it to list events on your own site and link each one through to the community app.

Registration, invitations, attendance and donations are covered in the Events guide and the client reference. Editing an event by id (PATCH /events/:eventId) is on Server and app platform.

The event bridge

Scope WRITE_EVENTS

Upsert an event that lives on somebody else's ticketing platform, addressed by their id rather than ours. PUT creates on first call and updates afterwards, so an importer can run repeatedly without tracking what it has already sent.

Use it to sync events from external ticketing platforms into the community.

Why the identifier is theirs and not ours

An importer reading someone else's calendar has no Cobuntu id to send. Keying on (platform, externalId) makes the write idempotent from the caller's point of view: re-running the importer reconciles rather than duplicates.

This is the one place in Embed where a resource is addressed by a foreign key, and that is deliberate rather than an oversight.

What the response tells you

Read back created, archived, event.id, event.slug and linkConflicts — so a sync run can report what it changed instead of guessing.

Write carefully. Make your importer dry-run by default, write only on an explicit flag, pause between writes, and record every result. An importer that silently rewrites a community's calendar is a bad afternoon for whoever owns that calendar.

GET/eventsList eventsPublishable
GET/events/facetsEvent filter countsPublishable
GET/events/{idOrSlug}Get an eventPublishable
PATCH/events/{idOrSlug}Update an event by idSecret
GET/events/{idOrSlug}/tiersList an event's tiersPublishable
GET/tiers/{tierId}/formA tier's registration formPublishable
GET/tiers/{tierId}/member-pricing/previewMember pricing previewPublishable
POST/events/{idOrSlug}/rsvpRSVP to a free eventSecret
POST/events/{idOrSlug}/leaveLeave an eventSecret
POST/events/{idOrSlug}/attendees/me/cancel-pendingWithdraw a pending reservationSecretMember session
GET/events/{idOrSlug}/attendance-statusAttendance statusPublishable
GET/events/{idOrSlug}/invitations/checkCheck an invitation tokenPublishable
POST/events/{idOrSlug}/invitations/acceptAccept an invitationSecret
GET/magic-links/verifyVerify a guest manage linkPublishable
GET/events/external/{platform}/{externalId}Find an imported event by external idSecret
PUT/events/external/{platform}/{externalId}Create or update an event by external idSecret

Checkout

Paid checkout (tickets and marketplace baskets), quotes, session status, buyer refunds, guest order links, paid applications and donations.

How Checkout works

Donations to a product

Scope WRITE_SALES (donate), READ_PUBLIC (summary)

A "support the maker" donation without buying the product. The body is { donation: { amount }, customer?: { email }, successUrl, cancelUrl }, with amount a positive integer in cents; customer.email is required when no member session is forwarded. It answers { checkoutUrl, sessionId }: send the buyer to checkoutUrl. The summary answers { total, count, currency } with no donor identity.

const { checkoutUrl } = await cobuntu.products.donate("acme-notebook", {
  donation: { amount: 1000 },
  customer: { email },
  successUrl: returnUrl(origin, "/thanks"),
  cancelUrl: returnUrl(origin, "/marketplace/acme-notebook"),
});
const { total, count } = await cobuntu.products.donationsSummary("acme-notebook");

Codes: 400 INVALID_DONATION_AMOUNT, 400 missing return URLs or email, 404 unknown product, 429 (30 per hour per visitor IP). The default publishable key does not carry WRITE_SALES, so donate from your server.

POST/events/{idOrSlug}/checkoutCreate a ticket checkoutPublishable
GET/checkout/sessions/{sessionId}Checkout session statusPublishable
POST/events/{idOrSlug}/sales/me/refundBuyer refunds their own ticketPublishableMember session
POST/apply/checkoutPay for an anonymous applicationPublishable
POST/events/{idOrSlug}/donateDonate to an eventPublishable
POST/products/{idOrSku}/donateDonate to a productPublishable
GET/events/{idOrSlug}/donations/summaryAn event's donation totalsPublishable
GET/products/{idOrSku}/donations/summaryA product's donation totalsPublishable
POST/checkoutCreate a marketplace checkoutPublishable
POST/checkout/quotePrice a basketPublishable
POST/products/{sku}/sales/me/refundBuyer refunds their own product purchasePublishable
GET/sales/manageA guest's order from the order linkPublishable
GET/sales/manage/receiptA guest's receipt PDFPublishable

Commerce

Products (browse, filter counts, variants, stock), the cross-origin cart, host refunds and subscription admin.

How Commerce works

Use it to run a full storefront on your own domain.

Products

Scope READ_PUBLIC

The detail route takes either the product id or its SKU. Both have always worked; the path parameter used to be named :sku, which is why earlier versions of this page said SKU was the only way in.

Filtering

QueryMeaning
?q=text search (every word must match)
?sort=newest (default), price_asc, price_desc, name
?price=free or paid, on the card price
?minPrice= / ?maxPrice=inclusive range in the smallest unit, on the card price
?seller=community or member
?type=DIGITAL or PHYSICAL
?category= / ?subcategory=a category id (see Categories)
?limit= / ?offset= / ?cursor=page size (max 100, default 20), offset, or the previous nextCursor

An unknown category id narrows to nothing and returns an empty page rather than a 400, so a filter pointed at a deleted category does not break the page. GET /products/facets returns the counts for a filter bar. Each product carries seller and variants[] with stock and member prices. See the Marketplace guide for checkout, purchases and refunds.

Courses

Scope READ_PUBLIC

Courses are a product type and sit behind the community's Learning install. The list takes ?category / ?subcategory (from the COURSE taxonomy, categories({ type: "COURSE" })) and ?limit / ?offset. It answers { courses, pagination } with each course in the product shape, and 404 when Learning is not enabled for the community. Only PUBLIC courses are listed. On the client: cobuntu.courses.list({ category, limit, offset }).

There is deliberately no /courses/:id. A course is a product row with productType: COURSE, so GET /products/:idOrSku already serves it.

Categories

Scope READ_PUBLIC

Returns the community's categories with their subcategories nested, so a filter UI is one request rather than one per level. Feed the ids straight back as ?category= / ?subcategory=.

?type is required and must be one of PRODUCT, EVENT, COURSE, ARTICLE. It is not defaulted on purpose: categories are one table with a content-type discriminator, and a default would hand you a different taxonomy's categories with a 200.

Cart

Scope WRITE_CART on all of them, including the GET.

The read takes a write scope deliberately: a cart is the caller's own private object, and the same credential governs reading and writing it. Splitting them would imply a read-only cart view exists for somebody else to use.

Why the cart is held server side

So a landing page on another origin shares one cart with the community app. That is the whole point: somebody fills a basket on your site and finishes on Cobuntu without losing it.

handoff and claim are the two halves of that crossing. merge folds a guest cart into a member's once they sign in.

A signed-in caller is identified by their session; an off-origin guest holds an X-Cobuntu-Cart-Token instead. One route pair serves both rather than two near-identical surfaces that would drift apart.

The signed-in session here is the community app's own (an Authorization: Bearer session on its origin). An embed member session (X-Cobuntu-Auth-Token) is not accepted, so from your own server every cart is a guest cart and merge answers 401. On the client the routes are cart.create, cart.get, cart.addItem, cart.updateItem (0 removes), cart.removeItem, cart.merge, cart.handoff and cart.claim; each takes the cart token and sends it as the header. See the Cart and handoff guide.

GET/productsList productsPublishable
GET/products/facetsProduct filter countsPublishable
GET/products/{idOrSku}Get a product (or course)Publishable
POST/cartCreate a cartPublishable
GET/cartGet the cartPublishable
POST/cart/itemsAdd an itemPublishable
PATCH/cart/items/{itemId}Set an item's quantityPublishable
DELETE/cart/items/{itemId}Remove an itemPublishable
POST/cart/mergeMerge a guest cart into the user's cartPublishable
POST/cart/handoffMint a cart handoff codePublishable
POST/cart/claimClaim a cart handoff codePublishable
POST/sales/{saleId}/refundRefund a sale (host-initiated)Secret
POST/segment-subscriptions/admin-cancelCancel a member's subscriptionSecret
POST/segment-subscriptions/change-tier/{userId}Change a member's tierSecret

Content

Articles, categories, courses, branding, city autocomplete, map, notifications, feed posts and broadcasts.

How Content works

Branding

Scope READ_PUBLIC

Colours, fonts and logos for the community, so an app on another origin can look like it belongs to it. Use it to theme your own surface to match the community.

const branding = await cobuntu.branding();
// { name, iconUrl, theme: { ...the community's theme document }, communityUrl }

theme is passed through exactly as the community stored it, so read the tokens you know and ignore the rest. It is {} when no theme is set. No visibility gate: branding is what every anonymous visitor already sees.

City autocomplete

Scope READ_PUBLIC. No member session needed.

City suggestions for a profile or sign-up form, so your site needs no maps key of its own.

const cities = await cobuntu.places.cities({ q: "lisb", lang: "en" });
// [{ placeId, description: "Lisbon, Portugal", mainText: "Lisbon",
//    secondaryText: "Portugal", countryCode: "PT" }]
  • Always 200. A lookup problem degrades to [], so the visitor can still type their city by hand.
  • q is trimmed and cut to 100 characters; under 2 characters answers []. At most 6 suggestions.
  • countryCode is ISO 3166-1 alpha-2, or null when it could not be resolved.
  • Limited to 60 lookups per minute per visitor IP, checked before the key. Debounce keystrokes (about 250 ms). A limited call is 429 with predictions: [].

Map (Atlas)

Scope READ_PUBLIC

The community map: its curated places (hotspots) and their categories, for a map on your own site. The list answers { hotspots }, filtered by a category or subcategory id from atlas/categories (which answers { categories }); the detail answers the hotspot itself. Each hotspot carries at least id, name, latitude and longitude; treat other fields as optional.

StatusMeaning
403 Atlas is not enabled for this communityThe community has no map. Hide the map section.
404 Hotspot not foundDetail only.

Not wrapped by @cobuntu/embed; call with fetch and the publishable or secret key.

Co-creation

Scope READ_PUBLIC. Optional member session in X-Cobuntu-Auth-Token.

May this viewer create an event, a product or a course here, and if not, why not. One entry per surface:

{
  "event":   { "enabled": true,  "requireApproval": false, "feeModel": "FLAT", "state": "allowed",  "reason": "ok" },
  "product": { "enabled": true,  "requireApproval": true,  "feeModel": "FLAT", "state": "locked",   "reason": "tier_locked" },
  "course":  { "enabled": false, "requireApproval": false, "feeModel": null,   "state": "disabled", "reason": "toggle_off" }
}

state is decided in order: disabled (show nothing), signin (no session), join (not an accepted member), locked (the member's tier may not create this kind), allowed. reason is ok, role_grant, not_member, toggle_off, tier_locked or null. Errors: 404 COMMUNITY_NOT_FOUND, 403 WRONG_COMMUNITY.

Marketplace categories

Reads are open (no scope). Mutations are gated on MARKETPLACE_CREATE.

This one is not under /api/v1. It is the unversioned surface, and it is the only documented endpoint here other than the cart handoff that sits outside the versioned API. Call it exactly as written above: it has a different prefix and a different auth posture from the rest.

Reads are deliberately open so a storefront filter can list categories without auth.

Articles

Scope READ_PUBLIC

Use it to run a journal or blog on your own domain from the community's content. Creating, editing and publishing articles (WRITE_ARTICLES) is on Server and app platform.

Feed posts

Scope WRITE_FEED

Use it to post into the community's feed from your own system.

Notifications

Scope WRITE_NOTIFICATIONS

Send a notification to a member through Cobuntu's own bell and email, rather than building a second delivery path your members have to learn.

Both of these are server-side scopes. Neither belongs in a browser.

GET/articlesList published articlesPublishable
POST/articlesCreate an articleSecret
PATCH/articles/{articleId}Update an articleSecret
POST/articles/{articleId}/publishPublish an articleSecret
POST/articles/{articleId}/unpublishUnpublish an articleSecret
GET/categoriesCommunity categoriesPublishable
GET/coursesList coursesPublishable
GET/brandingBrand tokensPublishable
GET/places/citiesCity autocompletePublishable
GET/atlas/hotspotsList map hotspotsPublishable
GET/atlas/hotspots/{hotspotId}Get a map hotspotPublishable
GET/atlas/categoriesMap hotspot categoriesPublishable
POST/notificationsSend a notification to a member's bellSecret
POST/feed/postsPost to the community feedSecret
POST/broadcastsSend an email broadcastSecret

Finance

A community's own ledger, settlements and totals, for external reporting. Secret keys only.

How Finance works

Use it to pull a community's Cobuntu revenue into your own reporting, next to whatever else the business runs on.

Three reads, all server-side, all scoped to one community.

Ledger entries

Scope READ_FINANCE, plus READ_FINANCE_PII for buyer identity

Every entry is one thing that happened: a sale the community made, a commission it earned on a member's sale, a refund, a donation. Amounts are integer cents with an explicit currency, never a decimal.

Whose money is it

Each entry carries a role, and you should read it before you read net.

roleWhat net is
SELLERthe community sold its own thing, and keeps the proceeds
COMMISSION_RECIPIENTa member sold, and the community keeps only its commission

This is not decoration. On a commission entry the sale's full value also appears, as gross, because it is useful context — but it is the member's money, not the community's. Summing gross across the ledger will give you a number that looks like revenue and is not. Sum net.

Refunds

A refund is reported on the sale it refunded, as a refund block carrying refundedAt, amount and status. It does not arrive as a separate entry.

A fully refunded sale reports net: 0. So summing net already excludes refunded money and you should not subtract the refund again — the refund block is there to tell you when and how much, not to be added up.

Settlement status

Every entry carries a settlement block saying where the money is. status is one of:

statusMeaning
ESCROWinside the refund window, not yet releasable
HOLDreleasable, but below the transfer threshold, so it is accumulating
ELIGIBLEpast the refund window, waiting for the next transfer run
PAIDtransferred — payoutId and paidAt are set
FAILEDa transfer was attempted and did not succeed
BLOCKEDpayout stopped; the money will not be transferred

BLOCKED is not a sanction. It is the payout engine recording that a sale will never pay out, and on this endpoint that always means the sale was fully refunded — usually before any transfer ran, so payoutId and paidAt stay null. Such an entry also carries a refund block and reports net: 0.

So do not surface BLOCKED to a community as-is. It reads like a compliance hold or a frozen account, and it means "refunded". Show the entry as refunded, and show net, not gross — a fully refunded sale still has its original gross, so rendering gross displays returned money as income.

Entries are keyed to rows that are only ever inserted, so an incremental sync by cursor never has to re-read the head of the list.

One caveat worth building for: figures can be restated in place. Stripe occasionally settles a fee differently from its original estimate, and when that happens the entry's fees change without a new entry appearing. Re-sync a full window periodically — monthly is ample — rather than trusting an incremental cursor to surface it.

Paging

QueryMeaning
?limit=page size, default 100, max 500 (a larger value is clamped, not refused)
?cursor=the nextCursor from the previous page
?from= / ?to=ISO-8601 bounds, inclusive

Entries come back oldest first, so a cursor walks forward in time and a sync never has to re-read the head of the list. Read nextCursor until hasMore is false.

Treat the cursor as opaque. Every entry also carries its own cursor, which is the value to resume from — do not build one out of id, which is prefixed and will not compare the way you expect.

A malformed cursor is a 400, deliberately. Restarting silently from the top would look like it worked and would duplicate everything downstream.

Buyers

Without READ_FINANCE_PII an entry carries only buyerRef: an opaque, stable handle you can group a buyer's purchases by, with no identity attached. With the scope, buyer.email and buyer.name are included as well.

The two are separate grants on purpose. Buyer identity turns a revenue feed into an export of members' purchase history, which is a different thing to agree to. buyerRef can also be null — on a guest checkout, and on any purchase whose buyer has since exercised erasure.

buyer.email prefers the address used at purchase time and falls back to the account's. buyer.name comes from the account, so it is null for a guest checkout and for a buyer who has been erased.

Settlements

Scope READ_FINANCE

What Cobuntu actually transferred, and when. An entry's settlement.payoutId joins to a row here.

This is not your bank statement

Money reaches a community in two hops, and only the first is ours:

  1. Cobuntu to the community's Stripe balance. That is a row in this endpoint.
  2. Their Stripe balance to their bank. That runs on the community's own Stripe payout schedule, batches however many of our transfers have accumulated, and we do not hold it.

So one bank credit routinely covers several rows here. One community saw nine of our transfers, spread over two months, arrive as a single bank line. If you are reconciling against a bank feed, expect to match many-to-one, and use the Stripe dashboard for the bank side.

Same paging contract as entries.

Totals

Scope READ_FINANCE

Aggregates for a window, broken down by stream and by entry type, and grouped by currency. Totals are never summed across currencies, because a community selling in two of them has two numbers, not one blended one.

Takes ?from= and ?to=; the window you asked for is echoed back.

This is computed from exactly the same entries /entries serves, so the two cannot disagree. If you need a breakdown this does not offer, aggregate the entries yourself rather than expecting another cut here.

What one bank credit covered

Scope READ_FINANCE

A remittance statement: the payouts, and their invoice numbers, behind a single line on the bank statement. JSON by default; add ?format=pdf for a document you can forward to an accountant.

You get a depositId from the payout.deposited webhook, which fires when the money reaches the bank.

totals has three numbers and the third is the interesting one:

FieldMeaning
coveredsum of the Cobuntu payouts in this deposit
depositthe bank credit itself
unaccounteddeposit - covered

unaccounted is normally zero. Non-zero is not an error: it means the deposit also carried money that did not come through Cobuntu, which happens when the same Stripe account takes payments directly. We show it rather than quietly letting the two figures disagree.

This is a statement, not an invoice. Every payout in it was already invoiced on its own, so nothing here is additional income and nothing here should be filed as a separate revenue document.

Keys

These are secret-key only. A publishable key is refused with SECRET_KEY_REQUIRED even when it somehow carries the scope, because a publishable key ships in browser bundles and this is a community's whole commercial history. A key belonging to another community gets WRONG_COMMUNITY.

Reads are logged. A community can be told which key read its finances and when, including whether buyer identity was served — the question a data processing agreement asks.

GET/finance/entriesThe community's ledgerSecret
GET/finance/payoutsSettlements to the communitySecret
GET/finance/deposits/{depositId}/statementWhat one bank credit coveredSecret
GET/finance/summaryTotals for a windowSecret

Apps

Installed-app platform: Sign in with Cobuntu handoff, member onboarding for apps, per-member app data, locale.

How Apps works

Routes for an app installed in a community and running on its own origin. They are secret-key only and act with the community's authority.

  • App handoff: sign a member into your app with a one-time code, and mint short-lived member tokens.
  • Onboarding: read what a member still owes the community and satisfy it in your own UI.
  • App data: per-member key/value storage, and writing a member's language back.
  • Server patterns: calling these routes with fetch, errors and the acting user.
GET/members/{userId}/onboardingA member's onboarding statusSecret
POST/members/{userId}/onboarding/dataSubmit a member's onboarding answersSecret
POST/members/{userId}/onboarding/rulesAccept the rules for a memberSecret
POST/apps/{appKey}/handoffMint a "Sign in with Cobuntu" codeSession
POST/api/v1/apps/handoff/exchangeExchange a handoff codeSecret
POST/apps/mint-member-tokenMint a member token for a known memberSecret
GET/members/{userId}/app-dataList your app's data for a memberSecret
GET/members/{userId}/app-data/{key}Get one valueSecret
PUT/members/{userId}/app-data/{key}Set one valueSecret
DELETE/members/{userId}/app-data/{key}Delete one valueSecret
PUT/members/{userId}/localeWrite a member's languageSecret

User keys

Reads authenticated by a personal user key rather than a community key.

How User keys works

Every other endpoint in Embed is addressed by community: you hold a key for :communityTag and you read that community's things. These four are addressed by person.

The case they exist for is a personal website. Someone who sells through Cobuntu wants their own products, courses and events on a page they control, under their own domain. That catalogue may span a community they sell into, but it is not that community's to hand out, so no community key can express it.

Authentication

A user key, not a community key. Same two header styles as everywhere else in Embed:

User keys start uk_; community keys start ck_. They are separate credentials against separate tables, and neither works where the other belongs — a community key on these routes is a 401, and a user key on a community route is a 401 too. The prefixes exist so a mix-up is visible before you deploy rather than after.

Scope READ_OWN — the only user-level scope there is. Note there is no user-level equivalent of the community ADMIN wildcard.

Generate and revoke keys in the Cobuntu app under Settings → API. The raw key is shown once and stored only as a hash, so it cannot be retrieved later.

The endpoints

me is not a placeholder. There is no :userId to supply — the owner is whoever the key resolves to. That is deliberate: an id in the path would be either redundant (it has to equal the key's user) or a way to ask for someone else's catalogue.

Courses

Through ?type=COURSE on the products list. A course is a product row with productType: COURSE, so there is no separate route:

Unfiltered, the products list includes courses and excludes event tickets. That matches how a profile works rather than how a marketplace shelf works: a course is a portfolio piece that keeps selling and belongs under its maker's name, where an event is a moment that has passed.

Filtering and pagination

The same parameters as the community lists: ?type, ?category, ?subcategory, ?limit (capped at 100, default 20), ?offset. Events also take ?upcoming=true.

Category ids come from the community categories endpoint. An unknown id returns an empty page, not a 400.

What these return

Exactly what is public. The key belongs to the owner, but its purpose is to render a public web page, so everything it returns is one fetch away from being public:

  • drafts are excluded
  • member-only items are excluded
  • tier backing rows are excluded (every paid tier has its own underlying product row; those are tickets, not listings)

An owner who wants to see their own drafts uses the app.

A product or event that is not yours answers 404, not 403. Ownership is part of the lookup rather than a check after it, so the row simply is not found — a 403 would confirm the id names something real.

Events are not filtered by community, products are

A small asymmetry worth knowing. GET /users/me/products returns products you own, excluding ones a community owns. GET /users/me/events returns events you created, including ones created inside a community — an event is created into a community far more often than not, and excluding those would empty the endpoint for exactly the person it is for.

GET/api/v1/users/me/productsYour products and coursesUser key
GET/api/v1/users/me/products/{idOrSku}One of your productsUser key
GET/api/v1/users/me/eventsYour eventsUser key
GET/api/v1/users/me/events/{idOrSlug}One of your eventsUser key