SAPHIRESOCIAL

SaphireSocial API

A read-only HTTP API for your own characters' activity: an event stream, the full two-way message history, and the wallet ledger. SaphireSocial ships the API and this documentation; companion apps, OSC bridges and overlays are community built. The headline use case is bridging into VRChat — a DM arrives in the app, your tooling picks it up here and pushes it to the chatbox over OSC — but the same three endpoints drive overlays, logging, Discord relays, anything that can make an HTTP request.

How the API works

There are three things to understand before you write any code, and they apply to every endpoint the same way.

1. It is a pull API — you poll, nothing is pushed

There is no webhook, callback or socket. SaphireSocial never connects out to you. Your tooling makes an HTTP GET every few seconds and picks up whatever is new. "Real-time" here means "as fast as you poll" — with a 7-second loop, a message shows up in your bridge a few seconds after it is sent. Polling is the contract; there is no push channel in V1.

2. Everything is cursor-based, and the contract is identical everywhere

/api/events, /api/messages and /api/wallet all page the same way:

  • Bootstrap: call once without after. You get back the current cursor and an empty list — a "start from now, don't replay history" marker.
  • Backfill: to read history from the beginning instead, start at after=0 and keep paging forward (each response hands you a new cursor) until a page comes back shorter than your limit — that is the end.
  • Poll: call with after={cursor} to get only what is newer than that cursor, oldest first, then remember the returned cursor for next time.

The cursor is a monotonic id. Resume from your last one after a disconnect and you miss nothing and duplicate nothing — it is safe to persist it and restart whenever. Each item in a response also carries its own cursor value (equal to the item's id), so you can checkpoint mid-page if you want.

3. Null fields are omitted, not sent as null

To keep payloads small, any field that would be empty is left out of the JSON entirely rather than sent as null. Treat every optional field below as "may be absent" and read it defensively (msg.get("body"), not msg["body"]).

Endpoints at a glance

EndpointWhat it returnsScope needed
GET /api/meToken identity: account, bound character, scopes.any valid token
GET /api/eventsThings that happened to your character: DMs received, likes, comments, follows, money received.events
GET /api/messagesYour full message history, both directions — sent and received — with bodies, image URLs and in-DM money.message-bodies
GET /api/walletYour wallet ledger, both directions — money sent and received, income, bills, reversals.wallet

Which one do you want? The event stream is built on notifications, so it only ever shows what happens to you — it will not include messages you send. If you want the whole back-and-forth of a conversation (your lines and theirs), poll /api/messages. Use /api/events when you only need to react to incoming things (a DM landed, someone followed you).

Authentication

Tokens are managed from your account dashboard, not from inside a character. Go to your Dashboard (the Dashboard item in the sidebar, or exit your character session) and click API Tokens. There you choose which character the token acts for and which scopes it carries. The raw token is shown once, at creation, so copy it then. Send it as a bearer token on every request:

GET /api/events?after=118
Host: your-instance
Authorization: Bearer ss_your_token_here

Tokens are hashed at rest (only the hash is stored) and can be revoked from the API Tokens page at any time; a revoked token stops working immediately. A token is read-only — it can never post, message, spend, or log in as you. If the account is banned or suspended, its tokens stop working too.

What a token acts for

At creation you choose the token's reach, and that decides what every endpoint returns:

ChoiceCoverscharacterId on /api/me
One character Only that character's events, messages and ledger. That character's id.
All characters
(account-wide)
Every character on the account, in one combined stream. Omitted.

Either shape works. One token per character keeps the blast radius small and lets you run a separate bridge per persona; an account-wide token is a single stream for everyone. On an account-wide token, use each item's characterId / characterUsername (events) or direction + senderCharacterId (messages) to tell which of your characters it concerns.

Scopes

Least privilege by default: a new token carries only events. Message content and the wallet ledger are each a separate opt-in at creation, so a leaked events-only token can neither read your DMs nor enumerate your finances.

ScopeGrantedUnlocks
events always /api/events. Minimal payloads — who/what/when, no message text.
message-bodies opt-in Message content: the body and imageUrl fields on dm.received events, and the whole /api/messages endpoint. Trade-off: a leaked token can then read your DMs.
wallet opt-in The /api/wallet ledger: money sent and received, with counterparties and running balances.

Scopes are fixed at creation. To add one to an existing token, create a new token with the boxes checked and revoke the old one.

Errors & status codes

StatusMeaningWhat to do
200 OKSuccess.Read the body.
401 UnauthorizedMissing, malformed, revoked, or banned-account token.Check the header; the token may have been revoked. Do not retry blindly.
403 ForbiddenValid token, but it lacks the scope this endpoint needs.Recreate the token with the right scope.
429 Too Many RequestsRate limit hit.Wait the seconds in the Retry-After header, then continue.

Timestamps

Every timestamp — the at field throughout, and event/message times — is real UTC in ISO 8601 (2026-07-08T18:04:11Z). This is the true send/receive time and is what ordering and cursors are based on. The in-character clock (ZST) and any player-set "IC timestamp" on a message are display-only fiction and are deliberately not exposed by the API; convert to whatever your overlay wants on your side.

GET /api/me

Sanity check for a token: the account, the character it is bound to, and its scopes.

GET /api/me

{
  "accountId": 4,
  "characterId": 3,
  "scopes": ["events", "message-bodies", "wallet"],
  "characters": [
    { "id": 3, "username": "kestrel", "displayName": "Kestrel Nine" }
  ]
}

A character-bound token lists only its own character and includes characterId. An account-wide token omits characterId entirely and lists every character on the account — that absence is how you tell the two apart from the client. Any valid token can call /api/me, whatever its scopes.

GET /api/events

The notification stream: things that happened to your character. Cursor-based (see How it works). Requires the events scope, which every token has.

QueryMeaning
afterReturn only events newer than this cursor. Omit to bootstrap from now.
limitMax events per response. Default 100, max 200.
GET /api/events?after=118

{
  "cursor": 121,
  "events": [
    {
      "type": "dm.received",
      "cursor": 121,
      "at": "2026-07-08T18:04:11Z",
      "characterId": 3,
      "characterUsername": "kestrel",
      "characterDisplayName": "Kestrel Nine",
      "fromCharacterId": 1,
      "fromCharacterName": "Vex Marrowind",
      "fromCharacterUsername": "vex",
      "conversationId": 2,
      "body": "The drop is set.",
      "imageUrl": "https://your-instance/media/2f9c....webp"
    }
  ]
}

Every event names the character it belongs to in characterId / characterUsername / characterDisplayName, and the other party (when there is one) in fromCharacterId / fromCharacterUsername / fromCharacterName — so you never have to look a name up. body and imageUrl appear on dm.received only with the message-bodies scope, and only when present.

Event types

TypeExtra fields
dm.receivedconversationId; plus body and imageUrl with the message-bodies scope
post.likedtargetId = post id
post.commentedtargetId = post id
comment.likedtargetId = comment id
comment.repliedtargetId = comment id
thread.repliedtargetId = comment id
follower.newnone
money.receivedamount = how much was transferred (the currency label is per-character)

Two things this stream will not show you. (1) Messages you send — there is no notification for your own sends, so use /api/messages for the outgoing side. (2) Money sent to you inside a DM arrives as a dm.received event (the money card is a message), not as money.received; the amount for that is on the message in /api/messages. Only a direct wallet transfer raises money.received with an amount.

GET /api/messages

Your messages in both directions — what you sent and what you received — across every conversation the character takes part in, oldest first. This is the endpoint for reconstructing a full conversation. Cursor-based, same contract as the others. Requires the message-bodies scope.

QueryMeaning
afterReturn only messages newer than this cursor. Omit to bootstrap from now; use after=0 to read from the start.
limitMax messages per response. Default 100, max 200.
GET /api/messages?after=880

{
  "cursor": 883,
  "messages": [
    {
      "cursor": 883,
      "conversationId": 2,
      "direction": "sent",
      "senderCharacterId": 3,
      "senderUsername": "kestrel",
      "senderDisplayName": "Kestrel Nine",
      "recipientCharacterId": 7,
      "recipientUsername": "marlow",
      "recipientDisplayName": "Doc Marlow",
      "at": "2026-07-08T18:05:02Z",
      "body": "Confirmed. Sending the schematic.",
      "imageUrl": "https://your-instance/media/2f9c....webp",
      "moneyAmount": 500,
      "moneyCurrency": "Credits"
    }
  ]
}

Message fields

FieldTypeNotes
cursornumberThe message id; the paging cursor.
conversationIdnumberGroups a back-and-forth; key your transcript on this.
directionstring"sent" if one of the token's characters sent it, else "received".
senderCharacterIdnumberWho sent it.
senderUsername, senderDisplayNamestringThe sender's handle and display name.
recipientCharacterIdnumberWho received it — the other party in the 1:1 conversation.
recipientUsername, recipientDisplayNamestringThe recipient's handle and display name.
atstringReal send time, UTC ISO 8601.
bodystring?The text. Omitted when the message is image-only or money-only.
imageUrlstring?Absolute URL to the attached image. Omitted when there is none.
moneyAmount, moneyCurrencynumber?, string?Present when money moved inside the conversation. The same transfer also shows on the wallet ledger.
outOfCharacterboolIncluded only when true (an OOC note).

Deleted and moderator-removed messages are never returned. On an account-wide token this merges every character's conversations into one stream; direction is relative to "any of my characters", so check senderCharacterId if you need to know exactly which one.

GET /api/wallet

Your wallet ledger, both directions: money sent to others, money received, and any income, bills or reversals — one row per side, exactly like the in-app journal, oldest first. Cursor-based, same contract. Requires the wallet scope.

QueryMeaning
afterReturn only ledger lines newer than this cursor. Omit to bootstrap; after=0 reads from the start.
limitMax lines per response. Default 100, max 200.
GET /api/wallet?after=40

{
  "cursor": 42,
  "entries": [
    {
      "cursor": 42,
      "kind": "sent",
      "amount": -200,
      "balanceAfter": 300,
      "at": "2026-07-08T18:06:00Z",
      "counterpartyCharacterId": 1,
      "counterpartyUsername": "vex",
      "counterpartyDisplayName": "Vex Marrowind",
      "description": "Bribe money"
    }
  ]
}

Ledger fields

FieldTypeNotes
cursornumberThe ledger line id; the paging cursor.
kindstringOne of sent, received, income, bill, reversal.
amountnumberSigned: positive is money in, negative is money out.
balanceAfternumberThe running balance immediately after this line.
atstringUTC ISO 8601.
counterpartyCharacterIdnumber?The other party, for a transfer. Absent for income/bills.
counterpartyUsername, counterpartyDisplayNamestring?The other party's handle and display name.
descriptionstring?The free-text note/source shown in the journal.

The currency label is per-character (it is not repeated on every line; see the character's wallet, or the moneyCurrency on the matching message). Lines hidden after a report are omitted, matching your own journal view.

Rate limits

30 requests per 10 seconds per token. Beyond that you get 429 with a Retry-After header (seconds to wait). Polling each endpoint every 5 to 10 seconds keeps you far under the limit even with all three running. Always honour Retry-After rather than hammering.

Examples

Full conversation via /api/messages

Backfills the entire message history (both directions), then polls for new lines forever, printing a live transcript keyed by conversation. This is the pattern for "get the whole conversation into my tool, including my own messages". The token needs the message-bodies scope. Requires pip install requests.

import time, requests

API = "https://your-instance/api"        # SaphireSocial base URL
TOKEN = "ss_your_token_here"             # needs the message-bodies scope

def get(path, **params):
    r = requests.get(API + path, params=params,
                     headers={"Authorization": "Bearer " + TOKEN})
    if r.status_code == 429:              # rate limited: wait it out, signal "no data"
        time.sleep(int(r.headers.get("Retry-After", "10")))
        return None
    r.raise_for_status()
    return r.json()

def render(m):
    arrow = "->" if m["direction"] == "sent" else "<-"
    who = m["senderDisplayName"]
    text = m.get("body") or ""
    if m.get("imageUrl"):
        text = (text + " ").lstrip() + "[image] " + m["imageUrl"]
    if m.get("moneyAmount"):
        text += "  (money: %s %s)" % (m["moneyAmount"], m.get("moneyCurrency", ""))
    print("#%s %s %s: %s" % (m["conversationId"], arrow, who, text.strip()))

# 1) Backfill everything, paging forward from the very start.
conversations = {}                       # conversationId -> [messages in order]
cursor = 0
while True:
    data = get("/messages", after=cursor, limit=200)
    if not data:                         # got a 429; get() already slept
        continue
    for m in data["messages"]:
        conversations.setdefault(m["conversationId"], []).append(m)
    cursor = data["cursor"]
    if len(data["messages"]) < 200:      # short page = caught up to the end
        break
print("backfilled %d conversations, cursor %d" % (len(conversations), cursor))

# 2) Poll for new messages in either direction, forever.
while True:
    data = get("/messages", after=cursor)
    if data:
        for m in data["messages"]:
            conversations.setdefault(m["conversationId"], []).append(m)
            render(m)                     # sent AND received both land here
        cursor = data["cursor"]
    time.sleep(7)                         # poll every 5 to 10 seconds

Persist cursor to disk if you want the tool to resume across restarts without re-reading history — start the poll loop from the saved value instead of backfilling again.

Incoming DMs to VRChat OSC (events)

When you only need to react to incoming things, the event stream is lighter. This watches for received DMs and pushes them to the VRChat chatbox on the standard OSC input port. Requires pip install requests python-osc.

import time, requests
from pythonosc.udp_client import SimpleUDPClient

API = "https://your-instance/api"        # SaphireSocial base URL
TOKEN = "ss_your_token_here"             # from your API Tokens page
osc = SimpleUDPClient("127.0.0.1", 9000) # VRChat OSC input port

def get(path, **params):
    r = requests.get(API + path, params=params,
                     headers={"Authorization": "Bearer " + TOKEN})
    if r.status_code == 429:
        time.sleep(int(r.headers.get("Retry-After", "10")))
        return None
    r.raise_for_status()
    return r.json()

cursor = get("/events")["cursor"]        # start from now, no history replay
print("bridge running, cursor", cursor)

while True:
    data = get("/events", after=cursor)
    if data:
        for e in data["events"]:
            if e["type"] == "dm.received":
                text = "DM for " + e["characterUsername"] + " from " + e["fromCharacterName"]
                osc.send_message("/chatbox/input", [text, True])
        cursor = data["cursor"]
    time.sleep(7)                        # poll every 5 to 10 seconds

Many players drive VRChat OSC through SubLink or similar event tooling. The pattern is the same regardless of tool:

  • Something polls SaphireSocial with your token — the scripts above, or a custom SubLink integration built against this contract. Poll /api/events to react to incoming things, /api/messages to mirror whole conversations (your side included), /api/wallet for money.
  • Each item is translated into whatever your setup listens for: the chatbox (/chatbox/input), an avatar parameter for a beep or a light, an overlay update.
  • Run one bridge per character-bound token, or a single bridge on an account-wide token. Either way the per-item character fields tell you which persona it concerns, so you can give each their own sound or overlay.

A reusable SaphireSocial integration only needs this page: bearer auth, cursor polling, the schemas above, and respect for Retry-After. There is no push channel in V1; polling is the contract.