Welcome
Memair stores a personâs quantified-self data: biometrics, emotions, journals, locations, medications, physical and digital activities, recommendations and insights. Apps read and write that data through one GraphQL API, https://memair.com/graphql, with an OAuth 2.0 access token that the person grants.
To start:
- Get a token. Quick Access gives you a temporary one for your own account, and Otto is a sandbox account anyone can use.
- Send a query. The curl example runs as written with Ottoâs token.
- Register an app before other people use it. Create an App starts the OAuth flow.
These pages explain how the API works and give one or two examples for each kind of record. For every field and argument, use the documentation explorer in GraphiQL, which reads the live schema.
Paste a token into âadd to examplesâ in the sidebar to put it into every example on the page.
Found a mistake? Open an issue or a pull request.
Deprecation Policy
Memair adds to the API without notice, and removes from it only after a deprecation period of at least six months.
Additive changes
A new type, field, optional argument or enum value ships when it is ready. Write clients that ignore fields and enum values they do not know.
Breaking changes
A breaking change removes or renames a type, field, argument or enum value, makes an argument required, or narrows what a field returns or an argument accepts. It goes through deprecation:
- Memair adds the replacement and marks the old element deprecated. The deprecation reason names the replacement and the earliest removal date.
- The earliest removal date is six months after the deprecation reaches production.
- Memair removes the element after that date, once it has had no use in the previous 30 days and no supported release of Memairâs iOS app uses it.
Memair counts each use of a deprecated field, argument or enum value per OAuth application, which is how it knows when an element has gone quiet. GraphiQLâs documentation explorer marks deprecated elements and shows their reasons. Introspection returns them with isDeprecated and deprecationReason.
Notices
These are deprecated, with removal after 2027-05-01.
| Deprecated | Use instead |
|---|---|
Biometrics, DigitalActivities, Emotions, Journals, Locations, Medications, PhysicalActivities, Recommendations |
biometrics, digitalActivities, emotions, journals, locations, medications, physicalActivities, recommendations, which page by cursor (Pagination) |
CreateBiometric, CreateDigitalActivity, CreateEmotion, CreateJournal, CreateLocation, CreateMedication, CreatePhysicalActivity, CreateRecommendation |
createBiometric and the other single-record creates, which answer validation failures as field errors (Create, Update and Delete) |
DeleteRecommendation |
deleteRecommendation |
Bug Beer Bounty đđşâď¸
Find a Bug and Iâll buy you a beer đť and give you an honourable mention here.
Bugs
Double inputs for BiometricsPratyusa RayGraphiQL sometimes hangs on âLoadingâŚâ, temporary fix is to hit refresh
API Limits
Memair limits how often each access token can call the API and how much one request can ask for. A request over a rate limit gets HTTP 429.
Rate limits
| Endpoint | Limit | Counted per |
|---|---|---|
POST /graphql |
20 requests in 10 seconds, and 300 in 5 minutes | access token |
POST /oauth/token, exchanging a code |
10 requests a minute | IP address |
POST /oauth/token, refreshing a token |
10 requests a minute | refresh token |
| GraphiQL | 60 requests a minute | signed-in user |
A request without a token counts against its IP address.
Request size
- A record list returns at most 1,000 records per page, and 25 when
firstis not given (Pagination). Createaccepts up to 10,000 records per request. Send at most 1,000 (Bulk Create).- A query may nest fields at most 13 levels deep, and select at most 300 fields in total. A query over either limit fails without running.
{
"error": {
"message": "Rate limit exceeded"
},
"data": {}
}
Quick Access
A signed-in user can generate a temporary access token for their own account. The token has every scope and expires after 30 days. Each visit to the page generates a new one.
Use it to try the API against your own data. An app that other people use should register and ask each user for a token through OAuth.
These tokens belong to an application named Docs Sandbox. To revoke them all, remove Docs Sandbox under Account > Installed.
Otto the Sandbox User
Otto is a shared sandbox account with data to explore. Anyone can use Ottoâs access token:
0000000000000000000000000000000000000000000000000000000000000000
The examples in these docs use it, so they run as written. The token never expires and cannot be revoked. It has every scope except medication_read and medication_write, so the medication examples need your own token.
To see Ottoâs data in the browser, choose âLogin as Sandbox Userâ in the Developer Tools menu on memair.com.
Everyone who uses Otto sees everything recorded on the account, so do not record anything private there. Memair resets Ottoâs data periodically.
Create an App
An app that other people use needs its own OAuth client. To register one:
- Sign in to Memair.
- Open Developer Tools > API Keys > New Application.
- Enter a name, the appâs redirect URIs (one per line), and the scopes it needs.
The applicationâs page then shows its Client ID and Client Secret. Keep the secret on your server.
Redirect URIs must use https, or a custom scheme such as com.example.app:/oauth/callback. Memair rejects http URIs, http://localhost included.
Apps registered this way are confidential clients. Their access tokens do not expire, and they get no refresh token. PKCE is optional for them, but a code_challenge must use the S256 method.
Ask only for the scopes the app uses. The user sees them on the consent screen.
Scopes
Each access token carries scopes, and each part of the API checks for one. A request outside the tokenâs scopes gets the error Access Token does not have permission for requested scope, with no data for that field.
| Scope | Allows |
|---|---|
biometric_read, biometric_write |
reading, or creating, changing and deleting, biometrics |
digital_activity_read, digital_activity_write |
the same for digital activities |
emotion_read, emotion_write |
the same for emotions |
journal_read, journal_write |
the same for journals |
location_read, location_write |
the same for locations |
medication_read, medication_write |
the same for medications |
physical_activity_read, physical_activity_write |
the same for physical activities |
recommendation_read, recommendation_write |
the same for recommendations |
recommendation_action |
marking a recommendation actioned or ignored with ActionRecommendation |
recommendation_delete |
the deprecated DeleteRecommendation only; deleteRecommendation needs recommendation_write |
insight_read, insight_write |
reading and creating insights |
preset_read |
listing the userâs presets with presets and running one with runPreset; a preset runs with the tokenâs other scopes, so it writes only what the token may write |
user_details |
the UserDetails query: the userâs email, time zone and record counts |
public |
granted to every token; nothing checks it |
A write scope covers Create, createX, updateX and deleteX for its records. It does not include the read scope: a token with emotion_write alone can create emotions but not list them.
The AccessToken query, the *Types queries, MedicationForms, recordResources, RevokeAccessToken and CreateStatus need no scope. CreateStatus returns only imports that the same app started. recordResources reports, for each record resource, whether the token may read (readable) and write (writable) it.
Request Authorization
Send the user to Memairâs authorization page in a browser:
https://memair.com/oauth/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&scope=location_read+location_write&state=RANDOM_VALUE
Parameters
- response_type
code. The authorization code grant is the only one Memair supports.- client_id
- the appâs Client ID
- redirect_uri
- one of the appâs registered redirect URIs, URL-encoded
- scope
- the scopes the app needs, separated by spaces (
+in a URL). Each must be one the app registered. Withoutscope, the token gets onlypublic. - state
- a random value the app keeps and compares when the user comes back, which protects the redirect against cross-site request forgery (RFC 6749 §10.12)
- code_challenge, code_challenge_method
- optional for apps registered through API Keys. When sent, the method must be
S256(RFC 7636).
The user signs in if needed and approves the scopes. Memair then redirects to redirect_uri with code and state in the query string. If the user declines, the redirect carries error=access_denied instead.
A code is valid for 10 minutes and can be exchanged once. Request an Access Token describes the exchange.
Request an Access Token
Exchange the code from the redirect for an access token. Make this request from your server, because it carries the client secret.
curl https://memair.com/oauth/token \
-d grant_type=authorization_code \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d code=CODE_FROM_THE_REDIRECT \
-d redirect_uri=https://example.com/callback
Parameters
- grant_type
authorization_code- client_id, client_secret
- the appâs credentials from API Keys
- code
- the
codefrom the redirect - redirect_uri
- the same redirect URI the authorization request used
- code_verifier
- required when the authorization request sent a
code_challenge
Store the access_token for the user. A token issued to an app registered through API Keys does not expire, so the response has no expires_in and no refresh_token.
The token endpoint allows 10 requests a minute from one IP address.
{
"access_token": "Yq3nC0bW1u7mH2xvJk9TzR4pL8sQe6dAfG5hN0oVwXc",
"token_type": "Bearer",
"scope": "location_read location_write",
"created_at": 1793111916
}
{
"error": "invalid_grant",
"error_description": "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."
}
Use an Access Token
Send the token in the Authorization header of every API request:
Authorization: Bearer 0000000000000000000000000000000000000000000000000000000000000000
Memair also reads the token from an Access-Token header, and from an access_token or bearer_token parameter, which older clients use. Prefer the Authorization header. A token in a URL can end up in logs and browser history (RFC 6750 §5.3).
A missing, unknown, revoked or expired token gets HTTP 401 and a WWW-Authenticate header (RFC 6750 §3.1):
- no token:
WWW-Authenticate: Bearer - any other token problem:
WWW-Authenticate: Bearer error="invalid_token"
A valid token that lacks a scope gets HTTP 200 and a GraphQL error instead. See Errors.
{
"error": {
"message": "Invalid access token provided, please see docs.memair.com for more details"
},
"data": {}
}
Access Token Details
The AccessToken query describes the token that sent the request: its scopes, when it expires, and whether it has been revoked. Each scope also has a Boolean field, such as locationRead.
GraphiQL runs as the signed-in user without a token, so this query fails there.
query {
AccessToken {
expired
revoked
expiresAt
locationRead
scopes
}
}
curl https://memair.com/graphql \
-H 'Authorization: Bearer 0000000000000000000000000000000000000000000000000000000000000000' \
-H 'Content-Type: application/json' \
-d '{"query": "{ AccessToken { expired revoked expiresAt locationRead scopes } }"}'
{
"data": {
"AccessToken": {
"expired": false,
"revoked": false,
"expiresAt": null,
"locationRead": true,
"scopes": "location_read location_write"
}
}
}
{
"errors": [
{
"message": "No Access Token used to access this endpoint. You are likely accessing this endpoint through GraphiQL so are not using an Access Token",
"locations": [{"line": 2, "column": 3}],
"path": ["AccessToken"]
}
],
"data": {
"AccessToken": null
}
}
Revoke an Access Token
Revoke a token when the app no longer needs it or when it may have leaked. A revoked token stops working at once.
The RevokeAccessToken mutation revokes the token that sends it. Ottoâs token cannot be revoked.
mutation {
RevokeAccessToken {
revoked
}
}
curl https://memair.com/graphql \
-H 'Authorization: Bearer 0000000000000000000000000000000000000000000000000000000000000000' \
-H 'Content-Type: application/json' \
-d '{"query": "mutation { RevokeAccessToken { revoked } }"}'
Memair also has the standard revocation endpoint, POST https://memair.com/oauth/revoke (RFC 7009). Send the token with the appâs client_id and client_secret.
Users can revoke an appâs tokens themselves under Account > Installed.
{
"data": {
"RevokeAccessToken": {
"revoked": true
}
}
}
{
"errors": [
{
"message": "Otto's token can not revoked, others may want to play with his data",
"locations": [{"line": 2, "column": 3}],
"path": ["RevokeAccessToken"]
}
],
"data": {
"RevokeAccessToken": null
}
}
About the API
One GraphQL endpoint reads and writes a user's data
Memairâs API is GraphQL at one endpoint: POST https://memair.com/graphql. A request sends a JSON body with a query and, optionally, variables and operationName. The response is JSON with data, and with errors when something failed (GraphQL over HTTP). New to GraphQL? graphql.org/learn covers the query language.
{
"query": "query RecentEmotions($first: Int) { emotions(first: $first) { nodes { timestamp intensity emotionType { name } } } }",
"variables": { "first": 5 }
}
Names
- Arguments and object fields are camelCase:
fromTimestamp,biometricType,recordsTotal. - The older top-level fields keep capitalised names:
Create,CreateStatus,AccessToken,BiometricTypes. Newer ones are camelCase:biometrics,createEmotion. - Type slugs are snake_case enum values, such as
weight,happyandoral_tablet. TheorderByanddirectionvalues of record lists are upper case:TIMESTAMP,DESC.
Scalars
Parameters
- DateTime
- returned in UTC as ISO 8601, such as
2026-10-27T14:03:00Z. Accepted as ISO 8601, epoch seconds, epoch milliseconds,"now","today","yesterday","tomorrow", or"last 14 days"(withseconds,minutes,hours,days,weeks,monthsoryears). The relative values count from midnight in the userâs time zone, except"now". - JSON
- an object, written either as a GraphQL object literal,
{distance_m: 5000}, or as a string of JSON - UUID
- a UUID in its 36-character form, such as
1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed. A malformed UUID fails the whole request. - ID
- a recordâs id, sent as a string or an integer
Every field and argument is described in GraphiQL.
GraphiQL
An in-browser console for the API, and its full reference
GraphiQL is an in-browser console for the API. Sign in to Memair to use it. Queries run as the signed-in user with every scope, so they need no token.
GraphiQL is also the APIâs full reference. Its documentation explorer reads the live schema and lists every query, mutation, type, field and argument, with descriptions and deprecation reasons. These docs explain concepts and give examples; look up field details there.
GraphiQL sends requests with the browserâs session, not a token, so the token queries AccessToken and RevokeAccessToken fail in it. It allows 60 requests a minute.
Curl Example
A request from the command line
This request reads the latest weight with Ottoâs token.
curl https://memair.com/graphql \
-H 'Authorization: Bearer 0000000000000000000000000000000000000000000000000000000000000000' \
-H 'Content-Type: application/json' \
-d '{"query": "{ biometrics(first: 1, type: weight) { nodes { timestamp value biometricType { name unitSymbol } } } }"}'
The Error tab shows the response when a field name is misspelled, here unit_symbol for unitSymbol.
{
"data": {
"biometrics": {
"nodes": [
{
"timestamp": "2026-10-26T12:30:00Z",
"value": 80.2,
"biometricType": {
"name": "Body weight",
"unitSymbol": "kg"
}
}
]
}
}
}
{
"errors": [
{
"message": "Field 'unit_symbol' doesn't exist on type 'BiometricType' (Did you mean `unitSymbol`?)",
"locations": [{"line": 1, "column": 85}],
"path": ["query", "biometrics", "nodes", "biometricType", "unit_symbol"],
"extensions": {
"code": "undefinedField",
"typeName": "BiometricType",
"fieldName": "unit_symbol"
}
}
]
}
Python Example
A request with the requests library
This script reads the latest weight with Ottoâs token. It uses requests.
import requests
ACCESS_TOKEN = "0000000000000000000000000000000000000000000000000000000000000000"
query = """
query LatestWeight {
biometrics(first: 1, type: weight) {
nodes {
timestamp
value
biometricType {
name
unitSymbol
}
}
}
}
"""
response = requests.post(
"https://memair.com/graphql",
json={"query": query},
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
timeout=30,
)
response.raise_for_status()
print(response.json())
raise_for_status() raises on token and rate-limit failures, which come back as HTTP errors. GraphQL errors come back with HTTP 200, so check the response for errors too (Errors).
The Error tab shows the response when a field name is misspelled, here unit_symbol for unitSymbol.
{
"data": {
"biometrics": {
"nodes": [
{
"timestamp": "2026-10-26T12:30:00Z",
"value": 80.2,
"biometricType": {
"name": "Body weight",
"unitSymbol": "kg"
}
}
]
}
}
}
{
"errors": [
{
"message": "Field 'unit_symbol' doesn't exist on type 'BiometricType' (Did you mean `unitSymbol`?)",
"locations": [{"line": 9, "column": 9}],
"path": [
"query LatestWeight",
"biometrics",
"nodes",
"biometricType",
"unit_symbol"
],
"extensions": {
"code": "undefinedField",
"typeName": "BiometricType",
"fieldName": "unit_symbol"
}
}
]
}
Ruby Example
A request with Ruby's standard library
This script reads the latest weight with Ottoâs token. It needs only Rubyâs standard library.
require "json"
require "net/http"
access_token = "0000000000000000000000000000000000000000000000000000000000000000"
query = <<~GRAPHQL
query LatestWeight {
biometrics(first: 1, type: weight) {
nodes {
timestamp
value
biometricType {
name
unitSymbol
}
}
}
}
GRAPHQL
response = Net::HTTP.post(
URI("https://memair.com/graphql"),
{ query: query }.to_json,
"Authorization" => "Bearer #{access_token}",
"Content-Type" => "application/json"
)
response.value
puts JSON.parse(response.body)
response.value raises on token and rate-limit failures, which come back as HTTP errors. GraphQL errors come back with HTTP 200, so check the result for errors too (Errors).
The Error tab shows the response when a field name is misspelled, here unit_symbol for unitSymbol.
{
"data": {
"biometrics": {
"nodes": [
{
"timestamp": "2026-10-26T12:30:00Z",
"value": 80.2,
"biometricType": {
"name": "Body weight",
"unitSymbol": "kg"
}
}
]
}
}
}
{
"errors": [
{
"message": "Field 'unit_symbol' doesn't exist on type 'BiometricType' (Did you mean `unitSymbol`?)",
"locations": [{"line": 8, "column": 9}],
"path": [
"query LatestWeight",
"biometrics",
"nodes",
"biometricType",
"unit_symbol"
],
"extensions": {
"code": "undefinedField",
"typeName": "BiometricType",
"fieldName": "unit_symbol"
}
}
]
}
Errors
Where the API reports each kind of failure
The API reports a failure in one of three places, so a client checks all three.
HTTP status
401 means the token is missing, unknown, revoked or expired (Use an Access Token). 429 means a rate limit (API Limits). 400 means the request had no query. These responses carry error.message and no GraphQL result.
GraphQL errors
Everything else answers HTTP 200. A query that does not match the schema, an argument the server refuses, or a field outside the tokenâs scopes adds an entry to the top-level errors list, with the path of the field. The failed field returns null. When that field cannot be null, the null moves up to its parent, as far as data itself.
Field errors in mutation payloads
createEmotion, updateEmotion, deleteEmotion and the same mutations for every other record report validation failures in the payloadâs errors list, not in the top-level errors. Each FieldError has the input field it belongs to, or null when it concerns the whole record, and a message to show the user. The record in the payload is null whenever errors is not empty. Read errors on every mutation, even when the response has no top-level errors.
Create imports in the background, so its validation failures appear later, in CreateStatus.importErrors (Bulk Create).
{
"error": {
"message": "Rate limit exceeded"
},
"data": {}
}
{
"errors": [
{
"message": "Access Token does not have permission for requested scope, please see docs.memair.com for more details",
"locations": [{"line": 1, "column": 3}],
"path": ["medications"]
}
],
"data": null
}
{
"data": {
"createEmotion": {
"emotion": null,
"errors": [
{
"field": "intensity",
"message": "Intensity must be less than or equal to 10"
}
]
}
}
}
Other Languages
Any HTTP or GraphQL client works
Any language that can send an HTTPS POST with a JSON body can use the API. The curl, Python and Ruby examples show the whole request. GraphQL client libraries, such as those listed on graphql.org, work too: point them at https://memair.com/graphql and add the Authorization header.
The endpoint answers introspection queries, so tools that generate client code from a schema can read it with any valid token.
To add an example for another language, open a pull request.
About the Models
Memair stores eight kinds of records: biometrics, digital activities, emotions, journals, locations, medications, physical activities and recommendations. All eight have the same shape in the API, so what these pages show for one kind works for the others:
- a cursor-paged list, such as
emotions, and a by-id field, such asemotion(id:)(Pagination) createEmotion,updateEmotionanddeleteEmotion(Create, Update and Delete)- an argument of the bulk
Createmutation, such asemotions(Bulk Create) - a read scope and a write scope, such as
emotion_readandemotion_write(Scopes)
Insights are older and work differently.
Fields every record has
Parameters
- id
- assigned by Memair
- timestamp
- when it happened. Memair uses the time of the request when it is left out.
- source
- free text naming where the record came from, such as an app or a device
- clientUuid
- a UUID the client generated, which makes creates safe to retry
- updatedAt
- when the record last changed
All but locations also have notes. Digital activities, journals, medications and physical activities have meta, a JSON object for details the model has no field for.
Types
Most records have a type chosen by its slug: a biometricâs type: weight, an emotionâs type: happy, a medicationâs form: oral_tablet. The slugs are GraphQL enum values. BiometricTypes, DigitalActivityTypes, EmotionTypes, JournalTypes, MedicationForms, PhysicalActivityTypes and RecommendationTypes list them with their names, and units where they have them. Their definitions are in the data-types repo, and many say which Apple Health samples they correspond to (healthKit).
To suggest a new type, open a pull request on data-types or contact us.
Describing the records
The recordResources query describes each kind of record: its list and by-id fields, its mutations, and the fields of its form, with labels, kinds, units, ranges and choices. It also says whether the token may read and write each kind. Memairâs phone apps draw their record screens from it.
Pagination
Record lists are connections. Each returns a page of nodes, a pageInfo for fetching the next page, and totalCount, the number of records that match the filters across every page. Every kind of record takes the same list arguments:
Parameters
- first
- page size: 25 when not given, at most 1,000. A larger value returns 1,000.
- after
- the
pageInfo.endCursorof the previous page - orderBy
TIMESTAMP(the default) orUPDATED_AT. Biometrics can also sort byVALUE, emotions byINTENSITYand recommendations byPRIORITY.- direction
DESC(the default) orASC- fromTimestamp, toTimestamp, fromUpdatedAt, toUpdatedAt
- time bounds, inclusive
- source
- only records with exactly this
source - type
- only records of this type (
formfor medications)
To read every record, request pages until hasNextPage is false, and pass each pageâs endCursor as the next after. A cursor holds the last recordâs sort value and id, so records added while you page do not shift the pages. A cursor works only with the orderBy that gave it out.
Lists page forwards only. To see records added since, start again from the first page.
To sync changes, list with orderBy: UPDATED_AT and fromUpdatedAt set to the time of the last sync. Deleted records do not appear in any list.
Lists also have edges { cursor node }, for clients that expect the Relay connection shape.
query {
locations(first: 5) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
id
timestamp
}
}
}
query {
locations(first: 5, after: "WyJ0aW1lc3RhbXAiLCIyMDI2LTEwLTI2VDE4OjQ0OjAwLjAwMDAwMFoiLDQyODY0NjA2XQ") {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
timestamp
}
}
}
{
"data": {
"locations": {
"totalCount": 4821,
"pageInfo": {
"hasNextPage": true,
"endCursor": "WyJ0aW1lc3RhbXAiLCIyMDI2LTEwLTI2VDE4OjQ0OjAwLjAwMDAwMFoiLDQyODY0NjA2XQ"
},
"nodes": [
{"id": "42864610", "timestamp": "2026-10-26T19:04:00Z"},
{"id": "42864609", "timestamp": "2026-10-26T18:59:00Z"},
{"id": "42864608", "timestamp": "2026-10-26T18:54:00Z"},
{"id": "42864607", "timestamp": "2026-10-26T18:49:00Z"},
{"id": "42864606", "timestamp": "2026-10-26T18:44:00Z"}
]
}
}
}
Create, Update and Delete
Each kind of record has three mutations that act on one record and answer at once: createEmotion(input:), updateEmotion(id:, input:) and deleteEmotion(id:), and the same for Biometric, DigitalActivity, Journal, Location, Medication, PhysicalActivity and Recommendation. All three need the recordâs write scope.
Each answers with a payload that holds the record (deletedId for a delete) and errors. A validation failure leaves the record null and adds a FieldError for each problem, naming the input field it belongs to. An id the user does not have gives an error with field: null. See Errors.
Safe retries
Send a random UUID as clientUuid with every create, generated once when the user saves. If the response is lost and the client sends the create again, Memair answers with the record it already stored instead of storing a second one.
Partial updates
An update changes the fields named in input and keeps the rest. Send null to clear an optional field such as notes. A record keeps the clientUuid it was created with.
mutation {
createEmotion(input: {
type: happy
intensity: 7
timestamp: "2026-10-27T08:15:00Z"
source: "Example app"
clientUuid: "ff47e3c3-067b-42ca-8626-97e52406b0d6"
}) {
emotion {
id
timestamp
intensity
emotionType {
name
}
}
errors {
field
message
}
}
}
mutation {
updateEmotion(id: "5820117", input: {
intensity: 5
notes: null
}) {
emotion {
id
intensity
notes
}
errors {
field
message
}
}
}
mutation {
deleteEmotion(id: "5820117") {
deletedId
errors {
field
message
}
}
}
{
"data": {
"createEmotion": {
"emotion": {
"id": "5820117",
"timestamp": "2026-10-27T08:15:00Z",
"intensity": 7.0,
"emotionType": {"name": "Happy"}
},
"errors": []
}
}
}
{
"data": {
"createEmotion": {
"emotion": null,
"errors": [
{
"field": "intensity",
"message": "Intensity must be less than or equal to 10"
}
]
}
}
}
{
"data": {
"deleteEmotion": {
"deletedId": null,
"errors": [
{"field": null, "message": "Emotion not found"}
]
}
}
}
Bulk Create
The Create mutation imports many records, of any kinds, in one request. It answers as soon as the import is queued, with a CreateStatus whose id you then poll. Use it for imports and offline queues. Use createEmotion and the other single-record mutations when a person saves one record and waits for the answer.
Create takes a list for each kind of record: biometrics, digitalActivities, emotions, journals, locations, medications, physicalActivities and recommendations. The token needs the write scope of every kind it sends. One request may hold up to 10,000 records, but send at most 1,000: the whole payload goes into one background job.
Poll CreateStatus(id:), with the id as an integer, until hasFinished is true. recordsProcessed counts up to recordsTotal. importErrors lists each record that failed validation, echoed as it was sent (with snake_case keys) next to its errors. Only the app that started an import can read its status.
Give every record a clientUuid. A record whose clientUuid the user already has is skipped: it is not stored twice and not reported as an error. So a batch can be sent again safely, and it should be when its status has not finished after a few minutes, because Create answers before the records are stored and a queued import can be lost.
CreateStatus also has a list for each kind of record (biometrics, locations and so on), which returns every record the import created. Leave them out of large imports.
mutation {
Create(
biometrics: [
{type: weight, value: 80.2, timestamp: "2026-10-27T07:00:00Z", clientUuid: "0787aab5-2c74-4a77-b316-ed5f8178adfc"}
]
emotions: [
{type: happy, intensity: 8, clientUuid: "fc7e2be5-61eb-4a8b-86d2-77cf6da1b3a6"}
{type: content, intensity: 42, clientUuid: "ed25b45f-e909-4f79-8dfb-38b7e3f67fcf"}
]
) {
id
recordsTotal
}
}
query {
CreateStatus(id: 123456) {
hasFinished
finishedAt
recordsProcessed
recordsTotal
importErrors
}
}
{
"data": {
"Create": {
"id": "123456",
"recordsTotal": 3
}
}
}
{
"data": {
"CreateStatus": {
"hasFinished": true,
"finishedAt": "2026-10-27T08:20:02Z",
"recordsProcessed": 3,
"recordsTotal": 3,
"importErrors": [
{
"emotion": {
"type": "content",
"intensity": 42.0,
"client_uuid": "ed25b45f-e909-4f79-8dfb-38b7e3f67fcf"
},
"errors": {
"intensity": ["must be less than or equal to 10"]
}
}
]
}
}
}
Biometrics
A biometric is one measurement of a defined type, such as weight, blood pressure or heart rate. value is a number in the typeâs unit. BiometricTypes lists each typeâs unit and unitSymbol, and biometric_types.yml defines them.
Types whose slug starts with daily_, such as daily_steps, are totals for one day, recorded at the start of that day.
Scopes: biometric_read and biometric_write. Besides the list arguments every record has, biometrics can sort by VALUE.
query {
biometrics(first: 10, type: heart_rate, fromTimestamp: "last 7 days") {
nodes {
id
timestamp
value
biometricType {
name
unitSymbol
}
}
}
}
mutation {
createBiometric(input: {
type: weight
value: 80.2
timestamp: "2026-10-27T07:00:00Z"
clientUuid: "b922d55f-244b-4113-930a-e497bb38fca0"
}) {
biometric {
id
value
}
errors {
field
message
}
}
}
{
"data": {
"biometrics": {
"nodes": [
{
"id": "34117181",
"timestamp": "2026-10-27T07:00:00Z",
"value": 72.0,
"biometricType": {
"name": "Heart rate",
"unitSymbol": "bpm"
}
}
]
}
}
}
Digital Activities
A digital activity is something the user did on a device, such as opening an app, visiting a website or watching a video. DigitalActivityTypes lists the types, and digital_activity_types.yml defines them.
duration is in seconds, and endedAt is timestamp plus duration. meta holds any details; url and title return meta.url and meta.title.
Scopes: digital_activity_read and digital_activity_write.
query {
digitalActivities(first: 20, type: visited_website) {
nodes {
timestamp
duration
url
title
}
}
}
mutation {
createDigitalActivity(input: {
type: visited_website
duration: 300
meta: {url: "https://docs.memair.com", title: "Memair Docs"}
clientUuid: "d06fc445-9bec-4d06-85ca-ba23c2ad7ea5"
}) {
digitalActivity {
id
url
}
errors {
field
message
}
}
}
{
"data": {
"digitalActivities": {
"nodes": [
{
"timestamp": "2026-10-27T09:12:00Z",
"duration": 300.0,
"url": "https://docs.memair.com",
"title": "Memair Docs"
}
]
}
}
}
Emotions
An emotion is a feeling of a defined type with an intensity from 0 to 10. EmotionTypes lists the types with their emoji, and emotion_types.yml defines them.
Scopes: emotion_read and emotion_write. Besides the list arguments every record has, emotions can sort by INTENSITY.
query {
emotions(first: 5) {
nodes {
timestamp
intensity
emotionType {
name
emoji
}
}
}
}
mutation {
createEmotion(input: {
type: happy
intensity: 7
clientUuid: "4c007994-6335-40fb-a14c-0c12f006daf7"
}) {
emotion {
id
}
errors {
field
message
}
}
}
{
"data": {
"emotions": {
"nodes": [
{
"timestamp": "2026-10-27T08:15:00Z",
"intensity": 7.0,
"emotionType": {
"name": "Happy",
"emoji": "đ"
}
}
]
}
}
}
Journals
A journal entry is a piece of text, document, of a defined type such as an idea, a dream or a gratitude. JournalTypes lists the types, and journal_types.yml defines them.
Scopes: journal_read and journal_write.
query {
journals(first: 5, type: idea) {
nodes {
timestamp
document
journalType {
name
}
}
}
}
mutation {
createJournal(input: {
type: idea
document: "Grow catnip in the garden to attract more cats"
clientUuid: "e1feb553-0619-495e-af31-d87fb12e07d3"
}) {
journal {
id
}
errors {
field
message
}
}
}
{
"data": {
"journals": {
"nodes": [
{
"timestamp": "2026-10-20T21:00:00Z",
"document": "Grow catnip in the garden to attract more cats",
"journalType": {
"name": "Idea"
}
}
]
}
}
}
Locations
A location is a point where the user was. Locations have no type.
Parameters
- lat, lon
- latitude from -90 to 90 and longitude from -180 to 180, in degrees
- pointAccuracy, altitude, altitudeAccuracy
- in meters
- heading
- degrees clockwise from north, at least 0 and less than 360. Send
nullwhen the device reports no heading; a negative value fails the record. - speed
- in meters per second
- timezone
- the time zone at the point, which Memair works out
Scopes: location_read and location_write. Besides the list arguments every record has, locations takes within: "latitude,longitude,meters", which keeps the points within that distance of a place.
A phone that records points while offline should send them in batches through Bulk Create.
query {
locations(first: 50, within: "45.4215,-75.6972,500") {
nodes {
timestamp
lat
lon
pointAccuracy
timezone
}
}
}
mutation {
createLocation(input: {
lat: 45.4215
lon: -75.6972
pointAccuracy: 12
timestamp: "2026-10-27T12:01:00Z"
clientUuid: "dca469ef-3996-490b-a45a-ab81e0538998"
}) {
location {
id
timezone
}
errors {
field
message
}
}
}
{
"data": {
"locations": {
"nodes": [
{
"timestamp": "2026-10-27T12:01:00Z",
"lat": 45.4215,
"lon": -75.6972,
"pointAccuracy": 12.0,
"timezone": "America/Toronto"
}
]
}
}
}
Medications
A medication record is one dose the user took. medicationName and form are required. form is how it was taken, such as oral_tablet or inhaled_vapour: MedicationForms lists the forms, and medication_forms.yml defines them.
dose is a number with no unit. Memairâs Apple Health import stores the unit in meta, as dose_unit.
Scopes: medication_read and medication_write. Ottoâs token has neither, so try these examples with your own token. Lists filter by form instead of type.
query {
medications(first: 5, form: oral_tablet) {
nodes {
timestamp
medicationName
dose
meta
medicationForm {
name
}
}
}
}
mutation {
createMedication(input: {
medicationName: "Ibuprofen"
form: oral_tablet
dose: 400
meta: {dose_unit: "mg"}
clientUuid: "0e0a70fd-de6b-4cfe-9a6c-03c5dcc00d6a"
}) {
medication {
id
}
errors {
field
message
}
}
}
{
"data": {
"medications": {
"nodes": [
{
"timestamp": "2026-10-27T08:00:00Z",
"medicationName": "Ibuprofen",
"dose": 400.0,
"meta": {"dose_unit": "mg"},
"medicationForm": {
"name": "oral (tablet)"
}
}
]
}
}
}
Physical Activities
A physical activity is something the user did with their body over a period, from running and yoga to deep sleep. PhysicalActivityTypes lists the types, and physical_activity_types.yml defines them.
duration is in seconds, and endedAt is timestamp plus duration. meta holds any details. Memairâs Apple Health import records a workoutâs distance and energy there, as distance_m and active_energy_kcal.
Scopes: physical_activity_read and physical_activity_write.
query {
physicalActivities(first: 5, type: running) {
nodes {
timestamp
duration
endedAt
meta
physicalActivityType {
name
}
}
}
}
mutation {
createPhysicalActivity(input: {
type: running
timestamp: "2026-10-26T17:30:00Z"
duration: 1800
meta: {distance_m: 5000}
clientUuid: "ceb8f9de-cbc4-432b-b490-e382b5a70bac"
}) {
physicalActivity {
id
endedAt
}
errors {
field
message
}
}
}
{
"data": {
"physicalActivities": {
"nodes": [
{
"timestamp": "2026-10-26T17:30:00Z",
"duration": 1800.0,
"endedAt": "2026-10-26T18:00:00Z",
"meta": {"distance_m": 5000},
"physicalActivityType": {
"name": "Running"
}
}
]
}
}
}
Recommendations
A recommendation suggests something to the user: a video, an article, a book, a person to contact. An app creates it, and the user acts on it or ignores it. RecommendationTypes lists the types, and recommendation_types.yml defines them.
A new recommendation needs a type, a priority from 0 to 100, an expiresAt in the future, and at least one of title, url, description, thumbnailUrl, duration (in seconds) and publishedAt.
ActionRecommendation(id:) marks a recommendation as acted on, or as ignored with ignored: true. It needs the recommendation_action scope. isActioned, isIgnored and isExpired report the state.
Scopes: recommendation_read and recommendation_write. Besides the list arguments every record has, recommendations filters by expired, actioned and ignored, and can sort by PRIORITY.
query {
recommendations(first: 5, expired: false, actioned: false, orderBy: PRIORITY) {
nodes {
id
title
url
priority
expiresAt
recommendationType {
name
}
}
}
}
mutation {
createRecommendation(input: {
type: article
title: "How GraphQL pagination works"
url: "https://graphql.org/learn/pagination/"
priority: 80
expiresAt: "tomorrow"
clientUuid: "dab82bc1-6e15-4993-843c-8abcd408098b"
}) {
recommendation {
id
expiresAt
}
errors {
field
message
}
}
}
mutation {
ActionRecommendation(id: 771204)
}
{
"data": {
"recommendations": {
"nodes": [
{
"id": "771204",
"title": "How GraphQL pagination works",
"url": "https://graphql.org/learn/pagination/",
"priority": 80.0,
"expiresAt": "2026-10-28T04:00:00Z",
"recommendationType": {
"name": "Read Article"
}
}
]
}
}
}
{
"data": {
"ActionRecommendation": true
}
}
Insights
An insight is a chart that an app made from the userâs data. Memair shows insights on the userâs Insights page.
Insights are older than the eight record kinds and work differently. Insights returns a plain list: first (10 by default) sets its length, and after takes the id of the last insight on the previous page. CreateInsight adds one, and there is no update or delete. Scopes: insight_read and insight_write.
The chart is a line chart. categoryAxis labels the x axis, and each series has a label and one data value per category. title, min and max are optional. The stored chart is returned with snake_case keys, as category_axis.
query {
Insights(first: 1) {
id
timestamp
chart
}
}
mutation {
CreateInsight(
source: "Example app"
chart: {
title: "Daily steps"
type: line
categoryAxis: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
series: [{label: "Steps", data: [4129, 7621, 1723, 2372, 3342, 7983, 3130]}]
}
) {
id
timestamp
}
}
{
"data": {
"Insights": [
{
"id": "9112",
"timestamp": "2026-10-27T06:00:00Z",
"chart": {
"title": "Daily steps",
"type": "line",
"category_axis": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"series": [
{
"label": "Steps",
"data": [4129.0, 7621.0, 1723.0, 2372.0, 3342.0, 7983.0, 3130.0]
}
]
}
}
]
}
}