API

Members and permissions

Manage organizations, members, invitations, and grants.

The Permissions API runs Synq's team and access-control model: it reports whether the active org is a team, lists the resources you can grant, creates a team organization, and manages the per-member grants and invitations that scope a restricted member to specific projects, servers, or other resources. In the dashboard this is the organization's Team tab. See Auth & access for how roles and grants combine.

User grants give a restricted member access to specific resources. Roles also provide their standard permissions. Token grants and read-only restrictions are an additional limit for every role. A user grant is (user, resourceType, resourceId, permissions[]); resourceId: "*" applies across the organization.

Create a team organization

Creates a brand-new organization, marks it as a team, and sets the caller as its owner. Personal workspaces stay personal — this is the way to get a shared, multi-member org.

const result = await ship.permissions.createTeamOrg({
  name: "Acme Platform",
});
console.log(result);
curl -X POST "$SYNQ_URL/api/permissions/create-team-org" \
  -H "Authorization: Bearer $SYNQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Platform"}'

Prop

Type

Returns 201 with { data: { id, name, isTeam: true } }.

List grantable resources

Side-effect-free picker payload for the grant modal and invite flow. Pass the resource type; get back { id, label, meta? }[]. The * wildcard is not listed — the picker adds it itself.

const result = await ship.permissions.listResources({
  type: "server",
});
console.log(result);
curl "$SYNQ_URL/api/permissions/resources?type=server" \
  -H "Authorization: Bearer $SYNQ_TOKEN"

For type=billing or type=audit the only entry is the org-wide *. For type=github_repository you can narrow to one account with &owner=<login>.

Grant a resource

Idempotent upsert of a single grant tuple. Re-sending the same (userId, resourceType, resourceId) replaces its permissions in place. An empty permissions array revokes the grant (Synq keeps no zero-permission placeholder rows).

const result = await ship.permissions.upsertGrant({
  userId: "user_123",
  resourceType: "project",
  resourceId: "proj_abc",
  permissions: ["read", "write"],
});
console.log(result);
curl -X POST "$SYNQ_URL/api/permissions/grants" \
  -H "Authorization: Bearer $SYNQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userId":"user_123","resourceType":"project","resourceId":"proj_abc","permissions":["read","write"]}'

Prop

Type

Returns 201 with { data: <grant> }, or { data: null, revoked: true } when permissions was empty.

Replace a member's grant set

Sends the member's entire desired grant set; the server diffs against what exists — upserting added/changed tuples and deleting any that are absent. This is the single save path for the member-grants editor. Zero-permission entries are dropped.

const result = await ship.permissions.replaceGrants({
  userId: "user_123",
  grants: [
    {
      resourceType: "project",
      resourceId: "proj_abc",
      permissions: ["read", "write"],
    },
    {
      resourceType: "server",
      resourceId: "srv_1",
      permissions: ["read"],
    },
  ],
});
console.log(result);
curl -X PUT "$SYNQ_URL/api/permissions/grants" \
  -H "Authorization: Bearer $SYNQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "userId":"user_123",
    "grants":[
      {"resourceType":"project","resourceId":"proj_abc","permissions":["read","write"]},
      {"resourceType":"server","resourceId":"srv_1","permissions":["read"]}
    ]
  }'

Prop

Type

Each entry of grants is a Grant object:

Prop

Type

Non-wildcard, row-backed resources are checked to belong to the active org; a foreign id is rejected with 400 RESOURCE_NOT_IN_ORG.

Invite a member with grants

Wraps Better Auth's invite and, in the same call, stores pending grants against the invitation. When the invitee accepts, the accept-invite page calls POST /invitations/:id/materialize to turn those pending grants into real ones.

const result = await ship.permissions.inviteWithGrants({
  email: "dev@acme.com",
  role: "restricted",
  grants: [
    {
      resourceType: "project",
      resourceId: "proj_abc",
      permissions: ["read", "write"],
    },
  ],
});
console.log(result);
curl -X POST "$SYNQ_URL/api/permissions/invite-with-grants" \
  -H "Authorization: Bearer $SYNQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "email":"dev@acme.com",
    "role":"restricted",
    "grants":[{"resourceType":"project","resourceId":"proj_abc","permissions":["read","write"]}]
  }'

Prop

Type

Returns 201 with { data: { id, email, role, pendingGrantCount } }.

Materialize an invitation's grants

Called from the accept-invite page after Better Auth records the acceptance. It finds the invitation's pending grants, upserts them as real grants for the now-joined user, and clears the pending rows. Authorized by the invitation itself — the caller's email must match the invite and its status must be accepted.

const result = await ship.permissions.materializeInvitation("inv_123");
console.log(result);
curl -X POST "$SYNQ_URL/api/permissions/invitations/inv_123/materialize" \
  -H "Authorization: Bearer $SYNQ_TOKEN"

Returns { data: { materialized: <count> } }.

Errors

400 — missing or invalid fields

userId, resourceType, and resourceId are required on POST /grants; name is required on create-team-org; email on invite-with-grants. An unknown resourceType returns code: "INVALID_RESOURCE_TYPE". On the invitation flow, 400 also means the invite hasn't been accepted yet or has expired.

403 — not admin, or wrong invitee

The grant/invitation routes require the caller to be admin or owner in the active org. On /invitations/:id/materialize, 403 means your account's email doesn't match the invitation.

404 — not a member / grant not found

POST /grants returns 404 when the target userId isn't a member of the active org; DELETE /grants/:id returns 404 when the id doesn't belong to your organization (grants are org-scoped, so you can't touch another tenant's rows even by guessing the id).

Operations

OperationSDKREST API
Read the selected organization’s permission metadata.permissions.orgMeta()GET /api/permissions/org-meta
permissions:read
List resources available for permission grants.permissions.listResources(input)GET /api/permissions/resources
permissions:read
Create a team organization with the caller as its owner.permissions.createTeamOrg(input)POST /api/permissions/create-team-org
permissions:write
Read grants for the requested subject.permissions.listGrants(input)GET /api/permissions/grants
permissions:read
Create or update one resource grant.permissions.upsertGrant(input)POST /api/permissions/grants
permissions:write
Replace the requested subject’s grant set.permissions.replaceGrants(input)PUT /api/permissions/grants
permissions:write
List organization invitations.permissions.listInvitations()GET /api/permissions/invitations
permissions:read
Invite a member with explicit role and optional grants.permissions.inviteWithGrants(input)POST /api/permissions/invite-with-grants
permissions:write
List organization members.permissions.listMembers()GET /api/permissions/members
permissions:read
Apply accepted invitation grants to the resulting membership.permissions.materializeInvitation(id)POST /api/permissions/invitations/:id/materialize
permissions:write
Remove one grant.permissions.deleteGrant(id)DELETE /api/permissions/grants/:id
permissions:admin
Accept an authorized invitation.permissions.acceptInvitation(id)POST /api/permissions/invitations/:id/accept
permissions:write
Reject an invitation.permissions.rejectInvitation(id)POST /api/permissions/invitations/:id/reject
permissions:write
Cancel a pending invitation.permissions.cancelInvitation(id)POST /api/permissions/invitations/:id/cancel
permissions:write
Resend an eligible invitation.permissions.resendInvitation(id, input?)POST /api/permissions/invitations/:id/resend
permissions:write
Change a member’s organization role.permissions.setMemberRole(id, input)PATCH /api/permissions/members/:id
permissions:write
Remove a member while preserving the final owner.permissions.removeMember(id)DELETE /api/permissions/members/:id
permissions:admin

On this page