endorr®Core integration guideInternalIssuer https://auth.endorr.com
Endorr Core · for Suite engineers

One identity. One file layer.
Here is how to plug into it.

Endorr Core owns accounts, sessions, OAuth/OIDC, organisations, teams, file identity and permissions. The Suite (and every product) is a client of Core: sign users in with Login with Endorr, then call the /v1 API with the access token. Nothing in this guide requires a shared database or a shared cookie.

01

Overview

ConcernWho owns itHow the Suite uses it
Accounts, passwords, email verification, sessionsCoreNever touch. Redirect to Core for sign-in; Core hands back an ID token.
Organisations, roles, invitations, teamsCore (data + rules)Suite renders the management UI on top of the /v1 organisation endpoints.
Files, folders, ACLs, share links, storageCore (metadata + signed URLs)Suite renders the browser; bytes go browser ↔ storage directly via signed URLs.
Sign-in, sign-up, email verification, password reset, consent, invitation acceptanceCore UI at https://auth.endorr.comLink to them; do not rebuild them. Users come back via return_to / the OAuth callback.
Account profile, password, sessions, connected apps, members, teams, Cloud file browser, sharing UISuiteBuild here, against /v1 with the account and organizations scopes.

Base URL for everything below: https://auth.endorr.com. All product-facing endpoints are versioned under /v1. Responses are JSON with snake_case keys and ISO-8601 timestamps. Every response carries an x-request-id header; include it in bug reports.

02

Signing users in

The Suite is a confidential OAuth client using Authorization Code + PKCE, like PrintReadySheets and FileFixer. Your backend keeps the client secret and refresh tokens; the browser only ever holds your own Suite session. Do not try to read the Core session cookie: it is HttpOnly and scoped to auth.endorr.com, and cross-origin cookie calls to /v1 are rejected by the CSRF check by design.

1. Register the client

Core seeds an endorr-suite first-party client, which is the only kind allowed to request the account scope. Set its callback URLs and secret from the deployment environment and run the seed once:

Core deploymentbash
SUITE_REDIRECT_URIS=https://cloud.endorr.com/auth/endorr/callback,http://localhost:3000/auth/endorr/callback \
SUITE_CLIENT_SECRET=<generate with: openssl rand -base64 32> \
pnpm db:seed

Redirect URIs are matched exactly (scheme, host, port, path, no query, no fragment). http is only accepted for loopback hosts.

2. Discover the endpoints

GET /.well-known/openid-configurationjson
{
  "issuer": "https://auth.endorr.com",
  "authorization_endpoint": "https://auth.endorr.com/oauth/authorize",
  "token_endpoint": "https://auth.endorr.com/oauth/token",
  "userinfo_endpoint": "https://auth.endorr.com/oauth/userinfo",
  "revocation_endpoint": "https://auth.endorr.com/oauth/revoke",
  "jwks_uri": "https://auth.endorr.com/.well-known/jwks.json",
  "code_challenge_methods_supported": ["S256"],
  "grant_types_supported": ["authorization_code", "refresh_token"]
}

3. Redirect to Core

Build the authorisation URL (server side)ts
import { randomBytes, createHash } from 'node:crypto';

const verifier = randomBytes(48).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(16).toString('base64url');
const nonce = randomBytes(16).toString('base64url');
// store { verifier, state, nonce } in the user's pre-login session

const url = new URL('https://auth.endorr.com/oauth/authorize');
url.search = new URLSearchParams({
  response_type: 'code',
  client_id: 'endorr-suite',
  redirect_uri: 'https://cloud.endorr.com/auth/endorr/callback',
  scope: 'openid profile email offline_access organizations files:read files:write account',
  state, nonce,
  code_challenge: challenge,
  code_challenge_method: 'S256',
  // optional: org_id: 'org_…'  → skip the organisation picker
  // optional: prompt: 'select_account' → force the organisation picker
}).toString();
redirect(url);

Core signs the user in if needed, requires a verified email, shows the "Continue to Endorr Suite" consent screen the first time, and redirects back with code and state. Errors come back as error + error_description on the same callback (for example access_denied, login_required with prompt=none).

4. Exchange the code

POST /oauth/token (client_secret_basic)ts
const res = await fetch('https://auth.endorr.com/oauth/token', {
  method: 'POST',
  headers: {
    'content-type': 'application/x-www-form-urlencoded',
    authorization: 'Basic ' + Buffer.from(`endorr-suite:${process.env.ENDORR_CLIENT_SECRET}`).toString('base64'),
  },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code,                                   // from the callback
    redirect_uri: 'https://cloud.endorr.com/auth/endorr/callback',
    code_verifier: verifier,                // from the pre-login session
  }),
});
const tokens = await res.json();
// { access_token, id_token, refresh_token, token_type: 'Bearer', expires_in: 3600, scope }

Codes are single use and expire after 2 minutes. A failed exchange (wrong verifier, wrong redirect URI) consumes the code, so restart the flow rather than retrying.

03

Tokens and verification

TokenFormatLifetimeUse it for
id_tokenRS256 JWT60 minEstablishing who signed in. Verify once, then create your own Suite session.
access_tokenRS256 JWT (typ at+jwt)60 minAuthorization: Bearer on every /v1 call.
refresh_tokenopaque, rotating60 daysGetting a new access token from your backend. Store the newest one only.
Verify the ID tokents
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://auth.endorr.com/.well-known/jwks.json'));

const { payload } = await jwtVerify(tokens.id_token, JWKS, { issuer: 'https://auth.endorr.com', audience: 'endorr-suite' });
if (payload.nonce !== expectedNonce) throw new Error('nonce mismatch');
// payload: { sub: 'usr_…', email, email_verified, name, org_id?: 'org_…', auth_time, iat, exp }

Store sub as the user's endorr_user_id and org_id as their selected organisation. Never match accounts by email address: two people can share a mailbox, and Core has already proven control of it. Keys rotate; the JWKS keeps retired keys for 30 days, so cache the JWKS but refetch on an unknown kid.

Refresh (rotating)ts
const r = await fetch('https://auth.endorr.com/oauth/token', { method: 'POST', headers, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token }) });
const next = await r.json();       // contains a NEW refresh_token — replace the stored one atomically
// Presenting an already-rotated refresh token revokes the whole family (all tokens from that grant).
When a call returns 401 with a valid-looking token
The user disconnected the Suite from Account → Connected apps, or reuse detection fired. Clear your Suite session and send them through the flow again. Do not loop on refresh.
04

Calling the API

Every /v1 requesthttp
GET /v1/me HTTP/1.1
Host: auth.endorr.com
Authorization: Bearer <access_token>
Accept: application/json

Sending users to Core pages

Core hosts the screens that need Core's own session: sign-in, sign-up, verification, password reset, invitation acceptance. Link to them with return_to set to an absolute URL on one of your registered origins (any redirect URI or homepage origin of a registered client) and Core sends the user back there afterwards. Anything else falls back to Core's minimal signed-in page, which itself links to the Suite.

https://auth.endorr.com/login?return_to=https://cloud.endorr.com/files
https://auth.endorr.com/signup?return_to=https://cloud.endorr.com/welcome&email=anna@acmeprint.com
https://auth.endorr.com/verify-email?next=https://cloud.endorr.com/

Organisation context

Every file and folder belongs to exactly one organisation. A bearer token carries the organisation chosen at sign-in (org_id) and all resource calls use it. To let a user work in another organisation they belong to, send them through /oauth/authorize again with org_id=… (consent is remembered, so it is a silent redirect) and swap the tokens. POST /v1/files/uploads and POST /v1/folders also accept an explicit organization_id.

Errors

{ "error": { "code": "forbidden", "message": "You do not have permission to do that", "details": null, "request_id": "…" } }
StatusCodeMeaningWhat to show
401unauthorizedNo or invalid token / sessionRe-authenticate
403forbiddenInside the organisation, but the role is insufficient (or the token lacks a scope)"You need editor access" style messaging
404not_foundResource does not exist, is deleted, or belongs to another organisation. Core never distinguishes these."Not found"
409conflictState conflict, e.g. completing an upload before the bytes arrivedRetry or explain
422validation_failedBad input; details[] lists { path, message }Field errors
429rate_limitedToo many attemptsBack off

Scopes

ScopeGrants
openidConfirm who you are with an Endorr ID token.
profileYour name.
emailYour email address and whether it is verified.
offline_accessStay signed in to this product without asking again.
organizationsThe organisations you belong to and your role in them.
files:readOpen your Endorr files in this product.
files:writeSave new files and derivatives to Endorr.
accountManage your Endorr account: profile, password, sessions and connected apps.

Request only what the Suite needs. Missing scopes come back as 403 forbidden with the scope named in the message. account lets a first-party client manage the signed-in account (profile, password, sessions, connected apps) on the user's behalf; Core refuses it for any client that is not marked first-party.

Rate limits

Per IP unless stated: token endpoint 60 / 1 min, authorisation 60 / 1 min, invitations 30 / 60 min per user, share-link resolution 60 / 1 min. Login and signup limits apply to Core's own pages only.

05

Organisations and teams

Roles are owneradminmember. They control organisation administration only; they are not file permissions (see below). Every account gets a personal workspace organisation at signup; personal workspaces cannot invite members or be left. Business organisations are created with POST /v1/me/organizations.

ActionWho may do it
Rename organisation, create/delete teams, add/remove team membersowner, admin
Invite members, revoke invitations, change member ↔ adminowner, admin
Remove an adminowner
Transfer ownership (PATCH role=owner; the previous owner becomes admin)owner
Leave the organisation (DELETE own membership)any non-owner member

Removing a member also removes their team memberships and user-level resource grants in that organisation. Deleting a team removes the team's grants. Membership lists include you: true for the caller so the UI can disable self-actions.

Members listjson
GET /v1/organizations/org_…/members
{ "members": [ { "user_id": "usr_…", "name": "Anna", "email": "anna@acmeprint.com", "email_verified": true, "role": "admin", "joined_at": "…", "you": false } ] }
06

Permissions

Resource roles are viewereditormanager and are evaluated by Core on every request. The effective role is the strongest of:

  • Organisation owners and admins are manager on everything in their organisation.
  • The creator of a file or folder is manager on it.
  • Explicit grants on the resource itself or on any ancestor folder, to an organization (everyone), a team, or a user principal. Grants inherit downwards; there are no deny rules.
RoleCan
viewerList, read metadata, download, open in another product
editorViewer + upload into the folder, rename/move files, promote to permanent, delete files, record derivatives
managerEditor + list/add/remove grants, create share links, rename/move/delete folders
Render from capabilities, never re-derive
Every file and folder response includes capabilities: { can_view, can_edit, can_manage } computed for the caller. Use it to enable or hide actions. Core still checks on the write, so a stale UI can never escalate.
Grant a team editor access to a folder (inherits to every child)http
PUT /v1/folders/fld_…/permissions
{ "principal_type": "team", "principal_id": "tm_…", "role": "editor" }

GET /v1/folders/fld_…/permissions        → manager only
{ "grants": [ { "id": "grant_…", "principal_type": "team", "principal_id": "tm_…", "principal_label": "Prepress", "role": "editor", … } ] }

DELETE /v1/folders/fld_…/permissions/grant_…

Principals must belong to the same organisation as the resource; Core rejects anything else with 422. Members without any grant get 403; users from other organisations get 404. Design list views around what the API returns: GET /v1/folders/:id/children already filters to what the caller may see.

07

Files and folders

The unit of the suite is the Endorr file ID (file_…). It is stable across products, storage providers and promotion from temporary to permanent. Pass IDs between products, never signed URLs.

Upload

1. Create the record and get a signed URL (server or browser with bearer)http
POST /v1/files/uploads
{ "folder_id": "fld_…", "name": "front-logo.png", "mime_type": "image/png", "size_bytes": 482113, "storage_class": "temporary" }

201 { "file": { "id": "file_…", "status": "pending", "expires_at": "…", … },
      "upload": { "url": "https://…signed…", "method": "PUT", "headers": { "content-type": "image/png" }, "expires_at": "…" } }
2. Send the bytes straight to storage from the browserts
await fetch(upload.url, { method: upload.method, headers: upload.headers, body: fileBlob });
// If you passed size_bytes, the PUT must send exactly that many bytes (it is part of the signature).
3. Finalisehttp
POST /v1/files/file_…/complete
{ "sha256": "<hex, optional>", "size_bytes": 482113 }
→ { "file": { "status": "ready", "size_bytes": 482113, … } }

Signed URLs live 10 minutes. Uploads into a folder need editor there; uploads to the organisation root need membership only. Files are temporary by default and expire after 30 days; PATCH { "storage_class": "permanent" } keeps them and clears expires_at without changing the ID. Show the expiry state in the UI ("Saved to Endorr for 30 days"); the Suite is where "Keep permanently" lives.

Open in another product

GET  /v1/files/file_…            → metadata, capabilities, path (folder breadcrumb), lineage
POST /v1/files/file_…/download   → { "url": "https://…signed…", "expires_at": "…" }   (viewer)

Folders

POST  /v1/folders                     { "name": "Customers", "parent_folder_id": null }
GET   /v1/folders/root/children       → top level of the organisation (folders[] + files[], each with capabilities)
GET   /v1/folders/fld_…/children
PATCH /v1/folders/fld_…               { "name": "…", "parent_folder_id": "fld_…" }   (manager)
DELETE /v1/folders/fld_…              soft delete; everything beneath becomes invisible (manager)

Derivatives and lineage

A fixed file, mockup or sheet is a new file. After creating it, record the edge so users can trace what happened:

POST /v1/files/file_SOURCE/relationships
{ "derived_file_id": "file_DERIVED", "relationship_type": "pixlpilot_enhancement" }   // or prs_sheet, mockup, …

GET /v1/files/:id returns lineage.derived_from[] and lineage.derivatives[]. created_via on every file is the client id that made it, so the Suite can show the product mark.

Deletion and retention

DELETE /v1/files/:id is a soft delete (editor). Objects are purged after 14 days by Core's retention job; the metadata row stays so lineage never dangles. GET /v1/storage/usage returns temporary and permanent bytes for the storage meter.

08

Share links

Managers can create viewer-only links for a file or folder. The token is returned once inside the URL; Core stores only its hash.

POST /v1/files/file_…/shares      { "expires_in_days": 7, "max_downloads": 20 }
201 { "share": { "id": "shr_…", "url": "https://auth.endorr.com/s/<token>", "expires_at": "…" } }

GET  /v1/shares/resolve/<token>   anonymous, rate-limited → file metadata + a fresh signed download URL
DELETE /v1/shares/shr_…           revoke (manager)

The public landing page for a link belongs to the Suite: route /s/<token> on your domain, call the resolve endpoint from your server, render the file card and the download button. Set APP_URL-relative share URLs to your own host by rewriting the returned url path.

09

Invitations

The Suite renders the members page and calls the invite endpoints; Core sends the email and hosts the acceptance flow, because accepting may involve creating and verifying an account.

POST /v1/organizations/org_…/invites   { "email": "anna@acmeprint.com", "role": "member" }   (admin, verified email)
GET  /v1/organizations/org_…/invites   → pending invitations
DELETE /v1/organizations/org_…/invites/inv_…

The recipient lands on https://auth.endorr.com/invite/<token>, signs in or creates an account with the invited address, and is added to the organisation. Invitations are valid for 7 days, single use, and only the invited address can accept. After acceptance Core shows its minimal signed-in page with a link to the Suite (DEFAULT_RETURN_URL); pass return_to on the invite link if you want them somewhere specific.

10

The SDK

@endorr/core-sdk wraps everything on this page for TypeScript, Next.js and React: PKCE and the token endpoints, ID-token verification against JWKS, a typed /v1 client, direct-to-storage uploads with progress, route handlers for the login/callback dance, and a small React provider. It has one runtime dependency (jose).

Next.js: two route files and you are signed ints
// lib/endorr.ts (server only)
import { createEndorrAuth } from '@endorr/core-sdk/nextjs';
export const endorrAuth = createEndorrAuth({
  issuer: 'https://auth.endorr.com', clientId: 'endorr-suite', clientSecret: process.env.ENDORR_CLIENT_SECRET!,
  redirectUri: 'https://cloud.endorr.com/auth/endorr/callback', cookieSecret: process.env.ENDORR_COOKIE_SECRET!,
  scope: ['openid', 'profile', 'email', 'offline_access', 'organizations', 'files:read', 'files:write', 'account'],
});

// app/auth/endorr/login/route.ts
export const { GET } = endorrLoginHandler(endorrAuth);

// app/auth/endorr/callback/route.ts
export const { GET } = endorrCallbackHandler(endorrAuth, {
  onSuccess: async ({ tokens, claims, returnTo }) => {
    await sessions.create({ endorrUserId: claims.sub, orgId: claims.org_id, refreshToken: tokens.refresh_token, accessToken: tokens.access_token });
    return Response.redirect(new URL(returnTo, 'https://cloud.endorr.com'), 303);
  },
});
Calling the APIts
import { EndorrClient } from '@endorr/core-sdk';
const core = new EndorrClient({ baseUrl: 'https://auth.endorr.com', accessToken: () => session.accessToken() });

const me = await core.me.get();
const members = await core.organizations.members(me.organization!.id);
await core.me.changePassword({ current_password, new_password });          // account scope
const { folders, files } = await core.folders.children(null);              // organisation root
await core.permissions.grant('folder', folderId, { principal_type: 'team', principal_id: teamId, role: 'editor' });
Browser uploads (React)tsx
import { EndorrProvider, useEndorrUpload } from '@endorr/core-sdk/react';
// <EndorrProvider config={{ baseUrl: '/api/endorr', credentials: 'same-origin' }}> — your BFF proxy adds the bearer token.
const { upload, progress, uploading } = useEndorrUpload();
await upload(file, { folderId });   // create → PUT to storage → complete

Keep tokens server-side. The browser talks to your own backend (or a thin proxy that appends the bearer header); only the signed storage URL is ever used directly from the browser.

11

Endpoint reference

Identity and account

EndpointAuthScopeWhat it does
GET/v1/mesession · bearerCurrent user, selected organisation, token scope.
PATCH/v1/mesession · beareraccountUpdate profile (name).
POST/v1/me/passwordsession · beareraccountChange password with the current one; revokes other Core sessions.
POST/v1/me/verification/resendsession · beareraccountSend a new verification email.
GET/v1/me/sessionssession · beareraccountActive Core web sessions (device, IP, last seen).
DELETE/v1/me/sessions/:idsession · beareraccountRevoke one session.
POST/v1/me/sessions/revoke-otherssession · beareraccountSign out everywhere else.
GET/v1/me/connected-appssession · beareraccountProducts the user authorised, with granted permissions.
DELETE/v1/me/connected-apps/:clientIdsession · beareraccountDisconnect a product (revokes its tokens).
GET/v1/me/organizationssession · bearerorganizationsOrganisations the user belongs to with their role.
POST/v1/me/organizationssession · bearerorganizationsCreate a business organisation (verified email required).
GET/oauth/userinfobearerOIDC userinfo: sub, email, name, org_id, organizations[] with the organizations scope.

Organisations and teams

EndpointAuthScopeWhat it does
GET/v1/organizations/:idsession · bearerorganizationsOrganisation details; 404 for non-members.
PATCH/v1/organizations/:idsession · bearerorganizationsRename (admin).
GET/v1/organizations/:id/memberssession · bearerorganizationsMember list with roles.
PATCH/v1/organizations/:id/members/:userIdsession · bearerorganizationsChange role; owner transfer with role=owner.
DELETE/v1/organizations/:id/members/:userIdsession · bearerorganizationsRemove member or leave.
GET/v1/organizations/:id/invitessession · bearerorganizationsPending invitations (admin).
POST/v1/organizations/:id/invitessession · bearerorganizationsInvite by email (admin).
DELETE/v1/organizations/:id/invites/:inviteIdsession · bearerorganizationsRevoke invitation.
GET/v1/organizations/:id/teamssession · bearerorganizationsTeams with members.
POST/v1/organizations/:id/teamssession · bearerorganizationsCreate team (admin).
DELETE/v1/teams/:idsession · bearerorganizationsDelete team and its grants.
PUT/v1/teams/:id/members/:userIdsession · bearerorganizationsAdd member to team.
DELETE/v1/teams/:id/members/:userIdsession · bearerorganizationsRemove from team.
POST/v1/session/switch-organizationsessionChange the selected organisation of a Core session.
Session-only endpoints
POST /v1/session/switch-organization and POST /v1/invites/:token work with the Core session only: they belong to Core's own pages. A product changes organisation context by re-authorising with org_id.

Files, folders, permissions, shares

EndpointAuthScopeWhat it does
POST/v1/files/uploadssession · bearerfiles:writeCreate file record + signed upload URL.
POST/v1/files/:id/completesession · bearerfiles:writeConfirm bytes, mark ready.
GET/v1/files/:idsession · bearerfiles:readMetadata, capabilities, path, lineage.
POST/v1/files/:id/downloadsession · bearerfiles:readSigned download URL.
PATCH/v1/files/:idsession · bearerfiles:writeRename, move, promote to permanent.
DELETE/v1/files/:idsession · bearerfiles:writeSoft delete.
POST/v1/files/:id/relationshipssession · bearerfiles:writeRecord a derivative edge.
GET/v1/files/recentsession · bearerfiles:readRecent visible files in the organisation (limit ≤ 50).
GET/v1/storage/usagesession · bearerfiles:readTemporary/permanent bytes and file count.
POST/v1/folderssession · bearerfiles:writeCreate folder.
GET/v1/folders/:idsession · bearerfiles:readFolder metadata and path. :id may be root for children only.
GET/v1/folders/:id/childrensession · bearerfiles:readFolders and files the caller may see.
PATCH/v1/folders/:idsession · bearerfiles:writeRename or move (manager).
DELETE/v1/folders/:idsession · bearerfiles:writeSoft delete subtree (manager).
GET/v1/{files|folders}/:id/permissionssession · bearerfiles:readList grants (manager).
PUT/v1/{files|folders}/:id/permissionssession · bearerfiles:writeCreate or update a grant (manager).
DELETE/v1/{files|folders}/:id/permissions/:grantIdsession · bearerfiles:writeRemove a grant (manager).
POST/v1/{files|folders}/:id/sharessession · bearerfiles:writeCreate share link (manager).
DELETE/v1/shares/:idsession · bearerfiles:writeRevoke share link.
GET/v1/shares/resolve/:tokennoneResolve a share link (rate-limited).
12

Local development

Run Core next to the Suitebash
# in endorr-auth
cp .env.example .env                      # SECRET_KEY, DATABASE_URL (Postgres on :5433 locally)
pnpm install && pnpm db:migrate
SUITE_REDIRECT_URIS=http://localhost:3000/auth/endorr/callback SUITE_CLIENT_SECRET=dev-suite-secret pnpm db:seed
pnpm dev                                  # https://auth.endorr.com

# in the Suite
ENDORR_ISSUER=https://auth.endorr.com
ENDORR_CLIENT_ID=endorr-suite
ENDORR_CLIENT_SECRET=dev-suite-secret

Locally, Core writes emails to .dev/outbox.jsonl (verification, reset and invite links included) and serves signed storage URLs itself, so the whole upload flow works without a bucket. Core's own smoke suite (pnpm smoke) shows every flow above as executable HTTP: see tests/smoke/ in the Core repository.

13

Go-live checklist

  • Client registered with production callback URLs only; secret stored in the Suite's secret manager.
  • ID tokens verified against JWKS with issuer and audience checks; nonce compared.
  • Users keyed by sub, never by email.
  • Refresh tokens stored server side, replaced atomically on rotation; 401 handling sends the user back through sign-in.
  • All action buttons driven by capabilities; 403 and 404 rendered distinctly.
  • Uploads go browser → storage via the signed URL, then /complete; the Suite never proxies bytes.
  • Temporary-file expiry visible in the UI with a "Keep permanently" action.
  • Cross-product links carry file_… IDs, not signed URLs.
  • Storage bucket CORS allows the Suite origin (see ops/b2-cors-rules.json in Core).
  • Account and organisation management screens live in the Suite; Core only hosts sign-in flows and links back with return_to.

Endorr Core · integration guide generated from the running configuration. Questions go to the Core owners.