Authentication, API keys and roles
plugsync has three credentials, and each one opens a different part of the
API. This page says which credential to send to which endpoint, what an
organization API key can and cannot do, how the owner and member roles
split the work inside an organization, and how to manage the team and rotate
keys.
| Credential | Looks like | Sent as | Opens | Created by |
|---|---|---|---|---|
| Organization API key | ps_org_... | Authorization: Bearer ps_org_... | The config plane (/api/...): connectors, drafts, publish, events, replay | An owner |
| User session | A JWT | Authorization: Bearer <access_token> | The config plane, plus the routes that need a person | Logging in |
| Connector API key | ps_live_... | x-api-key: ps_live_... | The event plane only: sending events to one connector | Any member, owner or organization key |
The two planes do not mix. A connector key is refused on the config plane
(401). The event plane reads only the x-api-key header: an organization
key or a session token sent as Authorization: Bearer answers
401 Missing x-api-key header, and an organization key put in x-api-key
answers 401 Invalid or revoked API key. Ingestion always takes a connector
key.
Organization API keys
An organization API key is the credential for scripts, CI and the
CLI. It is the same Authorization: Bearer token on every
config-plane endpoint as a login session, with no 15-minute expiry, no refresh
calls and no cookie jar.
Fields
| Field | Required | Meaning |
|---|---|---|
label | yes | 1 to 100 characters. What the key is for (ci, nightly-export). It is the only thing that tells two keys apart in the listing, in GET /api/me and in the record of who started a drain or a data export, so make it specific. |
expires_at | no | ISO 8601 datetime. Omit it for a key that never lapses. It must be in the future (422 otherwise); a value without a timezone, a bare date included, is read as UTC. Past that instant the key answers 401 exactly like a revoked key. |
There is no scope field. A key is not restricted to a connector, to read-only calls or to a subset of endpoints: it acts on the whole organization, on every connector, with the authority described in What an organization key can do. Store it like a password, and give each automation its own key so you can revoke one without breaking the others.
Managing keys
All three calls need a signed-in owner (an organization key cannot mint, list or revoke keys, see Owner-only actions). The dashboard does the same from Settings → API Keys.
| Method | Path | Effect |
|---|---|---|
POST | /api/org-api-keys | Create a key. Body {"label": "ci", "expires_at": "2027-01-31T00:00:00Z"} (expires_at optional). Answers 201 with the key and its raw_key, shown once: only a hash is kept, so a lost key cannot be recovered, only replaced. |
GET | /api/org-api-keys | List the active keys: id, key_prefix (the first 12 characters of the key), label, is_active, created_at, last_used_at, expires_at. Never the key itself. Revoked keys are left out. |
DELETE | /api/org-api-keys/{key_id} | Revoke a key, immediately. Answers 204; requests carrying it answer 401 from then on. A key of another organization answers 404. |
curl -s -X POST https://app.plugsync.com/api/org-api-keys \
-H "Authorization: Bearer $OWNER_JWT" -H 'content-type: application/json' \
-d '{"label": "ci"}'
# -> {"id": "...", "key_prefix": "ps_org_3f9a1", "label": "ci", "is_active": true,
# "created_at": "...", "last_used_at": null, "expires_at": null,
# "raw_key": "ps_org_..."}last_used_at moves on every request the key authenticates. A key belongs to
the organization, not to the owner who created it: it keeps working when that
owner leaves or is demoted, so revoking the keys someone handled is part of
offboarding them.
To check which key a script holds, call GET /api/me with it: the response
has principal_type: "api_key", the org_id, the api_key_label and
role: "api_key", and no user fields.
What an organization key can do
A key carries the fixed role api_key. It is neither an owner nor a member,
so every route decides explicitly whether it admits one:
- Everything a member can do on the config plane: create and edit connectors, drafts and event sources, publish, mint connector keys, read events, replay, retry a circuit breaker.
- The operational levers reserved to owners that also admit a key, because only an owner can create a key and the key is what automation holds: draining a connector’s backlog, repairing an identity mapping and running an identity pre-warm, exporting the organization’s data, importing and revoking compat keys.
What a key cannot do:
- Owner-only actions (team, invitations, ownership, organization
settings, API keys, HubSpot connect links, billing details):
403with{"detail": {"error": "owner_required"}}, oruser_auth_requiredon the billing routes. The full list is in Owner-only actions. - Actions that need a person, listed in
Actions that need a signed-in user:
releasing a connector held for review, checkout and plan changes, creating
an organization, creating a
test-mode connector, accepting the legal documents. - The event plane: send events with a connector key.
User sessions
A user session is what the dashboard uses, and what a script gets from logging in with an email and a password. It is meant for interactive use: for anything unattended, use an organization key.
| Step | Call | Result |
|---|---|---|
| Log in | POST /api/auth/login with {"email", "password"}, optional active_org_id | access_token (a JWT) and expires_in (900 seconds, 15 minutes), plus an httpOnly refresh cookie and a readable CSRF cookie. |
| Second factor | POST /api/auth/mfa/challenge with {"mfa_token", "code"} | Only when the account has two-factor authentication on: login then answers {"mfa_required": true, "mfa_token": "...", "expires_in": ...} instead of a session, and the session starts here, with a code from the authenticator app or a recovery code. |
| Refresh | POST /api/auth/refresh with the cookies and an X-CSRF-Token header equal to the CSRF cookie | A new access_token. The refresh cookie is replaced on every call and lasts 14 days from the last refresh. |
| Log out | POST /api/auth/logout (same CSRF header), ?all=true to end every session of the account | The refresh cookie stops working. |
Once expires_in runs out, config-plane requests answer 401: refresh before
it does. A 401 on refresh means the session is over (expired, logged out,
or a refresh cookie presented twice, which ends the whole session family as a
precaution): log in again. The
API-first quickstart
has the full curl sequence with the cookie jar.
Changing the password (POST /api/me/password) revokes the refresh cookies
of every other session of the account and keeps the current one: the other
sessions cannot refresh any more, but an access token they already hold keeps
working until its expires_in runs out (at most 15 minutes).
Signing in with Google
When Google sign-in is available (GET /api/v1/auth/google/config answers
{"enabled": true}), the dashboard’s login, signup and HubSpot install
pages offer Continue with Google. It is a browser flow, not something
to script: it ends in the same session as a password login (access token,
refresh cookie, two-factor step when the account has it on). Which account
it opens, creates or links, and when it refuses, is described step by step
in Getting started: continue with Google;
the codes it reports are in
Google sign-in errors.
An account created with Google has no password: changing the password or
the email, turning two-factor off and
closing the organization all ask for it, and
answer 401 wrong_password until one is set with the password reset
(Forgot password? on the login page). The reset
also unlinks Google from the account, and so does any password reset; the
next Continue with Google links it again when Google is authoritative for
the address.
Active organization
A user can belong to several organizations, with a different role in each.
A session acts on one at a time, its active organization: the
active_org_id returned by login and refresh. Every config-plane call with
that token reads and writes that organization only.
To switch, call POST /api/me/active-organization with {"org_id": "..."}:
it answers {"access_token": "...", "active_org_id": "..."}, a token for the
new organization, and 403 not_a_member if the user does not belong to it.
POST /api/auth/refresh also accepts {"active_org_id": "..."}, but it
does not fail on an organization the user does not belong to: it silently
falls back to another of their memberships, so check active_org_id in the
response. Use POST /api/me/active-organization when a wrong organization
must be an error.
GET /api/me lists every membership with its role.
The role is read from the membership on every request, not stored in the token: promoting, demoting or removing someone takes effect on their next call, without waiting for their token to expire.
Connector API keys
A connector key authenticates the Event API: it
goes in the x-api-key header and is bound to exactly one connector. It
cannot read or change configuration.
| Method | Path | Effect |
|---|---|---|
POST | /api/connectors/{connector_id}/api-keys | Create a key. Body {"label": "ingest"} (1 to 100 characters, surrounding spaces trimmed). Answers 201 with raw_key, shown once. |
GET | /api/connectors/{connector_id}/api-keys | List the active keys: id, key_prefix, label, is_active, created_at, last_used_at. Revoked keys are left out. |
DELETE | /api/connectors/{connector_id}/api-keys/{key_id} | Revoke a key, immediately. Events sent with it answer 401 from then on. |
These calls authenticate on the config plane: any member, owner or
organization key of the connector’s organization can make them. A connector of
another organization answers 404. In the dashboard: connector → Settings →
API keys. A key can also be minted together with the connector, with
"create_api_key": true and an optional "api_key_label" on
POST /api/connectors or on creation from a template.
A connector key has no expiry date: it works until revoked. key_prefix is
ps_live_ on every connector key, so the label is what tells them apart.
Roles: owner and member
Inside an organization every user is either an owner or a member. There
is no other role, and no “admin”: the authority above a member is the owner.
- owner: whoever creates the organization at signup, and anyone an owner invites or promotes as owner. An organization can have several owners, and always has at least one.
- member: everyone else. A member works on connectors like an owner: config, drafts, publish, events, replay, connector keys. What a member cannot do is administer the organization, nor pull the operational levers listed in Owner or organization key: drain, identity mapping repair, identity pre-warm, data export, importing and revoking compat keys.
Organization API keys are not users and have no role in this sense: see What an organization key can do.
Owner-only actions
These routes need a signed-in owner of the organization. A member gets 403
with {"detail": {"error": "owner_required"}}, and so does an organization
key (on the billing routes a key gets 403 user_auth_required instead).
| Action | Endpoints |
|---|---|
| See and manage the team: list members, change a role, remove someone | GET /api/orgs/{org_id}/members, PATCH and DELETE /api/orgs/{org_id}/members/{user_id} |
| Invite people, list and revoke pending invitations | GET, POST /api/orgs/{org_id}/invitations, DELETE /api/orgs/{org_id}/invitations/{invitation_id} |
| Transfer ownership | POST /api/orgs/{org_id}/members/transfer-ownership |
| Change the organization settings: name, incident notifications, record retention | PATCH /api/orgs/{org_id} |
| Create, list and revoke organization API keys | /api/org-api-keys |
| Create, list and revoke HubSpot connect links, which let a HubSpot admin without a plugsync account connect a portal | /api/orgs/{org_id}/hubspot-connect-links |
| Let a connector held for review after its first sync continue | POST /api/connectors/{connector_id}/write-cap/release |
| Edit the billing details, read order confirmations and the agreement | PATCH /api/v1/billing/details, GET /api/v1/billing/order-confirmations, GET /api/v1/billing/agreement |
| Close the organization, or cancel a closure | POST, DELETE /api/orgs/{org_id}/closure |
The routes under /api/orgs/{org_id}/... also check that {org_id} is the
session’s active organization: an owner of two
organizations switches before acting on the other one.
Release after the first sync. Some organizations have a first-sync
review: a new connector pauses after its first records are written to
HubSpot, and the owners get an email asking them to check those records in
the CRM. The release resumes the connector and sends the held records on;
it answers 409 write_cap_not_tripped when the connector is not held. It
needs an owner signed in: an organization key is refused even though it
can pull the other owner levers, because the check is meant for a person
and the key is held by the same automation that sent the records. What
happens before and after: First-sync write cap.
HubSpot connect links
A connect link lets someone who administers a HubSpot portal, but has no plugsync account, connect that portal to your organization. An owner creates it, signed in (an organization key cannot), and sends it; the dashboard does the same from the “HubSpot” card on the Connectors page, as shown in Getting started.
| Method | Path | Effect |
|---|---|---|
POST | /api/orgs/{org_id}/hubspot-connect-links | Create a link. No body. Answers 201 with id, url, expires_at, created_at. The url is shown once: the list does not carry it. |
GET | /api/orgs/{org_id}/hubspot-connect-links | List the organization’s links: id, created_at, expires_at, used_at, revoked_at, and once used credential_id, portal_id, hub_domain of the portal it connected. |
DELETE | /api/orgs/{org_id}/hubspot-connect-links/{link_id} | Revoke a link that was not used yet. Answers 204, also for a link already revoked; 409 link_already_used for a used one, 404 link_not_found for an unknown one. |
Rules of a link:
- It expires 24 hours after it is created.
- It connects one portal, once. It is spent when the connection succeeds: an attempt that fails or is cancelled on HubSpot can be retried with the same link while it is valid.
- The connection is the organization’s, as if an owner had connected the portal: the plan’s portal limit applies, and approving a portal that is already connected to the organization updates its connection.
The person who opens the url sees a Connect HubSpot page naming your
organization, with the scopes plugsync asks for and the link’s expiry; the
button takes them to HubSpot’s consent screen and back to “HubSpot
connected successfully.” A link that cannot be used answers, on the page
and on its public calls, 410 with link_revoked, link_used or
link_expired, or link_invalid for a link that is malformed (400) or
unknown (404). The fix is a new link from an owner. All codes:
API error catalog.
Owner or organization key
These levers are refused to members (403 owner_required) and accepted from
an owner or an organization key:
| Action | Reference |
|---|---|
| Start and stop a drain | Discarding a backlog: drain |
| Read, remove or clear the tombstone of one identity mapping | Repairing one identity mapping |
| Start, follow, resume and cancel an identity pre-warm | Pre-warming the identity map |
| Request and download a data export | Data export |
| Import and revoke compat keys (listing them is open to members) | Compat keys |
Actions that need a signed-in user
These routes refuse an organization key whatever the role, because they act on behalf of a person:
| Action | Endpoint | Error for a key |
|---|---|---|
| Release a connector held for review (owner) | POST /api/connectors/{connector_id}/write-cap/release | 403 owner_required |
| Start a checkout, preview a plan change, open the billing portal | POST /api/v1/billing/checkout-session, /plan-change-preview, /portal-session | 403 user_auth_required |
| Accept the updated legal documents (owner) | POST /api/legal-acceptances | 403 user_session_required |
| Create another organization (the caller becomes its owner) | POST /api/orgs | 403 api_key_cannot_create_org |
Create a test-mode connector | POST /api/connectors with "mode": "test" | 403 api_key_cannot_create_test_connector |
| Change your own profile, password, email or two-factor settings, or switch the active organization | PATCH /api/me, POST /api/me/active-organization, /password, /email, /email/confirm, every route under /api/me/mfa/ | 403 user_auth_required: these act on a user account, and a key has none. GET /api/me accepts a key and describes it. |
Managing the team
Invitations
An owner invites someone by email, choosing the role they join with:
curl -s -X POST https://app.plugsync.com/api/orgs/$ORG_ID/invitations \
-H "Authorization: Bearer $OWNER_JWT" -H 'content-type: application/json' \
-d '{"email": "ana@company.com", "role": "member"}'
# -> 201 {"id": "...", "email": "ana@company.com", "role": "member",
# "expires_at": "...", "created_at": "..."}role is owner or member. The invitee gets an email with a link valid for
7 days. The call answers:
| Status | detail.error | When |
|---|---|---|
409 | already_member | The address already belongs to a member. |
409 | invitation_pending | An invitation to that address is still open. |
402 | seat_limit_reached | The plan’s seat limit is reached. Seats count members and open invitations (an expired invitation does not count); the body carries limit, usage and an upgrade_url. Accepting an invitation answers the same code only when the plan no longer has room for it, for example after a downgrade. See Team seats. |
GET /api/orgs/{org_id}/invitations lists the open invitations, and
DELETE /api/orgs/{org_id}/invitations/{invitation_id} revokes one (204,
also when it was already revoked; 409 already_accepted once accepted). An
invitation whose link has expired is no longer open: it leaves the list, frees
its seat and does not block a new invitation to the same address.
The link opens the dashboard, where the invitee accepts. Someone without an account sets a name and a password there; someone who already has an account must sign in with the invited address first (the same link cannot add a different account). An accepted invitation signs the invitee in, with the new organization active.
Changing a role and removing someone
PATCH /api/orgs/{org_id}/members/{user_id} with {"role": "owner"} or
{"role": "member"} changes a role; DELETE on the same path removes the
member. Both answer 404 membership_not_found for a user who is not a member,
and 409 last_owner when they would leave the organization without an owner.
A removed user’s requests on the organization answer 403 from their next
call, even with an access token issued before the removal. If it was their
only organization, their refresh cookies are revoked as well, so no session
of theirs can be renewed.
Transferring ownership
curl -s -X POST https://app.plugsync.com/api/orgs/$ORG_ID/members/transfer-ownership \
-H "Authorization: Bearer $OWNER_JWT" -H 'content-type: application/json' \
-d '{"to_user_id": "..."}'
# -> {"new_owner": "..."}The target becomes owner and the caller becomes member, in one step. The
target must already be a member of the organization (404 target_not_member
otherwise): invite them first. To add an owner without stepping down, promote
them with PATCH .../members/{user_id} instead. The dashboard offers both
from Settings → Team.
Rotating and revoking keys
Both kinds of key can coexist with others of the same kind, which is what makes a rotation free of downtime:
- Create the new key, with a
labelthat tells it from the old one. - Deploy it wherever the old one is used.
- Watch the old key’s
last_used_atin the listing: once it stops moving, nothing uses it any more. - Revoke the old key.
For an organization key you can also set expires_at when you create it, so
that a forgotten key lapses on its own; plan the rotation before that date,
since past it the key answers 401 with no warning. Connector keys have no
expiry date.
Revocation is immediate and final: there is no call to reactivate a key. Revoke at once a key that may have leaked, then create its replacement.
Organization lifecycle
An organization lives until an owner closes it. Closing is not immediate: it starts a 30-day recovery period, fixed, during which the organization is stopped but nothing is deleted yet and any owner can change their mind. At the end of the period the organization and all its data are deleted, and that cannot be undone.
Closing the organization
In the dashboard: Settings → Organization → Close organization, a card shown to owners only. The dialog asks you to type the organization’s name, your password, and the authentication code when two-factor authentication is on for your account. An account created with Google has no password: set one first with Forgot password? on the login page. The reset signs out every session of the account and unlinks Google; the next Continue with Google links it again under the usual rules.
Through the API, a signed-in owner of the active organization calls:
curl -X POST "$PLUGSYNC_URL/api/orgs/$ORG_ID/closure" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"confirm_name": "Acme", "password": "...", "mfa_code": "123456"}'
# -> {"closing": true, "requested_at": "...", "deletes_at": "..."}deletes_at is 30 days after requested_at. mfa_code is needed only
with two-factor on. A member and an organization key get
403 owner_required. The request is refused when:
| Status | Code | Why |
|---|---|---|
422 | confirmation_mismatch | confirm_name is not exactly the organization’s name (spaces around it are ignored). |
401 | wrong_password, mfa_code_required, invalid_code | The password is wrong (an account created with Google has none until it sets one), or the two-factor code is missing or wrong. |
429 | Too many password change attempts | Too many wrong passwords or codes: the limit is shared with password changes. |
409 | org_already_closing | A closure is already running. |
409 | org_has_commercial_agreement | The organization has a commercial agreement: it cannot close itself. Contact support@plugsync.com to end the agreement first. |
While the organization is closing
From the moment of the request:
- Every sync stops, inbound and outbound. The Event API, bulk imports,
inbound webhooks, manual syncs, full extracts and replays answer
423 org_closing(Suspended or closing organization), and a HubSpot app installed from the marketplace cannot be attached to the organization. - Every member sees a banner, “This organization is being closed”, with the deletion date, and can still sign in and read the dashboard.
- Owners can still export the data until the deletion date: see Data export.
- Every owner gets an email, “Organization will be deleted on date”, listing what will be deleted.
Cancelling a closure
Any owner, signed in, can cancel until the deletion date: Cancel closure on the same card, or
curl -X DELETE "$PLUGSYNC_URL/api/orgs/$ORG_ID/closure" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# -> {"closing": false, "requested_at": null, "deletes_at": null}No password is asked. Syncs resume and every owner gets an email that the
closure was cancelled. What changed in HubSpot or in your systems while the
organization was closing is not replayed automatically. Cancelling answers
409 org_not_closing when there is no closure, and 409 org_erasure_started
once the deletion date has passed: from then on the deletion can no longer
be stopped.
Deletion
Shortly after the deletion date the organization is deleted with everything in it: connectors, configuration and versions, credentials and HubSpot connections, identity mappings, event history, archived records, plugins, API keys, invitations and exports. The accounts of members who belong to no other organization are deleted too; members of other organizations keep their account. A paid subscription is cancelled.
Nothing is deleted in HubSpot: records plugsync wrote stay there, and the plugsync app stays installed on the portal until you uninstall it from HubSpot.
Every owner gets a last email when the deletion is done. As that email says, backups kept for disaster recovery expire on their own within 35 days, and invoices already issued are kept as the law requires.
Errors
The errors below are the ones specific to authentication and roles. The API error catalog lists every error code of the public API, with the field that carries it and the remedy.
| Status | Body | Meaning |
|---|---|---|
401 | Invalid or revoked API key | The key is unknown, revoked or, for an organization key, past its expires_at. On the event plane, also an organization key sent in x-api-key. |
401 | Missing x-api-key header | An event-plane call without the x-api-key header, for example with a key or token sent as Authorization: Bearer. |
401 | Invalid token | The session token is expired or malformed, or a connector key was sent as Bearer on the config plane. |
403 | owner_required | The route needs an owner and the caller is a member or an organization key. |
403 | wrong_active_org | A route under /api/orgs/{org_id}/... was called for an organization that is not the caller’s active one: switch the active organization first. |
403 | user_auth_required, user_session_required | The route needs a signed-in user and was called with an organization key. |
403 | User is not a member of the requested organization | The session names an organization the user no longer belongs to: log in again. |
409 | last_owner | The change would leave the organization without an owner. |