Skip to content

GraphQL API

AlphOne serves one GraphQL API. Everything the interface does goes through it, so anything the app can do, your program can do.

POST https://your-domain/api/graphql
Content-Type: application/json

Send {"query": "...", "variables": {...}} and read data and errors back.

An AI agent can read AlphOne without writing queries, see AI agents.

Two credentials work, and every operation needs one.

An API token, for programs. Create one on the AlphOne host and send it as a bearer token:

Terminal window
alphone token:create -email you@example.com -name "my integration"
Authorization: Bearer a1_...

A token acts as the user who created it, and never reaches further than that user does. See Roles for what a role may do. Work it creates records token:<name> in originSource, so automated work stays distinguishable from typed work.

A token narrows that authority twice over. Each -scope grants one area and whether the token may write there, and an operation touching an area the token does not hold is refused with code UNAUTHORIZED naming the scope it needed. Without -scope the token holds every area. A token minted with alphone token:create also expires, after ALPHONE_TOKEN_TTL_DAYS days, ninety by default, unless -ttl says otherwise. apiTokenCreate without ttlDays mints one that never expires. An expired token is refused as invalid token. Tokens that predate scopes keep full authority and no expiry until you replace them, so run alphone token:list -all to see which ones those are.

Token management is the one thing a token can never do, whatever its scopes. apiTokens, apiTokenCreate and apiTokenRevoke require a session, so a leaked token cannot mint its own replacement.

A scope grants an entry point and everything that entry point reaches. The check runs on the fields an operation starts from, not on every field it walks through, so contacts:read reads a contact’s open tasks through contact { tasks { ... } } without holding tasks:read. Grant the narrowest set of entry points an integration needs, and read a scope as the doorway it opens rather than a fence around one table.

A session cookie, for browsers. Call login and the reply sets it:

mutation {
login(email: "admin@example.com", password: "password1234") {
me { id email name }
}
}
{
"data": {
"login": {
"me": {
"id": "019fdd4b-b6a3-710d-85dd-b597d7a9b453",
"email": "admin@example.com",
"name": "Admin"
}
}
}
}

login and the locale query are the only operations an anonymous caller may run. Anything else answers UNAUTHENTICATED with HTTP 200, because a GraphQL error is not an HTTP error:

{
"errors": [
{ "message": "authentication required", "extensions": { "code": "UNAUTHENTICATED" } }
],
"data": null
}

Always read errors. A 200 does not mean it worked.

An account holds one role, and the role decides what the account may do. A stock deployment names two. An admin manages users and webhooks. A member works the product, which is contacts, tasks, and whatever your plugins add. A plugin may declare roles of its own, so do not assume the list stops at two.

An account can also hold no role at all, which happens to accounts made before roles existed. me answers an empty role and an empty capabilities for it. Such an account still signs in, still reads me and logout, and still works every field that names no capability, which today is most of the product. What it cannot reach is the fields a capability guards, which today are user management and creating webhooks. Give it a role and it gains whatever that role holds.

What a role may do is a set of named capabilities. me answers the ones the calling account holds, so a client asks what it may do rather than guessing from the role’s name:

query { me { role capabilities grantable } }

capabilities names what the account may do. grantable names the roles it may give another account, which is every role whose capabilities it already holds itself. An admin cannot grant a role that reaches further than its own.

Four operations need the manage_users capability: invite, resendInvite, setUserDisabled and setUserRole. An account without it is refused:

{
"errors": [
{
"message": "admin required",
"extensions": {
"code": "UNAUTHORIZED",
"scope": "users:write",
"capability": "manage_users",
"reason": "capability_missing",
"meta": { "scope": "users:write", "capability": "manage_users" }
}
}
],
"data": null
}

createWebhook needs the manage_webhooks capability, which the admin role holds in a stock install. An account without it is refused the same way, with scope naming webhooks:write and capability naming manage_webhooks. The same capability widens webhooks and deleteWebhook. A holder lists and revokes every webhook of its workspace, and any other account only the ones it created, see webhooks.

The message reads admin required whichever capability was missing, because it has said that since before capabilities existed and clients match on it. Read capability rather than the message to learn what the account’s role actually fell short of. Holding the admin role is not what the field asks for, holding that capability is, and a plugin-declared role holding it passes just as well.

The scope extension still names what the field wanted, so a caller always learns which area an operation acts in. An operation refused over a token’s scopes carries no capability, so the two halves stay distinguishable. Minting a wider token does not help here. A token cannot carry more authority than the user it acts as.

Listing users stays open to members. A member sees who its colleagues are, which is what assigning a task to one of them needs.

Nobody changes its own role, and nobody disables its own account. Both are refused with code VALIDATION and the messages you cannot change your own role and you cannot disable your own account.

Writing a role the caller does not hold itself is refused the same way, with that role is beyond your own. A caller may only write a role whose capabilities it already holds, and may only touch an account whose current role it likewise holds. So an admin can neither grant a role reaching further than admin nor demote or disable an account already holding one, whether that role came with the product or with a plugin.

A write that would leave no enabled account able to manage users is refused with the last admin cannot be unseated.

invite takes an optional role. Leaving it out starts the account as a member.

A role narrows the user. A scope narrows what a token carries of that user’s authority. An operation runs only when both allow it.

The callerWhat it holdsReaching invite
An admin’s sessionthe capability, and no token to narrow ityes
An admin’s token scoped users:writebothyes
An admin’s token scoped contacts:readthe capability but not the scopeno, scope required: users:write
A member’s token scoped *the scope but not the capabilityno, admin required

The token is checked first. A caller holding neither is told about the scope, because that is the half it can fix on its own.

AlphOne answers each reader in one locale. locale resolves it: the signed-in account’s stored choice wins, else the closest match to the Accept-Language header, else en-US. The query is open to anonymous callers, so a login screen can ask before anyone signs in.

query { locale }

supportedLocales lists every locale AlphOne serves, the default first, so a screen can offer the choice without hardcoding the list.

setLocale stores the calling account’s choice and answers it back. It takes a locale from the supported list and refuses anything else with the reason locale_unknown, naming the list in meta.supported.

mutation { setLocale(locale: "es-ES") }
ScalarFormatExample
UUIDRFC 4122 text0198d000-0000-7000-8000-000000000001
DateTimeRFC 3339 in UTC2026-08-09T16:21:31Z
Datecalendar day, no time2026-08-06
Uploadmultipart file partsee uploads below

Lists that can grow are connections. You ask for first and walk with after.

query($before: Date!, $first: Int!, $after: String) {
tasks(dueBefore: $before, status: "all", first: $first, after: $after) {
edges {
cursor
node { id title dueOn status }
}
pageInfo { hasNextPage endCursor }
}
}
{
"data": {
"tasks": {
"edges": [
{
"cursor": "eyJkdWVfb24iOiIyMDI2LTA4LTA2IiwiaWQiOiIwMTk4ZDAwMC0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwMDEifQ",
"node": {
"id": "0198d000-0000-7000-8000-000000000001",
"title": "Chase the overdue invoice",
"dueOn": "2026-08-06",
"status": "open"
}
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "eyJkdWVfb24iOiIyMDI2LTA4LTA2IiwiaWQiOiIwMTk4ZDAwMC0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwMDEifQ"
}
}
}
}

Pass endCursor back as after for the next page, and stop when hasNextPage is false. Treat a cursor as opaque, it is only meaningful to the field that issued it. first accepts 1 to 200 and defaults to 50. The operator can move both numbers with ALPHONE_GRAPH_PAGE_SIZE and ALPHONE_GRAPH_PAGE_CAP, and a size outside them answers first_out_of_range naming the range.

Listing tasks takes exactly one of date, dueBefore or contactId. Sending none or two answers VALIDATION.

A screen that numbers its pages reads contactPage instead of contacts. It answers one page of contacts, how many contacts match in all, and the size of the page.

query($q: String, $offset: Int) {
contactPage(
q: $q
channels: ["whatsapp"]
orderBy: CREATED_AT
order: DESC
limit: 20
offset: $offset
) {
items { id name createdAt identities { channel } }
total
limit
}
}
{
"data": {
"contactPage": {
"items": [
{
"id": "0198d000-0000-7000-8000-000000000002",
"name": "Maria Perez",
"createdAt": "2026-09-30T09:00:00Z",
"identities": [{ "channel": "whatsapp" }]
}
],
"total": 1,
"limit": 20
}
}
}

q searches the way contacts does, by name, by identity name and by the digits of an identifier. channels keeps the contacts reachable on any of the channels it names. orderBy takes NAME or CREATED_AT, order takes ASC or DESC, and together they default to the name from A to Z. limit follows the same range as first. offset skips that many contacts, so the next page starts at offset plus limit, and the last page is the one where that sum reaches total. The identities of every contact on a page load in one read.

adminSettings answers the settings the admin screens read once when they start.

query {
adminSettings {
toastMilliseconds listPageSizes listPageSize contactPageCap formatLocale
}
}
FieldWhat it holdsSet with
toastMillisecondsHow long a confirmation toast stays, 6000 by defaultALPHONE_TOAST_DURATION
listPageSizesThe page sizes a list offers, 10, 20, 50 and 100 by defaultALPHONE_LIST_PAGE_SIZES
listPageSizeThe page size a list opens on, 20 by defaultALPHONE_LIST_PAGE_SIZE
contactPageCapThe most contacts one contactPage answers, 200 by defaultALPHONE_GRAPH_PAGE_CAP
formatLocaleThe locale the screens write dates, times, numbers and money in, es-ES by defaultALPHONE_FORMAT_LOCALE

Dates in the data stay ISO 8601, such as 2026-09-30, in every query, mutation and event.

mutation($input: CreateTaskInput!) {
createTask(input: $input) {
replay
task { id title dueOn status }
}
}
{
"data": {
"createTask": {
"replay": false,
"task": {
"id": "019fe75e-4cc1-715f-8a50-dfeff9695b98",
"title": "Read the reference",
"dueOn": "2026-08-10",
"status": "open"
}
}
}
}

Give CreateTaskInput an originEventId and the creation becomes repeatable. A second create with the same origin returns the task already stored and replay: true, rather than a duplicate.

Every error carries a code in its extensions.

CodeMeaning
UNAUTHENTICATEDNo usable credential, or the operation is not login
UNAUTHORIZEDThe caller does not reach the field. scope required means the token lacks the scope scope names, admin required means the account’s role holds no capability the field needs
VALIDATIONThe input was refused. message names the field or rule
NOT_FOUNDThe id names nothing
CONFLICTThe write clashes with what is stored, such as an identity another contact holds or a full list. For an identity, ownerContactId names the owner
RATE_LIMITEDToo many attempts. retryAfter is in seconds
COMPLEXITY_LIMIT_EXCEEDEDThe query asks for too much, see limits below
INTERNALAlphOne failed. The message is deliberately bare

A refused input looks like this:

{
"errors": [
{
"message": "contact: empty name",
"path": ["createContact"],
"extensions": { "code": "VALIDATION", "reason": "contact_name_required" }
}
],
"data": null
}

path names the field that failed, which matters when one operation asks for several.

One refusal never reaches the graph. A write a browser sends from a page at another origin is refused before GraphQL reads it, with HTTP 403 and a plain JSON body rather than an errors list. A program sending a token without browser headers never meets it. See cross-origin writes.

{ "error": "cross-origin request refused", "code": "request_cross_origin" }

Beside the coarse code, a refused operation names a reason, a short fixed name for the exact condition, and meta, the values its message mentions. A client should match on reason and read meta, never parse the message. The message can be reworded, a reason never is. An INTERNAL error names no reason, its message and shape are deliberately bare.

{
"errors": [
{
"message": "graph: first must be between 1 and 200",
"extensions": {
"code": "VALIDATION",
"reason": "first_out_of_range",
"meta": { "min": 1, "max": 200 }
}
}
],
"data": null
}

The reasons the core answers with:

ReasonMetaWhen
authentication_requiredno usable credential
credentials_invalida login that did not match
rate_limitedretryAftertoo many attempts
scope_missingscopethe token lacks the area
capability_missingscope, capabilitythe role lacks the capability
contact_name_requireda contact needs a name
identity_channel_requiredan identity needs a channel
identity_identifier_requiredan identity needs an identifier
identity_takenownerContactIdthe identity belongs to another contact
channel_not_writablethe channel accepts no writes
identity_not_foundthe id names no identity
contact_not_foundthe id names no contact
task_title_requireda task needs a title
task_priority_unknownthe priority is not one AlphOne knows
task_status_unknownthe status is not one AlphOne knows
task_filter_choice_requiredtasks take exactly one filter
task_not_foundthe id names no task
origin_source_requiredan origin event needs a source
event_unknownthe event name is not one AlphOne knows
webhook_url_invalidthe webhook URL does not parse
webhook_url_internalthe webhook URL is an internal IP address the operator has not allowed, see webhooks
webhook_events_requireda webhook needs at least one event
webhook_not_foundthe id names no webhook
first_out_of_rangemin, maxthe page size is outside the range
locale_unknownsupportedthe locale is not one AlphOne serves
cursor_malformedthe cursor is not one a field issued
value_malformeda scalar did not parse
token_name_requireda token needs a name
token_not_foundthe id names no token
scope_malformeda scope is area colon access
scopes_requireda scoped token needs at least one
area_unknownthe area is not one the schema declares
lifetime_negativea lifetime is zero or more days
lifetime_too_longmaxDaysthe lifetime is past the cap
email_invalidthe address does not parse
email_takenthe address belongs to another account
name_requiredan account needs a name
name_too_longmaxthe name is past the cap
password_too_shortminthe password is under the floor
password_too_longmaxthe password is past the cap
user_not_foundthe id names no account
self_disable_refusednobody disables its own account
self_role_refusednobody changes its own role
last_privileged_refusedthe last account able to manage users stays
role_beyond_reachthe role holds more than the caller does
role_unknownthe role is not one the deployment names

The stock plugins add their own:

ReasonMetaWhen
field_name_malformeda field name is camelCase
field_label_requireda field needs a label
field_label_too_longthe label is past the cap
field_kind_unknownthe kind is not one the plugin knows
field_name_reservedthe name is one reservedFieldNames lists
field_sub_fields_requireda repeater needs at least one sub field
field_sub_fields_unexpectedonly a repeater holds sub fields
field_sub_field_nesteda sub field cannot be a repeater
field_sub_field_name_invalida sub field name is camelCase
field_sub_field_name_takentwo sub fields of one repeater share a name
field_sub_field_name_reserveda sub field is named id, which holds each entry’s own id
field_name_takenanother definition holds the name
field_kind_lockedan archived definition pins the kind and sub fields
field_not_foundthe id names no live definition
field_order_incompletethe ids are not exactly the live definitions, each named once
field_unknownno live definition or sub field holds the name
value_kind_mismatchthe value does not match the declared kind
values_not_an_objectvalues arrive as an object of names
field_not_a_repeaterthe field keeps no list of entries
field_entry_emptyevery cell of the entry is blank
field_entry_not_foundthe id names no entry in that field of the contact
field_entries_fullmaxthe list already holds the most entries it may
field_repeater_entries_onlya repeater takes its entries one at a time, never through writeContactFields
message_content_requireda message needs text
conversation_not_foundthe id names no conversation
upstream_failedthe messaging platform did not accept
import_not_foundthe id names no import
file_too_largemaxBytesthe upload is past the cap
file_unreadablethe file is not a CSV or spreadsheet AlphOne reads
mapping_invalidthe mapping does not fit the columns
mapping_requiredcommitting needs a mapping first
mapping_lockedthe import no longer accepts a mapping
already_committedthe import was committed before

A plugin you install may add more, each documented by the plugin.

Each staged row of an import answers a reason. It is null for a row that needs none, or an object holding a stable code and a meta object with the values the reason names. A client matches on the code and builds its own sentence from meta, and never parses a sentence. The AlphOne screens show each code in the reader’s language.

CodeMetaWhen
row_cell_count_mismatchcells, columnsthe row holds more or fewer cells than the header lists. The row still imports
row_quote_misplacedlinea quote mark sits out of place on that line of the file, so the row was left empty
row_malformedAlphOne could not read the row, so it was left empty
row_incompletethe row has no name or no address
contact_details_invalidthe row holds a name or an address AlphOne cannot use
identity_takenanother contact already holds an address of the row
identity_taken_byownerNamethe named contact already holds an address of the row. The row’s contactId points at that contact
value_kind_mismatchfield, kinda cell does not fit the kind its field declares
field_unknownfieldsthe row fills fields that no longer exist
field_text_refuseda field did not accept a cell and named no reason AlphOne reads
legacy_texttexta reason stored before reasons were codes, kept as it was written
LimitValue
Query complexity2500 per operation
Concurrent operations20 per user
Concurrent subscriptions5 per user
Operation deadline60 seconds
JSON body1 MiB
Multipart body6 MiB

Complexity prices a page as the number of rows asked for times the cost of one row, so a wide selection over a large page is what trips it:

{
"errors": [
{
"message": "operation has complexity 2800, which exceeds the limit of 2500",
"extensions": { "code": "COMPLEXITY_LIMIT_EXCEEDED" }
}
],
"data": null
}

Ask for fewer rows or fewer fields. Over a budget, AlphOne answers HTTP 429 with a Retry-After header.

Subscriptions arrive over Server-Sent Events on the same endpoint. Send Accept: text/event-stream and no JSON accept type, or you get a JSON answer instead of a stream.

subscription {
coreEvent
}

coreEvent streams the names of events you may see. Task events reach only their assignee. A stream is closed after 5 minutes, so reconnect and refresh.

A mutation taking a file uses multipart/form-data following the GraphQL multipart request specification, rather than plain JSON. importUpload is the one that ships.

Introspection is on, so any GraphQL client can read the schema and give you completion. The interactive query page is off by default and enabled with ALPHONE_DEV_GRAPHIQL, for development only.

{
__schema { queryType { name } }
}

The full field list lives in the schema itself rather than on this page, so it cannot drift. Point a client at the endpoint, or read graph/schema.graphql in the repository.

AreaReadsWrites
Sessionmelogin, logout
Localelocale, supportedLocalessetLocale
Usersusersinvite, resendInvite, setUserDisabled, setUserRole
Contactscontacts, contactPage, contactcreateContact, renameContact, addContactIdentity, deleteContactIdentity
Taskstasks, taskcreateTask, updateTask
WebhookswebhookscreateWebhook, deleteWebhook
Fieldsfields, reservedFieldNames, Contact.fielddefineField, archiveField, orderFields, writeContactFields, addContactFieldEntry, updateContactFieldEntry, deleteContactFieldEntry
Importsimports, importJob, importFieldsimportUpload, importSetMapping, importCommit
WhatsAppwhatsAppConversations, whatsAppConversationwhatsAppSendMessage
Admin settingsadminSettings
Versionversion

Subscriptions are coreEvent, whatsAppConversationEvent and whatsAppMessageReceived.

Plugins add their own fields, so an instance may serve more than this. See extending the graph.