The SoShiny API
A REST API over your association's own data. Tokens are created by a board admin, scoped to exactly what a tool needs, and revocable at any time. Included on every plan, with no developer tier and no per-call charge.
Authentication
A board admin creates tokens at Settings → API access inside the portal. The token is shown once at creation and stored only as a one-way hash, so it cannot be recovered later. Send it as a bearer token on every request.
curl https://soshiny.com/api/v1/members \
-H "Authorization: Bearer shy_your_token_here"
Every token belongs to exactly one association, and every response is limited to that association's records. There is no account-wide or cross-association token.
Scopes
A token carries only the permissions ticked when it was created. Requesting anything outside them returns 403 insufficient_scope.
| Scope | Allows |
|---|---|
members:read | Read the member directory |
members:write | Create and update members |
units:read | Read units and occupancy |
units:write | Create and update units |
work_orders:read | Read work orders |
work_orders:write | Create and update work orders |
announcements:read | Read announcements |
announcements:write | Post announcements |
events:read | Read the community calendar |
events:write | Create and update events |
documents:read | List documents (metadata only, not file contents) |
finance:read | Read budgets, balances and financial summaries |
webhooks:manage | Set up and remove its own event notifications |
There is no write scope for the general ledger. Financial records are readable but not writable over the API, because every posting must go through the balance, period-lock and fund checks in the accounting engine rather than around them.
Resources
Base URL https://soshiny.com/api/v1. Collections accept GET, and
POST where writes are supported; a single record accepts GET and
PATCH. All bodies are JSON.
Members /members
Owners, renters, staff and board members.
GET /api/v1/members
GET /api/v1/members/{id}
POST /api/v1/members
PATCH /api/v1/members/{id}
| Returns | id, first_name, last_name, email, role, unit_number, phone, is_owner, status, last_login_at, created_at |
|---|---|
| Accepts on write | first_name, last_name, email, unit_number, phone, is_owner, status |
| Scopes | members:read members:write |
Units /units
Units, lots or slips and their attributes.
GET /api/v1/units
GET /api/v1/units/{id}
POST /api/v1/units
PATCH /api/v1/units/{id}
| Returns | id, unit_number, type, bedrooms, baths, square_footage, ownership_percent, notes, created_at |
|---|---|
| Accepts on write | unit_number, type, bedrooms, baths, square_footage, ownership_percent, notes |
| Scopes | units:read units:write |
Work orders /work-orders
Maintenance and repair jobs.
GET /api/v1/work-orders
GET /api/v1/work-orders/{id}
POST /api/v1/work-orders
PATCH /api/v1/work-orders/{id}
| Returns | id, title, body, status, priority, unit_id, assigned_user_id, cost_estimate, cost_actual, due_date, opened_at, closed_at, created_at, updated_at |
|---|---|
| Accepts on write | title, body, status, priority, unit_id, assigned_user_id, cost_estimate, due_date |
| Scopes | work_orders:read work_orders:write |
Announcements /announcements
Notices posted to the community.
GET /api/v1/announcements
GET /api/v1/announcements/{id}
POST /api/v1/announcements
PATCH /api/v1/announcements/{id}
| Returns | id, title, body, type, audience, published_at |
|---|---|
| Accepts on write | title, body, type, audience, published_at |
| Scopes | announcements:read announcements:write |
Events /events
The community calendar.
GET /api/v1/events
GET /api/v1/events/{id}
POST /api/v1/events
PATCH /api/v1/events/{id}
| Returns | id, title, description, location, starts_at, ends_at, audience, created_at |
|---|---|
| Accepts on write | title, description, location, starts_at, ends_at, audience |
| Scopes | events:read events:write |
Documents /documents
Document metadata. File contents stay behind the portal gatekeeper.
GET /api/v1/documents
GET /api/v1/documents/{id}
| Returns | id, title, description, category, file_type, access_level, version, created_at |
|---|---|
| Accepts on write | read-only |
| Scopes | documents:read |
Paging and filtering
Collections return 50 records per page, up to a maximum of 200. Pass
page and per_page to move through them. Any returned field can also be
used as an exact-match filter; unrecognised parameters are ignored rather than passed through.
GET /api/v1/work-orders?status=open&per_page=100&page=2
{
"data": [ { "id": 412, "title": "Gate motor replacement", "status": "open" } ],
"meta": { "page": 2, "per_page": 100, "total": 137, "total_pages": 2 }
}
Errors
Errors return the matching HTTP status and a consistent body.
{ "error": { "code": "insufficient_scope",
"message": "This token does not carry the \"units:write\" scope." } }
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, malformed, expired or revoked token. |
| 403 | insufficient_scope | Valid token, but it lacks the scope for this call. |
| 403 | association_inactive | The association's subscription is not active. |
| 404 | not_found | No such record in your association. |
| 405 | read_only | The resource does not accept writes. |
| 429 | rate_limited | Over the hourly limit. See Retry-After. |
Who am I /me
Returns the association this token belongs to and the scopes it carries. It works with any valid token whatever its permissions, which makes it the right thing to test a connection against. Testing against a resource endpoint instead would fail for a perfectly good token that simply lacks that one scope.
{ "data": {
"association": { "id": 12, "name": "Bellair Condominiums" },
"token": { "name": "Zapier", "prefix": "shy_f01b9a76" },
"scopes": ["members:read", "webhooks:manage", "work_orders:read"] } }
Webhook subscriptions /webhooks
Subscribe and unsubscribe programmatically, which is how an automation tool turns delivery on
and off as a user enables or disables an automation. Requires the
webhooks:manage scope.
GET /api/v1/webhooks
POST /api/v1/webhooks
DELETE /api/v1/webhooks/{id}
POST /api/v1/webhooks
{ "url": "https://hooks.example.com/abc", "events": ["work_order.created"] }
201 { "data": { "id": 3, "url": "...", "events": [...], "secret": "9f2b..." } }
A token only ever sees and deletes the endpoints it created. Endpoints a board added by hand in the portal are invisible here and cannot be removed through the API, so a misbehaving integration cannot switch off notifications somebody else set up.
Webhooks
Rather than polling for changes, register an endpoint and we will POST to it when something happens. A board admin adds endpoints at Settings → API access, picks the events to receive, and gets a signing secret shown once.
Events
| Event | Fires when |
|---|---|
work_order.created | A work order is filed |
work_order.status | A work order changes status |
concern.created | A member submits a concern or compliment |
member.created | A member is added to the directory |
announcement.published | An announcement is posted |
event.created | An event is added to the calendar |
amenity.booked | An amenity booking is approved |
architectural.submitted | An architectural review request is submitted |
violation.issued | A violation notice is issued |
invoice.paid | A member pays dues |
Payload
Payloads carry identifiers and a short summary rather than the full record. Call the API with a scoped token when you need detail, so there is one permission model rather than two.
POST https://your-endpoint.example.com/hook
X-SoShiny-Event: work_order.created
X-SoShiny-Delivery: 4821
X-SoShiny-Timestamp: 1786412345
X-SoShiny-Signature: sha256=9f2b...
{
"event": "work_order.created",
"association_id": 12,
"occurred_at": "2026-08-09T16:32:18+00:00",
"data": { "id": 999, "title": "Gate motor replacement", "priority": "high" }
}
Verifying the signature
A webhook URL leaks eventually, into a screenshot or a shared automation. The signature is what
makes the URL alone useless for forging events, so verify it on every request. Compute
HMAC-SHA256 of timestamp + "." + rawBody using your signing secret,
and compare with a timing-safe function. Reject anything whose timestamp is more than
300 seconds old, which is what stops a captured request being replayed.
$body = file_get_contents('php://input');
$ts = (int)$_SERVER['HTTP_X_SOSHINY_TIMESTAMP'];
$expect = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expect, $_SERVER['HTTP_X_SOSHINY_SIGNATURE'] ?? '')) {
http_response_code(401); exit; // forged or tampered
}
if (abs(time() - $ts) > 300) {
http_response_code(401); exit; // replayed
}
Retries
Answer 2xx and quickly. Anything else is retried
6 times with growing gaps, roughly one minute, five minutes,
twenty-five minutes, two hours, then ten. Redirects are never followed, because the signature
belongs to the URL that was registered. After
15 consecutive failures we stop sending and flag the endpoint in the
portal, rather than hammering a dead URL forever.
Deliveries can arrive more than once, so make your handler idempotent. Use
X-SoShiny-Delivery to recognise a repeat.
Rate limits
600 requests per token per hour. Every response carries
X-RateLimit-Limit and X-RateLimit-Remaining, and a throttled response
adds Retry-After. If you have a job that genuinely needs more, get in touch rather
than sharding across tokens.
Create your first token
API access is included on every plan. Sign in as a board admin and open Settings → API access.