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
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-linkEmail a sign-in linkSecret
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 page | Response |
|---|---|
| Switched off | 404 { code: "PAGE_DISABLED" } |
| Members-only or private | 403 { code: "MEMBERS_ONLY", reason, hint } |
| Public | The 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
:communityTagis404, and a key from another community is403. 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_MEMBERSis 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/linkStart connecting 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
| Query | Meaning |
|---|---|
?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. qis trimmed and cut to 100 characters; under 2 characters answers[]. At most 6 suggestions.countryCodeis ISO 3166-1 alpha-2, ornullwhen 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
429withpredictions: [].
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.
| Status | Meaning |
|---|---|
403 Atlas is not enabled for this community | The community has no map. Hide the map section. |
404 Hotspot not found | Detail 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.
role | What net is |
|---|---|
SELLER | the community sold its own thing, and keeps the proceeds |
COMMISSION_RECIPIENT | a 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:
status | Meaning |
|---|---|
ESCROW | inside the refund window, not yet releasable |
HOLD | releasable, but below the transfer threshold, so it is accumulating |
ELIGIBLE | past the refund window, waiting for the next transfer run |
PAID | transferred — payoutId and paidAt are set |
FAILED | a transfer was attempted and did not succeed |
BLOCKED | payout 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
| Query | Meaning |
|---|---|
?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:
- Cobuntu to the community's Stripe balance. That is a row in this endpoint.
- 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:
| Field | Meaning |
|---|---|
covered | sum of the Cobuntu payouts in this deposit |
deposit | the bank credit itself |
unaccounted | deposit - 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.