Spark Account OAuth API
Let your application authenticate users with their Spark Account and read verified profile data using the standard OAuth 2.0 authorization-code flow.
Overview
Spark Account implements the OAuth 2.0 authorization code grant. The flow is three steps:
- 1 Redirect the user to the authorization endpoint.
- 2 Exchange the returned code for an access token.
- 3 Call the userinfo endpoint with that token to read the user's profile.
client_id and client_secret, and register your redirect URIs.Endpoints
| Purpose | Method | URL |
|---|---|---|
| Authorize | GET | https://sparkden.org/api/oauth/authorize |
| Token | POST | https://sparkden.org/api/oauth/token |
| User info | GET | https://sparkden.org/api/oauth/userinfo |
| JWKS (id_token keys) | GET | https://sparkden.org/api/oauth/jwks |
| Sparks | GET | https://sparkden.org/api/oauth/sparks |
| Send email | POST | https://sparkden.org/api/oauth/email |
| Account events | POST | https://sparkden.org/api/oauth/events |
Scopes
Request only what you need. Space-separate multiple scopes in the scope parameter.
| Scope | Grants access to |
|---|---|
profile | Name, username, avatar, bio, account creation date |
email | Email address and email-verified status |
social | Linked website, GitHub, Twitter, and Discord |
sparks | Read the user’s Spark balance and transaction history — read-only, never spends |
email.send | Send branded emails to the user through Spark Account — verified apps only |
2. Token exchange
Exchange the authorization code for an access token from your server. Credentials can be sent in the body (shown) or as HTTP Basic auth (Authorization: Basic base64(client_id:client_secret)).
curl -X POST https://sparkden.org/api/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=THE_CODE_FROM_REDIRECT" \
-d "redirect_uri=https://yourapp.com/callback" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"Response
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbGci...",
"scope": "profile email",
"id_token": "eyJhbGci..." // OIDC identity token (RS256, verify via JWKS)
}3. User info
Use the access token to fetch the user's profile. Fields returned depend on the granted scopes — email and email_verified are only included when the email scope was granted.
curl https://sparkden.org/api/oauth/userinfo \
-H "Authorization: Bearer ACCESS_TOKEN"Response
{
"sub": "a1b2c3d4-...",
"uid": "a1b2c3d4-...",
"username": "janedoe",
"email": "jane@example.com", // only with email scope
"email_verified": true, // only with email scope
"roles": ["ADULT", "VERIFIED", "SPARKCLOUD"],
"role": "VERIFIED",
"verified": true,
"id_verified": true,
"sparkcloud_access": true,
"chat_member": true,
"chat_admin": false,
"name": "Jane Doe",
"avatar_url": "https://...",
"created_at": "2026-01-12T...",
"sparks_balance": 1250 // only with sparks scope
}| Field | Type | Description |
|---|---|---|
sub / uid | string | Stable unique user ID (UUID) |
username | string | Account username |
email | string | Email address — only returned when email scope is granted |
email_verified | boolean | Whether the email address is confirmed — only returned when email scope is granted |
roles | string[] | All roles, e.g. STUDENT, ADULT, VERIFIED, SPARKCLOUD, CHAT_MEMBER, CHAT_ADMIN, ADMIN |
role | string | Legacy single role (STUDENT, ADULT, VERIFIED, MODERATOR, or ADMIN) |
verified | boolean | True if the user has passed identity verification |
id_verified | boolean | Alias of verified — identity verification status |
sparkcloud_access | boolean | True if the user may access SparkCloud |
chat_member | boolean | True if the user is a full member of the community chat (SparkChat) |
chat_admin | boolean | True if the user is a community chat admin |
name | string | Display name (requires profile scope) |
avatar_url | string | Profile picture URL (requires profile scope) |
sparks_balance | number | Current Spark balance — only returned when the sparks scope is granted, and omitted entirely while the economy is closed |
Student verification
Spark Account can verify a user's real-world identity. Your app can both read a user's verification status and require it before authorization.
Reading verification status
The userinfo response includes the user's verification state — check whichever you prefer:
verified/id_verified—trueonce the user has passed identity verification.roles— the full set, e.g.STUDENT/ADULT(account type),VERIFIED,SPARKCLOUD,CHAT_MEMBER,CHAT_ADMIN,ADMIN. Gate access on the role you need (e.g.SPARKCLOUD).sparkcloud_access— convenience boolean,truewhenrolesincludesSPARKCLOUD(orADMIN).
Requiring verification
Sparks
Sparks are the program-wide currency members earn for building and learning, and spend in the Sparks shop. With the sparks scope your app can read a member's balance and the ledger behind it — useful for showing what they've earned through your app, or gating a feature on a balance.
Balance only
If all you need is the number, the userinfo response carries sparks_balance whenever the sparks scope is granted — no second request.
Balance and history
The Sparks endpoint returns the balance plus the ledger entries behind it, newest first.
curl "https://sparkden.org/api/oauth/sparks?limit=3" \
-H "Authorization: Bearer ACCESS_TOKEN"| Parameter | Required | Description |
|---|---|---|
limit | No | Ledger entries to return, 0–100 (default 25). Use 0 to fetch the balance alone. |
cursor | No | The next_cursor from a previous response — returns the page after that entry. |
Response
{
"sub": "a1b2c3d4-...",
"balance": 1250,
"transactions": [
{
"id": "9f8e7d6c-...",
"delta": -500,
"balance_after": 1250,
"reason": "SparkCloud credit voucher",
"kind": "SPEND",
"actor_name": null,
"created_at": "2026-08-14T18:02:11.442Z"
},
{
"id": "3c2b1a09-...",
"delta": 250,
"balance_after": 1750,
"reason": "Shipped your first site",
"kind": "GRANT",
"actor_name": "avery",
"created_at": "2026-08-02T14:20:05.118Z"
}
],
"next_cursor": "3c2b1a09-..."
}| Field | Type | Description |
|---|---|---|
sub | string | The user this ledger belongs to (same value as userinfo's sub) |
balance | number | Current balance, in whole Sparks |
transactions[].delta | number | Sparks added (positive) or removed (negative) by this entry |
transactions[].balance_after | number | Balance immediately after this entry — lets you replay history without re-adding it yourself |
transactions[].reason | string | Human-readable reason, written for the member (e.g. "Shipped your first site") |
transactions[].kind | string | GRANT, SPEND, or REFUND |
transactions[].actor_name | string | Who moved the Sparks when it wasn't the member themselves — usually an admin. null otherwise. |
transactions[].created_at | string | ISO 8601 timestamp |
next_cursor | string | Pass as ?cursor= for the next page. null when the ledger ends. |
Paging the ledger
Pagination is cursor-based. Read a page, then pass its next_cursor back as ?cursor= to get the one after it; keep going until next_cursor is null. Ledger entries are immutable once written, so a cursor stays valid — a page you fetch tomorrow lines up with the one you fetched today.
Notes
- Whole numbers. Balances and deltas are always integers. Never render a Spark as a decimal or convert it to a currency — Sparks are priced in Sparks.
balance_afteris authoritative. Each entry records the balance immediately after it, so you can show history without summing deltas yourself.- The economy can be closed. When it is, this endpoint returns
economy_disabled(HTTP 403) andsparks_balanceis absent from userinfo — absent, not0. Treat it as "no balance to show", not "a balance of zero". - Rate limit. 120 requests per minute, matching userinfo.
Sending email
Verified apps can send emails to their users through Spark Account — no SMTP setup, no separate email provider. Every message is wrapped in a clean, Spark-styled template that carries your app's branding and a clear "sent from [your app] via Spark Account" disclosure, so users always know who's emailing them and why.
email.send scope during authorization. Unverified apps receive app_not_verified.Branding
Set a logo URL and brand color on your app under Account → OAuth Apps. The logo appears at the top of every email (and on the consent screen); the brand color is used for the accent bar and call-to-action button. No branding set? Emails fall back to a neutral monogram and Spark orange.
Request
Authenticate with the user's access token. The email is always delivered to the token holder's account email address — you never need (or get) the address itself.
curl -X POST https://sparkden.org/api/oauth/email \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subject": "Your weekly project report",
"title": "Your projects this week",
"body": "Hi! You shipped 4 new builds this week.\n\nKeep it up — your streak is now 3 weeks.",
"cta_url": "https://yourapp.com/dashboard",
"cta_text": "View dashboard"
}'| Field | Required | Description |
|---|---|---|
subject | Yes | Email subject line (max 150 characters) |
body | Yes | Plain-text message (max 5,000 characters). Use blank lines for paragraphs — HTML is not allowed and will be escaped. |
title | No | Heading shown at the top of the email (defaults to the subject, max 150 characters) |
cta_url | No | http(s) link for an optional call-to-action button |
cta_text | No | Button label (defaults to "Open [app name]", max 60 characters) |
Response
{ "ok": true }Content rules & limits
- Plain text only. Any HTML in
subject,title, orbodyis escaped, not rendered. Blank lines become paragraph breaks. - One link. The optional
cta_urlbutton is the only clickable link — it must be a valid http(s) URL. - Rate limits. Per user, each app may send 5 emails/hour and 20 emails/day. Exceeding either returns
rate_limited(HTTP 429). - Revocable. Users can stop your emails at any time by revoking your app's access from their account.
Refresh tokens
Access tokens expire after 1 hour. Use the refresh token to get a new pair without sending the user through the flow again. Refresh tokens are single-use — store the new one from each response.
client_id and client_secret — either in the request body or as HTTP Basic auth. Requests without valid credentials are rejected with invalid_client.curl -X POST https://sparkden.org/api/oauth/token \
-d "grant_type=refresh_token" \
-d "refresh_token=YOUR_REFRESH_TOKEN" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"Account events
The events endpoint lets a registered app notify Spark Account of account lifecycle changes — for example, when your app suspends or deletes a user. Spark Account applies the change locally and re-fans it to every other connected app.
client_id and client_secret via HTTP Basic auth or request body. Never expose your client secret in frontend code.Request
curl -X POST https://sparkden.org/api/oauth/events \
-H "Content-Type: application/json" \
-H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
-d '{
"type": "user.suspended",
"sub": "a1b2c3d4-...",
"reason": "Violated community guidelines"
}'| Field | Required | Description |
|---|---|---|
type | Yes | Event type — see table below |
sub | Yes | Spark Account user ID (UUID) of the affected user |
reason | No | Human-readable reason string (used with user.suspended) |
client_id | If no Basic auth | Your app's client ID |
client_secret | If no Basic auth | Your app's client secret |
Event types
| Type | Description |
|---|---|
user.suspended | The user's account was suspended. Includes an optional reason string. |
user.unsuspended | A previously suspended account has been reinstated. |
user.deleted | The user's account was permanently deleted. You should remove their data. |
Response
{ "ok": true }Outbound webhooks
Register an Event Webhook URL on your OAuth app (via an admin) and Spark Account will POST account lifecycle events to it whenever a user is suspended, unsuspended, or deleted — whether triggered by an admin action or by another connected app.
Payload
// POST to your registered eventWebhookUrl
// Headers:
// Content-Type: application/json
// X-Spark-Event: user.suspended
// X-Spark-Signature: sha256=abc123...
{
"type": "user.suspended",
"sub": "a1b2c3d4-...",
"reason": "Violated community guidelines",
"ts": 1748908800000
}| Field | Type | Description |
|---|---|---|
type | string | Event type (user.suspended, user.unsuspended, user.deleted) |
sub | string | Spark Account user ID (UUID) of the affected user |
reason | string | null | Suspension reason, if provided |
ts | number | Unix timestamp in milliseconds |
Verifying signatures
Every webhook request includes an X-Spark-Signature header of the form sha256=<hex>. Compute an HMAC-SHA256 of the raw request body using your client secret as the key and compare using a constant-time function. Reject requests where the signature doesn't match.
import crypto from 'crypto';
function verifySparkSignature(rawBody, signature, clientSecret) {
const expected = 'sha256=' +
crypto.createHmac('sha256', clientSecret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
// In your webhook handler:
app.post('/webhook', express.text({ type: '*/*' }), (req, res) => {
const sig = req.headers['x-spark-signature'];
if (!verifySparkSignature(req.body, sig, CLIENT_SECRET)) {
return res.status(401).json({ error: 'invalid_signature' });
}
const event = JSON.parse(req.body);
// handle event.type: 'user.suspended' | 'user.unsuspended' | 'user.deleted'
res.json({ ok: true });
});2xx status as soon as the signature is verified — do any heavy processing asynchronously.Errors
Token and userinfo errors are returned as JSON with an error field. Authorization errors are appended to your redirect_uri as query parameters.
| Error | Meaning |
|---|---|
invalid_request | Missing or malformed required parameters |
invalid_client | Client ID / secret did not match |
invalid_grant | Code expired, already used, or mismatched redirect URI |
access_denied | The user declined to authorize your app |
unsupported_grant_type | grant_type must be authorization_code or refresh_token |
invalid_event | Unknown event type or missing sub field in events endpoint |
invalid_json | Request body could not be parsed as JSON |
insufficient_scope | The access token is missing the scope required for this endpoint (e.g. email.send) |
app_not_verified | Email sending requires a verified app — submit your app for review first |
economy_disabled | The Sparks economy is closed to this member, so there is no balance to read (HTTP 403) |
rate_limited | Email quota reached for this user (5/hour, 20/day per app) — HTTP 429 |
email_unavailable | Email delivery is temporarily unavailable — retry later (HTTP 503) |