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.
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_requiredon 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 text403 workspace operation forbidden, as in Errors. - A person who has left the workspace gets
401at 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /admin/settings?section=workspace | admin:read | Owner or admin | The workspace's name and kind, and your role in it |
GET /admin/settings?section=features | admin:read | Owner or admin | Which optional parts of the S'more app are on for this workspace |
PATCH /admin/settings?section=workspace | admin:write | Owner or admin | Renames 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /admin/members, optional limit and cursor | admin:read | Owner or admin | The workspace's members and their roles |
PATCH /admin/members?id= | admin:write | Owner | Makes a member an admin, or an admin a member |
PATCH /admin/members?id=&action=transfer | admin:write | Owner, in a team workspace | Makes another member the owner |
DELETE /admin/members?id= | admin:write | Owner, or an admin removing a member | Removes 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /admin/invites, optional limit and cursor | admin:read | Owner or admin | The workspace's invitations |
POST /admin/invites | admin:write | Owner, or an admin inviting a member | Invites an email address and sends the invitation |
PATCH /admin/invites?id=&action=resend | admin:write | Owner, or an admin for a member's invitation | Sends the invitation again with a new link |
PATCH /admin/invites?id=&action=revoke | admin:write | Owner, or an admin for a member's invitation | Revokes 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:
| Field | Rules |
|---|---|
email | Required. The address to invite |
role | Required. admin or member. Only the owner can invite an admin |
idempotency_key | Required. 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /admin/keys | admin:read | Owner or admin | The workspace's live keys |
POST /admin/keys | admin:write | Owner or admin | Creates a key from the calling key, and answers its token once |
PATCH /admin/keys?id= | admin:write | Owner or admin | Renames a key, or lowers or sets its monthly credit cap |
DELETE /admin/keys?id= | admin:write | Owner or admin | Revokes 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:
| Field | Rules |
|---|---|
client_label | Required. 1 to 120 characters |
scopes | Required. Only scopes the calling key holds, and never admin:write |
monthly_credit_cap | Optional. 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, answers403 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_requestwithfieldmonthly_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 query | Scope | Authority | Does |
|---|---|---|---|
GET /admin/usage, optional from, to and group_by | admin:read | Owner or admin | The workspace's usage, as on the console's Usage page |
GET /admin/usage?key_id=, optional from, to and group_by | admin:read | Owner or admin | The 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /admin/billing?section=balance | admin:read | Owner or admin | Credits, the free allowance, this month's spend and the top-up packs |
GET /admin/billing?section=ledger, optional cursor | admin:read | Owner or admin | The credit history |
GET /admin/billing?section=runs | admin:read | Owner or admin | Runs for lead agents and company research, and the run packs |
POST /admin/billing?action=topup | admin:write | Owner | Starts buying credits, and answers a payment link |
POST /admin/billing?action=checkout | admin:write | Owner | Starts buying a run pack, and answers a payment link |
POST /admin/billing?action=portal | admin:write | Owner | Answers 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:
| Field | Rules |
|---|---|
amount_cents | Required. One of the amount_cents values in packs on the balance |
attempt_id | Optional. 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 query | Scope | Authority | Does |
|---|---|---|---|
POST /admin/exports | admin:write, linkedin:inbox:read, x:inbox:read, facebook:inbox:read and gmail:read | Owner | Exports 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:read | Owner | The export's state |
GET /admin/exports?id=&view=download | admin:read, linkedin:inbox:read, x:inbox:read, facebook:inbox:read and gmail:read | Owner | Downloads 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 query | Scope | Authority | Does |
|---|---|---|---|
GET /admin/audit, optional actor_id, resource_type, resource_id, action, from, to, limit and cursor | admin:read | Owner or admin | The 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:
| Status | Body | Meaning |
|---|---|---|
| 400 | invalid workspace request | A body or id that doesn't fit. These bodies take only the fields above, each once |
| 400 | bad json | A body for buying runs that doesn't fit, such as one with another field |
| 403 | workspace operation forbidden | Your role can't do this to that member or invitation, such as an admin changing a role |
| 409 | workspace request conflict | The idempotency_key was used for a different request, the address is already a member or invited, or the workspace is at its seat limit |
| 429 | Too many requests. Please try again shortly. | Over an invitation limit. Wait for Retry-After |
| 503 | service unavailable | S'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:
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | A query, body or value that doesn't fit. field can name the part, such as monthly_credit_cap |
| 400 | invalid_attempt, invalid_pack, pack_retired | A purchase's attempt id isn't a UUID, its amount isn't one of the packs, or its run pack is no longer sold |
| 403 | admin_required | Only owners and admins can use these routes |
| 403 | owner_required | Only the workspace owner can do this |
| 403 | insufficient_scope | The key lacks a scope this needs, or asked for a scope it can't give |
| 403 | cap_raise_forbidden | Raising or clearing a cap needs the console |
| 403 | workspace_forbidden | You no longer have access to this workspace |
| 404 | not_found | No such member, invitation, key usage or export in this workspace, or a part of these routes this workspace can't use |
| 404 | key_not_found | No key with that id in this workspace |
| 404 | console_off | The API console is off, so its key, usage and billing rows are too |
| 409 | checkout_expired | That attempt_id belongs to a different purchase |
| 429 | rate_limited | Over a limit on Rate limits, such as the per-IP burst limit. Wait for Retry-After |
| 503 | outreach_disabled | A new key asked for a scope whose product is closed |
| 503 | billing_disabled | Billing is off, so the billing portal can't open |
| 503 | portal_unavailable, checkout_unavailable, console_unavailable | The 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.