Developers

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.

ScopeAllows
members:readRead the member directory
members:writeCreate and update members
units:readRead units and occupancy
units:writeCreate and update units
work_orders:readRead work orders
work_orders:writeCreate and update work orders
announcements:readRead announcements
announcements:writePost announcements
events:readRead the community calendar
events:writeCreate and update events
documents:readList documents (metadata only, not file contents)
finance:readRead budgets, balances and financial summaries
webhooks:manageSet 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}

Returnsid, first_name, last_name, email, role, unit_number, phone, is_owner, status, last_login_at, created_at
Accepts on writefirst_name, last_name, email, unit_number, phone, is_owner, status
Scopesmembers: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}

Returnsid, unit_number, type, bedrooms, baths, square_footage, ownership_percent, notes, created_at
Accepts on writeunit_number, type, bedrooms, baths, square_footage, ownership_percent, notes
Scopesunits: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}

Returnsid, 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 writetitle, body, status, priority, unit_id, assigned_user_id, cost_estimate, due_date
Scopeswork_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}

Returnsid, title, body, type, audience, published_at
Accepts on writetitle, body, type, audience, published_at
Scopesannouncements: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}

Returnsid, title, description, location, starts_at, ends_at, audience, created_at
Accepts on writetitle, description, location, starts_at, ends_at, audience
Scopesevents:read events:write

Documents /documents

Document metadata. File contents stay behind the portal gatekeeper.

GET /api/v1/documents   GET /api/v1/documents/{id}

Returnsid, title, description, category, file_type, access_level, version, created_at
Accepts on writeread-only
Scopesdocuments: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." } }
StatusCodeMeaning
401unauthorizedMissing, malformed, expired or revoked token.
403insufficient_scopeValid token, but it lacks the scope for this call.
403association_inactiveThe association's subscription is not active.
404not_foundNo such record in your association.
405read_onlyThe resource does not accept writes.
429rate_limitedOver 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

EventFires when
work_order.createdA work order is filed
work_order.statusA work order changes status
concern.createdA member submits a concern or compliment
member.createdA member is added to the directory
announcement.publishedAn announcement is posted
event.createdAn event is added to the calendar
amenity.bookedAn amenity booking is approved
architectural.submittedAn architectural review request is submitted
violation.issuedA violation notice is issued
invoice.paidA 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.

Go to API access See all integrations