Skip to main content

Workspace admin API

Every /api/v1/admin route for settings, members, invitations, keys, usage, billing, exports and the audit log, with its query, body, answer, scope and errors

The workspace admin API has eight resources under https://app.makesmores.com/api/v1/admin: settings, members, invites, keys, usage, billing, exports and audit. Paths below are relative to https://app.makesmores.com/api/v1. Send Authorization: Bearer <key> on every call and bodies as JSON with Content-Type: application/json.

note

The workspace admin API isn't enabled in production yet. Until it is, every /admin path answers 404 with the plain text 404 page not found, and a device sign-in that asks for admin:read answers 503 outreach_disabled.

On each path the method and the query together pick one mode, and each parameter belongs to the mode it picks. A request that matches no mode answers 400 invalid_request before S'more reads the key. While workspace administration is closed, every request to these paths gets the same plain text 404 page not found as a path S'more doesn't serve. API conventions covers these rules and what every route here shares: errors, caching, rate limits, billing, timeouts, retries and paging.

Every route here is free, including on a workspace with no credits left, so an owner can still buy credits or revoke a leaked key. These routes are REST only, and no MCP tool covers them.

The answers match what the S'more app shows an owner or admin, so their field names follow the app: settings, members, invitations, runs and exports answer in camelCase, and keys, usage, balance, credit history and the audit log in snake_case. The JSON samples below are shortened, so read fields by name and expect more of them.

Who can call these routes​

Every GET needs admin:read, and every other method needs admin:write. S'more then checks the role the key's person holds now, on each call:

  • A member gets 403 admin_required on every route, and so does an admin whose role was changed to member after the key was made. Changing a role revokes no key, but this check refuses it.
  • Some routes need the workspace owner, and the tables below mark them Owner. On buying credits or runs, the billing portal and exports, an admin gets 403 owner_required. On changing a role or transferring ownership, an admin gets the plain text 403 workspace operation forbidden, as in Errors.
  • A person who has left the workspace gets 401 at the key.

The console's key form doesn't offer admin:read or admin:write. A key with them comes from a device sign-in that an owner or admin approves, as in Keys for workspace administration. This path hasn't been tested end to end yet. A member can't approve these scopes. An OAuth grant made for an MCP client gets 401 on every route here.

In the tables below, Authority is the role the operation checks.

Settings​

Method and queryScopeAuthorityDoes
GET /admin/settings?section=workspaceadmin:readOwner or adminThe workspace's name and kind, and your role in it
GET /admin/settings?section=featuresadmin:readOwner or adminWhich optional parts of the S'more app are on for this workspace
PATCH /admin/settings?section=workspaceadmin:writeOwner or adminRenames the workspace

GET /admin/settings?section=workspace answers:

{
"id": "11111111-1111-4111-8111-111111111111",
"kind": "team",
"name": "Example team",
"role": "owner",
"createdAt": "2026-10-30T12:00:00Z",
"updatedAt": "2026-10-30T12:00:00Z"
}

kind is team or personal, and role is the caller's.

GET /admin/settings?section=features answers the switches the S'more app reads for the workspace:

{ "facebook": { "settings": false, "outbound": false }, "console": true, "scans": false }

console says whether the API console is on, and scans whether repository scans are open here.

PATCH /admin/settings?section=workspace takes only name, 1 to 80 characters, and answers 204 with no body.

Members​

Method and queryScopeAuthorityDoes
GET /admin/members, optional limit and cursoradmin:readOwner or adminThe workspace's members and their roles
PATCH /admin/members?id=admin:writeOwnerMakes a member an admin, or an admin a member
PATCH /admin/members?id=&action=transferadmin:writeOwner, in a team workspaceMakes another member the owner
DELETE /admin/members?id=admin:writeOwner, or an admin removing a memberRemoves someone from the workspace

A workspace has one owner at a time. id is a member's userId.

Read members​

GET /admin/members answers a page of members:

{
"items": [
{ "workspaceId": "11111111-1111-4111-8111-111111111111", "userId": "33333333-3333-4333-8333-333333333333", "name": "Alex Kim", "role": "owner", "createdAt": "2026-10-30T12:00:00Z" }
],
"nextCursor": "33333333-3333-4333-8333-333333333333"
}

limit is 1 to 100, 100 when you leave it out. Send nextCursor back as cursor, and stop when there's none.

Change a role​

PATCH /admin/members?id= takes {"role": "admin"} or {"role": "member"} and answers 204. Only the owner changes roles, and the owner's own role never changes this way. Ownership moves only by a transfer.

Changing a role revokes none of the person's keys. Each call checks the role they hold now, so after a change to member their keys get 403 admin_required wherever an owner or admin is needed. Invitations they sent that haven't been accepted are revoked.

Transfer ownership​

PATCH /admin/members?id=&action=transfer takes {"idempotency_key": "..."} and answers 204. The member named by id becomes the owner and the caller becomes an admin, in one change. A personal workspace can't be transferred.

Sending the same idempotency_key again answers 204 and changes nothing more, even though the caller is now an admin. A new idempotency_key from the former owner answers 403, because only the owner can transfer. Only the new owner can transfer it back.

Remove a member​

DELETE /admin/members?id= answers 204. An admin can remove members, and the owner can remove admins too. Nobody can remove the owner, and the owner can't remove themselves. Once removed, the person's keys answer 401 in this workspace.

Gmail after a role change​

A connected Gmail mailbox belongs to the person who connected it, and only an owner or admin can send from one. See Gmail accounts.

  • When its owner becomes a member, the mailbox is suspended. Emails that LinkedIn automation was set to send from it stop, and emails waiting to send from it are cancelled. An email whose result is unknown keeps that state. Making them an admin again doesn't reconnect the mailbox: they reconnect it themselves, and LinkedIn automation's email from it has to be turned back on.
  • When its owner is removed, the mailbox is suspended, and disconnected for good 7 days later unless they rejoin.
  • A transfer keeps the former owner's mailbox connected, since they stay an admin.

Invitations​

Method and queryScopeAuthorityDoes
GET /admin/invites, optional limit and cursoradmin:readOwner or adminThe workspace's invitations
POST /admin/invitesadmin:writeOwner, or an admin inviting a memberInvites an email address and sends the invitation
PATCH /admin/invites?id=&action=resendadmin:writeOwner, or an admin for a member's invitationSends the invitation again with a new link
PATCH /admin/invites?id=&action=revokeadmin:writeOwner, or an admin for a member's invitationRevokes the invitation. It sends nothing

id is an invitation's id. There's no mode that reads one invitation and no filter by status, so page through the list.

Read invitations​

GET /admin/invites pages like members, with nextCursor:

{
"items": [
{
"id": "55555555-5555-4555-8555-555555555555",
"workspaceId": "11111111-1111-4111-8111-111111111111",
"email": "jo@example.com",
"role": "member",
"inviterId": "33333333-3333-4333-8333-333333333333",
"generation": 1,
"expiresAt": "2026-11-06T12:00:00Z",
"createdAt": "2026-10-30T12:00:00Z",
"delivery": "provider_accepted"
}
]
}

acceptedAt, acceptedBy and revokedAt appear once they apply. generation counts the links sent, and delivery says how sending the latest one went. delivery is provider_accepted once the email was handed off for sending, or failed, suppressed or unknown when S'more can't say it went. None of them proves the person read it. An admin's list holds only invitations to the member role. No test covers that yet.

Invite someone​

POST /admin/invites takes:

FieldRules
emailRequired. The address to invite
roleRequired. admin or member. Only the owner can invite an admin
idempotency_keyRequired. Up to 200 characters

The answer names the invitation and how the email went:

{ "inviteId": "55555555-5555-4555-8555-555555555555", "generation": 1, "delivery": "provider_accepted" }

S'more emails the address a link, and the person accepts it in the S'more app before the invitation's expiresAt. The same idempotency_key with the same request answers the same result and sends no second email. An address that's already a member, or already has an open invitation here, answers 409, and so does an invitation past the workspace's seat limit.

Resend or revoke​

PATCH /admin/invites?id=&action=resend and action=revoke each take {"idempotency_key": "..."} and answer as an invite does. A resend sends a new link with a new expiresAt, adds 1 to generation and replaces the old link. A revoke sends nothing. The same idempotency_key again answers the same result and sends nothing, and a new idempotency_key on a resend sends another email.

Invitations and resends count toward the limits on Rate limits. A repeat with the same idempotency_key doesn't count.

API keys​

Method and queryScopeAuthorityDoes
GET /admin/keysadmin:readOwner or adminThe workspace's live keys
POST /admin/keysadmin:writeOwner or adminCreates a key from the calling key, and answers its token once
PATCH /admin/keys?id=admin:writeOwner or adminRenames a key, or lowers or sets its monthly credit cap
DELETE /admin/keys?id=admin:writeOwner or adminRevokes a key and every key made from it

id is a key's id from GET /admin/keys. These routes manage the same keys as the console's API keys page. While the API console is off, listing, changing and revoking keys answer 404 console_off, and so do usage, the balance, the credit history and buying credits. Usage of one key answers 404 not_found then.

Read keys​

GET /admin/keys takes no query and never answers a key's secret:

{
"keys": [
{
"id": "55555555-5555-4555-8555-555555555555",
"label": "Reporting script",
"key_hint": "4f2a",
"kind": "key",
"created_by": { "id": "33333333-3333-4333-8333-333333333333", "name": "Alex Kim" },
"scopes": ["agents:read"],
"month_credits": 120,
"running_credits": 0,
"monthly_credit_cap": 5000,
"last_used_at": null,
"expires_at": "2027-01-28T12:00:00Z",
"created_at": "2026-10-30T12:00:00Z"
}
]
}

month_credits is the key's spend this UTC month, and monthly_credit_cap is null when it has no cap. The list holds at most the 500 newest keys, newest first, and has no paging. No test covers that cap yet.

Create a key​

POST /admin/keys makes a key for the caller from the key the call uses. It takes:

FieldRules
client_labelRequired. 1 to 120 characters
scopesRequired. Only scopes the calling key holds, and never admin:write
monthly_credit_capOptional. A whole number of credits from 1. Required when the calling key has a cap

The answer carries the token, which S'more shows only here:

{
"access_token": "smr_...",
"token_type": "Bearer",
"expires_at": "2027-01-28T12:00:00Z",
"grant": {
"id": "55555555-5555-4555-8555-555555555555",
"client_label": "Reporting script",
"scopes": ["agents:read"],
"expires_at": "2027-01-28T12:00:00Z",
"created_at": "2026-10-30T12:00:00Z",
"monthly_credit_cap": 5000
}
}
  • A scope the calling key lacks, or admin:write, answers 403 insufficient_scope. So a key made here can't make keys itself.
  • A scope whose product is closed answers 503 outreach_disabled.
  • The new key ends no later than the calling key.
  • When the calling key has a cap, the caps of the keys made from it must fit inside it. A key revoked this month still counts at its cap or its spend, whichever is more, so revoking and making a key again frees nothing. Otherwise the answer is 400 invalid_request with field monthly_credit_cap.

A create has no idempotency key. If its answer is lost, the token can't be read again: find the key whose label is the client_label you sent on GET /admin/keys, revoke it, and make a new one.

Change a key​

PATCH /admin/keys?id= takes label, monthly_credit_cap or both, and answers the key as GET /admin/keys lists it. Over the API a cap can only be lowered, or set where there was none, whatever your role. Raising or clearing one answers 403 cap_raise_forbidden, so do that on the console's API keys page. Any other field answers 400 invalid_request.

Revoke a key​

DELETE /admin/keys?id= answers 204. Calls with the key stop at once, and every key made from it is revoked in the same change. An id that isn't one of this workspace's keys answers 404 key_not_found.

Usage​

Method and queryScopeAuthorityDoes
GET /admin/usage, optional from, to and group_byadmin:readOwner or adminThe workspace's usage, as on the console's Usage page
GET /admin/usage?key_id=, optional from, to and group_byadmin:readOwner or adminThe same report over one key's calls

from and to are UTC dates as YYYY-MM-DD, both included, and a report covers at most 366 days. Without them the report covers the last 30 days. group_by is model, the default, or day, key or member:

{
"from": "2026-10-01",
"to": "2026-10-30",
"group_by": "key",
"rows": [
{ "group": "55555555-5555-4555-8555-555555555555", "label": "Reporting script", "key_hint": "4f2a", "kind": "key", "requests": 120, "input_tokens": 0, "cached_tokens": 0, "output_tokens": 0, "items": 0, "credits": 120 }
],
"totals": { "requests": 120, "input_tokens": 0, "cached_tokens": 0, "output_tokens": 0, "items": 0, "credits": 120 },
"daily": [{ "date": "2026-10-30", "credits": 4 }]
}

key_id is a key's id. A revoked key's usage stays readable. A key_id that isn't one of this workspace's keys answers 404 not_found. The report has no CSV form here and no paging.

Billing​

Method and queryScopeAuthorityDoes
GET /admin/billing?section=balanceadmin:readOwner or adminCredits, the free allowance, this month's spend and the top-up packs
GET /admin/billing?section=ledger, optional cursoradmin:readOwner or adminThe credit history
GET /admin/billing?section=runsadmin:readOwner or adminRuns for lead agents and company research, and the run packs
POST /admin/billing?action=topupadmin:writeOwnerStarts buying credits, and answers a payment link
POST /admin/billing?action=checkoutadmin:writeOwnerStarts buying a run pack, and answers a payment link
POST /admin/billing?action=portaladmin:writeOwnerAnswers a link to the billing portal, for invoices and payment details

Balance​

GET /admin/billing?section=balance answers what the console's Overview and Billing pages show:

{
"free": 3820,
"paid": 25820,
"available": 29640,
"running_credits": 0,
"allowance": 10000,
"month_spend": 6180,
"packs": [],
"credits_enforced": false,
"active_keys": 3,
"role": "owner",
"topup_url": "https://app.makesmores.com/app/console/billing"
}

The fields shared with GET /credits mean the same as on Credits. packs lists the top-ups on sale, each with amount_cents and credits, and active_keys counts the workspace's live keys. Packs and their prices are on Credits.

Credit history​

GET /admin/billing?section=ledger answers 50 entries a page, newest first:

{
"items": [
{ "date": "2026-10-30T12:00:00Z", "reason": "usage", "label": "API calls", "running": false, "calls": 120, "deducted": 120, "credits": -120, "balance_after": 9880 }
],
"next_cursor": "..."
}

Send next_cursor back as cursor, and stop when there's none. Each day's API charges share one line, and each scan has its own.

Runs​

GET /admin/billing?section=runs answers the run balance that lead agents and company research use, in camelCase:

{
"enabled": true,
"runsRemaining": 20,
"freeRuns": 5,
"purchasedRuns": 15,
"adminGrantedRuns": 0,
"paidAccess": true,
"packs": [{ "id": "runs_25", "label": "25 runs", "runs": 25 }]
}

Each pack also carries amountCents, its price, as on the console.

Buy credits​

POST /admin/billing?action=topup takes:

FieldRules
amount_centsRequired. One of the amount_cents values in packs on the balance
attempt_idOptional. A UUID you make for this purchase. Send one so a retry answers the same link

The answer is a link to a payment page, {"url": "..."}. The owner opens it and pays there. The link itself adds nothing: the credits arrive once the payment completes, so read the balance to confirm them. Sending the same attempt_id and amount again answers the same link. Without an attempt_id, each request opens a new payment page. The same attempt_id with another amount answers 409 checkout_expired, so start again with a new one.

Buy runs​

POST /admin/billing?action=checkout takes two required fields: pack, a pack id from section=runs, and attemptId, a UUID you make for this purchase. Sending the same attemptId and pack again answers the same link. The answer is a payment link, {"url": "..."}, and runs arrive only after the payment completes. While billing is off, it answers 200 with {"code": "billing_disabled"} and no url, so check for code first.

Billing portal​

POST /admin/billing?action=portal takes no body, {}, or {"return_to": "console"} to come back to the console's Billing page afterwards. It answers {"url": "..."}, a new portal link on each call. While billing is off, it answers 503 billing_disabled.

Each purchase or portal link is real. Don't call these routes from a test or a loop.

Exports​

Method and queryScopeAuthorityDoes
POST /admin/exportsadmin:write, linkedin:inbox:read, x:inbox:read, facebook:inbox:read and gmail:readOwnerExports the workspace's data to a JSON file
GET /admin/exports?id=admin:read, linkedin:inbox:read, x:inbox:read, facebook:inbox:read and gmail:readOwnerThe export's state
GET /admin/exports?id=&view=downloadadmin:read, linkedin:inbox:read, x:inbox:read, facebook:inbox:read and gmail:readOwnerDownloads the export

An export holds private message text from LinkedIn, X and Facebook, and the workspace's Gmail email records, so requesting an export, reading its state and downloading it also need the four read scopes on the key. Without them the answer is 403 insufficient_scope. No test covers that answer on reading an export's state yet. There's no mode that lists exports, so keep each id.

POST /admin/exports takes {"idempotency_key": "..."} and answers 202:

{ "id": "55555555-5555-4555-8555-555555555555", "kind": "export", "state": "complete", "createdAt": "2026-10-30T12:00:00Z", "updatedAt": "2026-10-30T12:00:00Z" }

The same idempotency_key answers the same export. GET /admin/exports?id= answers the same shape, and errorCode appears if the export failed. Once state is complete, view=download answers the file as a JSON attachment named smore-export.json. Its top-level sections use camelCase names, such as workspace, agents, leads, apiUsage and auditEvents.

An id that isn't one of your exports of this workspace answers 404 not_found.

Audit log​

Method and queryScopeAuthorityDoes
GET /admin/audit, optional actor_id, resource_type, resource_id, action, from, to, limit and cursoradmin:readOwner or adminThe workspace's audit log, newest first

Each write on these routes that changes something adds one entry, naming the person and the key. Opening the billing portal changes nothing and adds none, and neither does a repeat, a refusal or a change made in the S'more app. Writes on the Lists and Gmail routes, such as a list import or connecting a mailbox, add entries too. surface says whether the write came over the API (api) or through an MCP tool (mcp).

{
"items": [
{
"id": "66666666-6666-4666-8666-666666666666",
"occurred_at": "2026-10-30T12:00:00Z",
"actor_user_id": "33333333-3333-4333-8333-333333333333",
"actor_name": "Alex Kim",
"grant_id": "55555555-5555-4555-8555-555555555555",
"grant_label": "Workspace admin",
"grant_kind": "cli",
"surface": "api",
"action": "workspace.member.role_changed",
"resource_type": "workspace_member",
"resource_id": "44444444-4444-4444-8444-444444444444",
"metadata": { "from_role": "member", "to_role": "admin" }
}
],
"next_cursor": "..."
}

Actions include workspace.renamed, workspace.member.role_changed, workspace.member.removed, workspace.ownership.transferred, workspace.invite.created, workspace.invite.resent, workspace.invite.revoked, workspace.key.created, workspace.key.updated, workspace.key.revoked, workspace.export.requested, billing.topup.started and billing.checkout.started, and outbound.list.imported and gmail. actions such as gmail.mailbox.connect_started. metadata holds ids, roles and counts, never a message, an email address or a secret. Entries are kept for 365 days.

actor_id is a member's userId. from and to are RFC 3339 times, such as 2026-10-01T00:00:00Z, and from is included and to isn't. limit is 1 to 100 entries a page, 50 when you leave it out. Send next_cursor back as cursor, and stop when there's none. A cursor only works with the filters it came from.

Errors​

Settings, members, invitations and the export request and download answer most refusals in plain text, as the S'more app's own routes do. Buying runs answers a body that doesn't fit in plain text too:

StatusBodyMeaning
400invalid workspace requestA body or id that doesn't fit. These bodies take only the fields above, each once
400bad jsonA body for buying runs that doesn't fit, such as one with another field
403workspace operation forbiddenYour role can't do this to that member or invitation, such as an admin changing a role
409workspace request conflictThe idempotency_key was used for a different request, the address is already a member or invited, or the workspace is at its seat limit
429Too many requests. Please try again shortly.Over an invitation limit. Wait for Retry-After
503service unavailableS'more can't answer now. Try again later

A transfer to someone who already owns as many workspaces as they can also answers 409 workspace request conflict. No test covers that answer yet.

The rest are JSON with a code:

StatusCodeMeaning
400invalid_requestA query, body or value that doesn't fit. field can name the part, such as monthly_credit_cap
400invalid_attempt, invalid_pack, pack_retiredA purchase's attempt id isn't a UUID, its amount isn't one of the packs, or its run pack is no longer sold
403admin_requiredOnly owners and admins can use these routes
403owner_requiredOnly the workspace owner can do this
403insufficient_scopeThe key lacks a scope this needs, or asked for a scope it can't give
403cap_raise_forbiddenRaising or clearing a cap needs the console
403workspace_forbiddenYou no longer have access to this workspace
404not_foundNo such member, invitation, key usage or export in this workspace, or a part of these routes this workspace can't use
404key_not_foundNo key with that id in this workspace
404console_offThe API console is off, so its key, usage and billing rows are too
409checkout_expiredThat attempt_id belongs to a different purchase
429rate_limitedOver a limit on Rate limits, such as the per-IP burst limit. Wait for Retry-After
503outreach_disabledA new key asked for a scope whose product is closed
503billing_disabledBilling is off, so the billing portal can't open
503portal_unavailable, checkout_unavailable, console_unavailableThe billing portal, a payment page or the console can't answer now, or a run pack id isn't one of the packs on section=runs. Check the request, then try again later

Reads are safe to repeat. Before you repeat a write whose answer was lost, see Recover from a failed or uncertain write.