The SC Orgs API: keys, requests and webhooks
Give your org's bot or dashboard an API key, read members and events, and get signed webhooks when members join or an op is about to start.
Updated 2026-10-29
The SC Orgs API lets a bot, a dashboard or another tool read your org: its profile, members, roles, events and who signed up. It can also call your server the moment someone joins or an op is about to start (webhooks). You make an API key for it in Manage › API, and the key works for your org only. The API is read-only for now.
You need the Manage API Keys permission. The owner has it; nobody else does until the owner gives it to a role (see Advanced). The full reference and more code examples are on the developer docs.
Try it on the sandbox
You can try the API before making a key. ~SANDBOX is a made-up test org with a few members, roles and upcoming events, and its read-only key is public:
curl https://api.sc-orgs.space/api/v1/me \ -H "Authorization: Bearer sco_test_hKI7h4mK1eSEen1hfCCBUXOSQnKxIFbHSPcPM7sXVAl"
The answer names the org (~SANDBOX) and the key's scopes. Swap /me for /orgs/~SANDBOX/members or /orgs/~SANDBOX/events to see what a real key returns.
Create a key
- On your community page, click Manage, then open the API tab.
- Click Create key.
- Give it a Name, like the name of your bot.
- Pick what it can read under Scopes, or click a preset like Discord bot (read). Scopes you can't hand out are greyed out and show the permission they need.
- Pick when it Expires in: 30, 90, 180 or 365 days. Every key expires; 90 days is the default.
- Click Create key.
- Copy the key, tick I've stored it somewhere safe and click Done. It's only shown this once. If you lose it, rotate the key (see Advanced).
For your security, creating a key needs a recent sign-in. If you use two-factor sign-in, you're asked for a code. Otherwise, if your last sign-in was more than 10 minutes ago, click Sign in again and try once more.
Make your first request
Every request goes to https://api.sc-orgs.space/api/v1 and sends the key in a header: Authorization: Bearer sco_live_…. Keep the key on your server: never put it in a web page or an app people download.
- Check the key works: GET /me returns your org and the key's scopes.
- List your members: GET /orgs/{org}/members, where {org} is your org's SID (or its id). It needs the members:read scope.
- List upcoming events: GET /orgs/{org}/events shows the next 30 days, members-only events included, with events:read.
curl https://api.sc-orgs.space/api/v1/orgs/YOURSID/members \ -H "Authorization: Bearer sco_live_…"
Every answer is JSON, inside data. Lists come 25 at a time; ask for more with page and page_size (up to 100). Each list says how many there are in total.
Add a webhook
A webhook is a web address on your server that SC Orgs calls when something happens.
- On the API tab, under Webhooks, click Add webhook.
- Enter the Endpoint URL. It must start with https://.
- Pick the Key it works with. It only gets the events that key's scopes allow.
- Tick the Events you want, like member.joined or event.starting.
- Click Add webhook, then copy the signing secret (whsec_…) shown once, tick I've stored it somewhere safe and click Done.
- Click Send test. SC Orgs sends a ping right away and shows what your server answered.
Your server should answer with a 2xx status within 10 seconds. Delivery log shows every delivery of the last 30 days, and Redeliver sends one again. Check the signature of every delivery before you trust it: see Advanced.
Advanced
Scopes and who can manage keys
- The scopes. org:read: the member view of your org's profile (member count including hidden members, owner, whether Discord is linked). members:read: the roster, with handles, avatars, roles, training ranks and join dates. roles:read: role names, ranks, descriptions and Discord role ids, without their permissions. events:read: your events, members-only ones included. rsvps:read: who signed up for each event (used with events:read). There's no "everything" scope.
- A key never does more than its creator. members:read and roles:read need View Members, and rsvps:read needs Manage Events. They're checked when the key is made and again on every request. If you lose a role, your keys lose the scopes it gave (/me lists the ones still usable in effective_scopes). If you leave the org, sign out everywhere or delete your account, your keys are suspended.
- Who can manage keys. Give Manage API Keys (in the Integrations group) to a role on Manage › Roles: see Roles and permissions. Holders can revoke any of the org's keys. They can only change or rotate keys made by members ranked below them; the owner can change any key.
- Take over a key. When a key's creator has left the org, any key manager can click Take over to make the key act for them, or Revoke it. A key whose creator is still a member can be taken over by managers ranked above that member, and by the owner. Either way you need the permissions behind the key's scopes yourself.
- Rotate a key. Click Rotate on the key and pick how long the old one keeps working (24 hours by default, up to a week, or not at all). Copy the new key, update your bot, and the old one stops by itself. Key managers are told 7 days and 1 day before a key expires.
- Limits. An org can have 25 live keys and 10 webhooks. Keys are made and changed on the website only, never from the companion app.
Endpoints
All of them are GET, under https://api.sc-orgs.space/api/v1. The OpenAPI spec describes each one, with examples; you can import it into Postman or a code generator.
- /me: the key, its org and its scopes. Works with any key.
- /orgs/{org}: the org's profile. org:read adds the member view.
- /orgs/{org}/members: the roster, by join date. Filters: role_id, joined_since (a date and time) and q (the start of a handle, at least 3 characters). Needs members:read.
- /orgs/{org}/members/{member}: one member, by user id or by rsi: and their handle, like rsi:CarolSC. Needs members:read.
- /orgs/{org}/roles: every role, highest rank first. Needs roles:read.
- /orgs/{org}/events: events by start time. from and to set the window (ISO 8601; by default now to 30 days ahead, and at most 90 days). status is upcoming, ongoing, past or cancelled; without it, cancelled events are left out. Members-only events need events:read.
- /orgs/{org}/events/{event_id}: one event.
- /orgs/{org}/events/{event_id}/rsvps: who signed up, by sign-up time. Needs events:read and rsvps:read.
With a key, {org} must be the key's own org. Any other org answers 404, even a real one, so a key never learns what another org has.
Calls without a key
An org's profile and its public events (/orgs/{org}, /orgs/{org}/events and one event) also work with no key at all, so your org's website can show them. Those answers can be read from any website (CORS). Answers to requests with a key can't, because a key in a web page is a key anyone can copy. A request with a bad key is refused, never treated as a call without a key.
Paging
- page starts at 1. page_size is 1 to 100 (25 by default); anything bigger is cut to 100.
- A list looks like { "data": { "members": […], "total": 142, "page": 2, "page_size": 25 } }. Stop when you've read total items.
- Paging stops at the first 10,000 results (page × page_size). Past that you get a 400: narrow the filters, or keep in sync with webhooks.
- Times are ISO 8601 in UTC, and ids are UUIDs.
Errors
Errors look like this, whatever the endpoint:
{ "error": { "code": "INSUFFICIENT_SCOPE", "message": "This key lacks the rsvps:read scope", "details": { "required": ["events:read", "rsvps:read"], "effective": ["org:read", "events:read"] } } }
- 400 VALIDATION_ERROR: a parameter is wrong. validationErrors lists the fields.
- 401 UNAUTHORIZED: no key, or not a valid key.
- 401 KEY_EXPIRED: the key passed its expiry date. Make a new one.
- 401 KEY_REVOKED: the key was revoked.
- 401 KEY_SUSPENDED: its creator left the org, signed out everywhere or deleted their account (details.reason says which). A key manager can take it over or revoke it.
- 403 INSUFFICIENT_SCOPE: the key lacks a scope the endpoint needs, or its creator lost the permission behind it.
- 404 NOT_FOUND: not there, or not your org's.
- 429 RATE_LIMIT_EXCEEDED: too many requests. Wait Retry-After seconds.
- 503 SERVICE_UNAVAILABLE: the API is busy. Wait Retry-After seconds and try again.
- 500 INTERNAL_SERVER_ERROR: something broke on our side. Try again later.
Rate limits
- Each key: 120 requests a minute, in bursts of up to 30.
- All of an org's keys together: 600 a minute.
- Calls without a key: 60 a minute from each IP address, in bursts of up to 20.
- Invalid keys: after 20 in 15 minutes from one IP address, requests with a key from that address get a 429 until the 15 minutes are up.
- Send test: 10 an hour for each webhook.
Answers carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (when the limit is full again, in unix seconds). When you get a 429, wait the Retry-After seconds. A key that gets 100 403 answers in an hour is suspended, because it's clearly set up wrong.
Webhook deliveries
Each delivery is a POST with a JSON body like this:
{ "id": "evt_…", "type": "member.joined", "api_version": "v1", "occurred_at": "2026-10-27T18:04:11.000Z", "organization": { "id": "4b2c…", "sid": "HRMS" }, "data": { "member": { "id": "c4a8…", "rsi_handle": "carol", … }, "via": "invite" } }
- The events. member.joined (data: member, and via invite or application) and member.left (member, and reason left, removed or account_deleted) need members:read. member.roles_updated (member, and the role ids before, after, added and removed) needs members:read and roles:read. event.created, event.updated, event.cancelled (with reason cancelled or deleted) and event.starting (15 minutes before, with starts_in_seconds) need events:read, and their data has the event. Each date of a recurring event is its own event. ping comes from Send test only.
- If the key loses a scope, deliveries go on, but data only has the ids.
- The URL must be https:// on port 443 or 8443, on a public host. Redirects aren't followed: a 3xx counts as a failure.
- Order isn't guaranteed. Use occurred_at, and the updated_at of the event.
Check the signature of every webhook
Each delivery has webhook-id, webhook-timestamp and webhook-signature headers (Standard Webhooks). The signature is v1, and then the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with your secret: the part after whsec_, base64-decoded. Check it against the body exactly as it arrived, before you parse the JSON. The standardwebhooks library for Node, Python and other languages does the check for you.
JavaScript (Node, Express):
// npm install standardwebhooks express import express from 'express'; import { Webhook } from 'standardwebhooks'; const wh = new Webhook(process.env.SCO_WEBHOOK_SECRET); // whsec_… const app = express(); // express.raw keeps the body exactly as it arrived. app.post('/sco-hook', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = wh.verify(req.body.toString('utf8'), req.headers); // throws if it isn't from SC Orgs } catch { return res.status(400).end(); } if (event.type === 'member.joined') console.log('Welcome', event.data.member.rsi_handle); res.status(204).end(); // answer fast; do slow work afterwards }); app.listen(3000);
Python (Flask):
# pip install standardwebhooks flask import os from flask import Flask, request from standardwebhooks import Webhook wh = Webhook(os.environ["SCO_WEBHOOK_SECRET"]) # whsec_… app = Flask(__name__) @app.post("/sco-hook") def hook(): try: event = wh.verify(request.get_data(), dict(request.headers)) except Exception: return "", 400 if event["type"] == "event.starting": print(event["data"]["event"]["title"], "starts in", event["data"]["starts_in_seconds"], "seconds") return "", 204
Python without a library (the same check, step by step):
import base64, hashlib, hmac, time def verify(secret, headers, raw_body): # raw_body: bytes, as received headers = {k.lower(): v for k, v in headers.items()} msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"] if abs(time.time() - int(ts)) > 300: # more than 5 minutes off: reject return False key = base64.b64decode(secret.removeprefix("whsec_")) expected = hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest() return any( # two signatures while a secret rotates hmac.compare_digest(base64.b64decode(sig.split(",", 1)[1]), expected) for sig in headers["webhook-signature"].split(" ") if sig.startswith("v1,") )
curl: send your own server a signed test delivery, to check your code before you go live. It signs the body with your secret the way SC Orgs does (it needs openssl and xxd):
SECRET='whsec_…' URL='http://localhost:3000/sco-hook' BODY='{"id":"evt_test","type":"ping","api_version":"v1","occurred_at":"2026-10-28T12:00:00.000Z","organization":{"id":"test","sid":"TEST"},"data":{"message":"Hello from SC Orgs"}}' ID="msg_test_$(date +%s)"; TS=$(date +%s) KEY=$(printf '%s' "${SECRET#whsec_}" | openssl base64 -d -A | xxd -p -c 256) SIG=$(printf '%s' "$ID.$TS.$BODY" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary | openssl base64 -A) curl -i "$URL" -H 'content-type: application/json' \ -H "webhook-id: $ID" -H "webhook-timestamp: $TS" -H "webhook-signature: v1,$SIG" \ --data-raw "$BODY"
Your server should accept it. Change one letter of BODY after signing, and it should answer 400.
- Old and repeated deliveries. Turn away deliveries more than 5 minutes old (the library does). A retried or redelivered delivery has the same webhook-id, so skip ids you've already handled.
- Rotate secret keeps the old secret signing for 24 hours, so you can switch without missing deliveries. Meanwhile webhook-signature has two signatures, separated by a space; one match is enough.
Retries and turning off
A delivery that doesn't get a 2xx within 10 seconds is tried again after about 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours. After 20 failures in a row, or 3 days of failures, the webhook turns off and key managers are told; fix your server, then click Turn on. A 410 Gone answer turns it off at once. Revoking the key turns off its webhooks, and while a key is suspended its webhooks get nothing.
Test orgs and keys
Keys of test orgs like ~SANDBOX start with sco_test_ and can never see real orgs or members. Test orgs are invisible to everyone else, calls without a key included. The sandbox data is reset now and then.
What the API never shares
Citizen Intel, HR records and application answers, members' live in-game location, Discord ids, email addresses, last activity, and account or two-factor details. Members who hide their membership stay hidden from the public, but your own org's keys see them.
Versions
The version is in the path (/api/v1). Within v1, changes only add fields, endpoints and events, so ignore fields you don't know. Anything that would break your code comes as a new version.
Bots on Discord
The API pairs well with the SC Orgs bot for your server: see Connect your Discord server.
All guides
Start here
Getting started
Run your org
Orgs
Members & roles
- Roles and permissions for your org
- Invite members to your org
- Review applications to join your org
- Set up training tracks and ranks
Events & ops
Discord
Treasury
API & webhooks
Your account
Account