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 currentcursorand an empty list — a "start from now, don't replay history" marker. - Backfill: to read history from the beginning instead, start at
after=0and keep paging forward (each response hands you a newcursor) until a page comes back shorter than yourlimit— that is the end. - Poll: call with
after={cursor}to get only what is newer than that cursor, oldest first, then remember the returnedcursorfor 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
| Endpoint | What it returns | Scope needed |
|---|---|---|
GET /api/me | Token identity: account, bound character, scopes. | any valid token |
GET /api/events | Things that happened to your character: DMs received, likes, comments, follows, money received. | events |
GET /api/messages | Your full message history, both directions — sent and received — with bodies, image URLs and in-DM money. | message-bodies |
GET /api/wallet | Your 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:
| Choice | Covers | characterId 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.
| Scope | Granted | Unlocks |
|---|---|---|
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
| Status | Meaning | What to do |
|---|---|---|
200 OK | Success. | Read the body. |
401 Unauthorized | Missing, malformed, revoked, or banned-account token. | Check the header; the token may have been revoked. Do not retry blindly. |
403 Forbidden | Valid token, but it lacks the scope this endpoint needs. | Recreate the token with the right scope. |
429 Too Many Requests | Rate 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.
| Query | Meaning |
|---|---|
after | Return only events newer than this cursor. Omit to bootstrap from now. |
limit | Max 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
| Type | Extra fields |
|---|---|
dm.received | conversationId; plus body and imageUrl with the message-bodies scope |
post.liked | targetId = post id |
post.commented | targetId = post id |
comment.liked | targetId = comment id |
comment.replied | targetId = comment id |
thread.replied | targetId = comment id |
follower.new | none |
money.received | amount = 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.
| Query | Meaning |
|---|---|
after | Return only messages newer than this cursor. Omit to bootstrap from now; use after=0 to read from the start. |
limit | Max 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
| Field | Type | Notes |
|---|---|---|
cursor | number | The message id; the paging cursor. |
conversationId | number | Groups a back-and-forth; key your transcript on this. |
direction | string | "sent" if one of the token's characters sent it, else "received". |
senderCharacterId | number | Who sent it. |
senderUsername, senderDisplayName | string | The sender's handle and display name. |
recipientCharacterId | number | Who received it — the other party in the 1:1 conversation. |
recipientUsername, recipientDisplayName | string | The recipient's handle and display name. |
at | string | Real send time, UTC ISO 8601. |
body | string? | The text. Omitted when the message is image-only or money-only. |
imageUrl | string? | Absolute URL to the attached image. Omitted when there is none. |
moneyAmount, moneyCurrency | number?, string? | Present when money moved inside the conversation. The same transfer also shows on the wallet ledger. |
outOfCharacter | bool | Included 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.
| Query | Meaning |
|---|---|
after | Return only ledger lines newer than this cursor. Omit to bootstrap; after=0 reads from the start. |
limit | Max 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
| Field | Type | Notes |
|---|---|---|
cursor | number | The ledger line id; the paging cursor. |
kind | string | One of sent, received, income, bill, reversal. |
amount | number | Signed: positive is money in, negative is money out. |
balanceAfter | number | The running balance immediately after this line. |
at | string | UTC ISO 8601. |
counterpartyCharacterId | number? | The other party, for a transfer. Absent for income/bills. |
counterpartyUsername, counterpartyDisplayName | string? | The other party's handle and display name. |
description | string? | 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
Using SubLink and other OSC tooling
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/eventsto react to incoming things,/api/messagesto mirror whole conversations (your side included),/api/walletfor 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.