Developer Manual for API Integrators
Overview 🔗
This API provides a GraphQL-based interface for integrators to interact with the system. The API supports authentication via JWT tokens and provides access to various resources including work history, company data, and session information.
The API is accessible via a GraphQL endpoint, allowing integrators to query and mutate data according to their configured permissions.
API Endpoint: /graphql
For detailed API reference documentation, see: API Reference
Capabilities for Integrators 🔗
Beyond querying work history, the API offers a complete tool set for building a deep helpdesk/ticket-system integration:
- Onboarding & provisioning — check whether a pcvisit account exists, create a company + owner account on behalf of the integrator, or link an existing account, and activate the integration to obtain a company-specific API token.
- Webhooks — register a callback endpoint to receive session results (e.g.
SessionCompleted) without polling. - Team management — explicitly add or remove technicians on behalf of the integrator (there is no implicit auto-join).
- Single Sign-On (SSO) — issue a short-lived JWT for a specific technician so they land in a remote session without an extra login.
- Secure credential handover — pass remote-machine credentials encrypted (JWE) and have the backend decrypt them once.
- Stateful session context — attach a ticket reference and device reference to a session and receive them back when the session ends.
- Device matching — read third-party tool IDs (
thirdPartyIds) per managed computer for trivial, reliable device matching.
A full, end-to-end walkthrough of these capabilities is provided in the Integrator Integration section below.
Getting Started with GraphQL 🔗
If you’re new to GraphQL, here are some helpful resources:
-
Introduction to GraphQL - Official GraphQL documentation
-
How to GraphQL - Free tutorial for all skill levels
-
GraphQL Playground - Interactive GraphQL IDE for testing queries
GraphQL is a query language that allows you to request exactly the data you need. Unlike REST APIs, you can fetch multiple resources in a single request and avoid over-fetching data. This makes it ideal for building efficient prototypes and production applications.
Authentication 🔗
API Token Sources 🔗
Before you can authenticate with the API, you need to understand where the required tokens come from:
Integrator API Token 🔗
The integrator API token is provided by PCVisit directly in a secure manner. This token identifies your integration and grants access to the API on behalf of your organization.
- The integrator token is issued by PCVisit during the integration setup process
- It should be stored securely and never exposed in client-side code or public repositories
- This token is used to authenticate your integration’s requests to the API
User API Token 🔗
The user API token is created by the end user themselves using the PCVisit web application. This token represents the user’s consent to allow your integration to access their data.
- Users generate this token through their account settings in the PCVisit webapp
- Each user has their own unique user API token
- The token links your integration to a specific user’s account and permissions
- Users can revoke or regenerate their user API token at any time through the webapp
Important: Both tokens are required to obtain a JWT token. The integrator token authenticates your integration, while the user token ensures you’re acting on behalf of a specific user with their explicit consent.
How to Query for a JWT 🔗
To authenticate with the API, integrators must first obtain a JWT token using their API tokens. This is done through the SiRetrieveSignedJwt GraphQL query.
GraphQL Query:
query GetJWT($userApiToken: UserApiToken!, $integratorApiToken: IntegratorApiToken!) {
SiRetrieveSignedJwt(
userApiToken: $userApiToken
integratorApiToken: $integratorApiToken
) {
... on SignedJwtWasRetrieved {
token
customerUuid
}
... on Failure {
error
comment_for_dev
comment_for_user
}
}
}
cURL Example (with API token authentication):
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-H "x-api-key: your-integrator-api-token" \
-d '{
"query": "query GetJWT($userApiToken: UserApiToken!, $integratorApiToken: IntegratorApiToken!) { SiRetrieveSignedJwt(userApiToken: $userApiToken, integratorApiToken: $integratorApiToken) { ... on SignedJwtWasRetrieved { token customerUuid } ... on Failure { error comment_for_dev comment_for_user } } }",
"variables": {
"userApiToken": "your-user-api-token",
"integratorApiToken": "your-integrator-api-token"
}
}'
Note: The SiRetrieveSignedJwt query itself requires authentication. You can authenticate using your API tokens via the x-api-key header, or if you already have a JWT token, you can use the Authorization: Bearer header.
Alternative cURL Example (with existing JWT token):
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d '{
"query": "query GetJWT($userApiToken: UserApiToken!, $integratorApiToken: IntegratorApiToken!) { SiRetrieveSignedJwt(userApiToken: $userApiToken, integratorApiToken: $integratorApiToken) { ... on SignedJwtWasRetrieved { token customerUuid } ... on Failure { error comment_for_dev comment_for_user } } }",
"variables": {
"userApiToken": "your-user-api-token",
"integratorApiToken": "your-integrator-api-token"
}
}'
Response:
{
"data": {
"SiRetrieveSignedJwt": {
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"customerUuid": "customer-uuid-here"
}
}
}
For detailed authentication reference, see: Authentication Reference
How to Use Authentication Tokens 🔗
The API supports two authentication methods:
- JWT Tokens - Obtained via the
SiRetrieveSignedJwtquery - API Tokens - User API token and integrator API token (used to obtain JWT tokens)
Both methods can be used with REST/HTTP requests and WebSocket connections.
REST/HTTP Authentication 🔗
Using JWT Tokens
For REST/HTTP requests, include the JWT token in the Authorization header with the Bearer prefix:
Header Format:
Authorization: Bearer <your-jwt-token>
cURL Example:
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d '{
"query": "query { ... }"
}'
JavaScript Example:
fetch('https://api.example.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwtToken}`
},
body: JSON.stringify({
query: 'query { ... }'
})
});
Using API Tokens
For REST/HTTP requests, include the API token in the x-api-key header:
Header Format:
x-api-key: <your-api-token>
Note: API tokens can also be passed as query parameters, but using the header is recommended for security.
cURL Example:
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-H "x-api-key: your-api-token" \
-d '{
"query": "query { ... }"
}'
JavaScript Example:
fetch('https://api.example.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiToken
},
body: JSON.stringify({
query: 'query { ... }'
})
});
WebSocket Authentication 🔗
For WebSocket connections (GraphQL subscriptions), authentication is provided via connection parameters during the initial handshake.
Using JWT Tokens with WebSocket
When establishing a WebSocket connection, include the JWT token in the connectionParams with the Bearer prefix:
Connection Parameters:
{
authToken: "Bearer <your-jwt-token>"
}
JavaScript Example (Apollo Client):
import { SubscriptionClient } from 'subscriptions-transport-ws';
const client = new SubscriptionClient('wss://api.example.com/graphql', {
reconnect: true,
connectionParams: () => ({
authToken: `Bearer ${jwtToken}`
})
});
Python Example:
from gql import Client, gql
from gql.transport.websockets import WebsocketsTransport
transport = WebsocketsTransport(
url='wss://api.example.com/graphql',
init_payload={'authToken': f'Bearer {jwt_token}'}
)
client = Client(transport=transport, fetch_schema_from_transport=True)
Using API Tokens with WebSocket
When establishing a WebSocket connection, include the API token in the connectionParams without the Bearer prefix:
Connection Parameters:
{
authToken: "<your-api-token>"
}
JavaScript Example (Apollo Client):
import { SubscriptionClient } from 'subscriptions-transport-ws';
const client = new SubscriptionClient('wss://api.example.com/graphql', {
reconnect: true,
connectionParams: () => ({
authToken: apiToken // No "Bearer" prefix for API tokens
})
});
Python Example:
from gql import Client, gql
from gql.transport.websockets import WebsocketsTransport
transport = WebsocketsTransport(
url='wss://api.example.com/graphql',
init_payload={'authToken': api_token} # No "Bearer" prefix
)
client = Client(transport=transport, fetch_schema_from_transport=True)
Token Information 🔗
JWT Token Claims: The JWT token contains claims that define:
- The integrator identity
- Allowed API paths
- User permissions
- Token expiration time
Important Notes:
- JWT tokens have a limited lifetime. You should implement token refresh logic to obtain new tokens before expiration.
- For JWT tokens, always use the
Bearerprefix in both REST and WebSocket connections. - For API tokens, use the
x-api-keyheader in REST requests, or pass directly asauthToken(withoutBearer) in WebSocket connection parameters. - The API will automatically detect whether you’re using a JWT token (starts with
Bearer) or an API token based on the format.
Debugging a received JWT (jwt.io) 🔗
When you run into AccessDenied / TokenExpired and want to inspect what your integration is actually sending, you can decode the JWT on the website jwt.io.
Important security note: A JWT is a bearer credential (whoever has it can use it). Treat it like a password.
- Do not paste real production JWTs into third-party websites.
- Prefer using a short-lived token from a test account / test environment.
- Never share your JWT in tickets, screenshots, chat, or logs. If you must log it, redact it (e.g. keep only the first/last 6 chars).
How to decode it on jwt.io:
- Copy the token value (only the
eyJ...part). If you haveAuthorization: Bearer <token>, remove theBearerprefix. - Open jwt.io and paste the JWT into the “Encoded” field.
- Inspect the decoded parts:
- Header: check
alg(algorithm),typ, and optionallykid(key id). - Payload: check
exp(expiration),iat(issued at), and any identity/permission claims used by the API.
- Header: check
- Validate the time-based claims:
- If
expis in the past, request a new JWT viaSiRetrieveSignedJwtand retry. - If
nbfis in the future, verify your system clock (clock skew can break auth).
- If
Signature verification on jwt.io (optional):
- jwt.io can verify the signature only if you provide the correct public key for the signing key (
kid). - Never paste a private key. In most cases, decoding the header/payload is enough to debug integration issues.
Error Handling 🔗
Principles 🔗
The API uses a consistent error response format across all operations. All errors are returned as part of the GraphQL response using the Failure type.
Error Response Structure:
{
"data": {
"YourQuery": {
"error": "ErrorIDs.AccessDenied",
"comment_for_dev": "Detailed error message for developers",
"comment_for_user": "User-friendly error message"
}
}
}
Common Error Scenarios:
-
Authentication Errors:
AccessDenied: Invalid or missing JWT tokenTokenExpired: JWT token has expiredInvalidValue: Invalid API tokens provided
-
Authorization Errors:
InsufficientRights: User lacks required permissionsAccessDenied: Access denied to the requested resource
-
Validation Errors:
InvalidValue: Invalid input parametersMissingValue: Required parameters are missingDoesNotExist: Requested resource does not exist
Integrator / SSO-specific Errors:
SiSignedJwtTargetNotTeammember: ThetargetUserEmailpassed toSiRetrieveSignedJwtis not (yet) a member of the customer’s pcvisit team. Add the technician explicitly viaInviteToCompanyTeamOnBehalfOfIntegratorfirst (there is no auto-join).SiSignedJwtLimitExceeded: A quota relevant to the SSO request (e.g. parallel sessions) is exhausted.TeamUserLimitReached: The contract’s configured team-member limit was reached when adding a technician viaInviteToCompanyTeamOnBehalfOfIntegrator.CanceledSubscription/FeatureNotLicensed: Reused where semantically applicable (e.g. expired contract/trial or a feature such as integrator SSO not being licensed). When these occur in an integrator context,comment_for_usercarries a context-specific, user-ready message.
For every
Failure,erroris a stable, machine-readable code (enumErrorIDs) andcomment_for_useris a ready-to-display end-user message (default German). Integrations should displaycomment_for_userdirectly in their UI and react logically onerrorwithout parsing free text. -
Server Errors:
ApplicationFailure: Internal application errorBackendFailure: Backend service error
-
Rate Limiting Errors:
TryAgainLater: Rate limit exceeded (for integrator API calls)- The
comment_for_devfield contains HTTP-styleRetry-Afterinformation - Format:
Retry-After: <seconds> seconds (until <UTC date>) - Example:
Retry-After: 5 seconds (until Mon, 01 Jan 2024 12:00:00 GMT) - The
comment_for_devalso includes the configured rate limit (e.g., “Rate limit: 100 requests per minute”) - Important: When receiving a
TryAgainLatererror due to rate limiting, wait for the specified duration before retrying the request
- The
Error Handling Best Practices:
- Always check for the
Failuretype in union responses - Log the
comment_for_devfor debugging purposes - Display
comment_for_userto end users - Implement retry logic for transient errors:
- For
TryAgainLatererrors due to rate limiting, parse theRetry-Aftervalue fromcomment_for_devand wait before retrying - Extract the retry delay (in seconds) from the
Retry-Afterheader format - Use exponential backoff for other transient errors
- For
- Handle token expiration by refreshing the JWT
- Rate Limiting: Integrators are subject to per-integrator rate limits configured in the system. When a rate limit is exceeded, the API returns a
TryAgainLatererror with retry information in thecomment_for_devfield. Always respect theRetry-Aftertiming to avoid further rate limit violations.
Troubleshooting authentication and authorization errors 🔗
Use this checklist when you receive authentication/authorization errors.
If you receive ErrorIDs.AccessDenied:
- Ensure you are sending exactly one of the supported authentication formats:
- JWT:
Authorization: Bearer <jwt>(note theBearerprefix) - API token (only for endpoints that accept it):
x-api-key: <api-token>(noBearer)
- JWT:
- Ensure you are not accidentally sending an API token as a Bearer token.
- Decode the JWT on jwt.io and check
exp/nbf(see the section above).
If you receive ErrorIDs.TokenExpired:
- Request a new JWT via
SiRetrieveSignedJwtand retry the original request. - If you see frequent expiry failures, refresh the JWT a bit before
exp(and make sure your server clock is correct).
If you receive ErrorIDs.InsufficientRights:
- The JWT is valid, but the user/token does not have the required permission(s) for the queried resource.
- Verify you are using the correct user’s API token (end-user consent) and that the user account has the expected rights in PCVisit.
For detailed error reference, see: Error Reference
Functionality 🔗
How to Get Work History 🔗
The work history (session history) can be retrieved using the AcQuerySessionsHistory GraphQL query. This query returns a paginated list of session protocols for a given company.
GraphQL Query:
query GetWorkHistory(
$company: CustomerUuid!
$timeFrame: TimeFrame
$filter: Filter
$sort: [Sort!]
$search: Search
$pagination: Pagination
) {
AcQuerySessionsHistory(
company: $company
timeFrame: $timeFrame
filter: $filter
sort: $sort
search: $search
pagination: $pagination
) {
... on SessionProtocolEdge {
nodes {
cursor
value {
id
name
sessionCreatedAt
sessionFinishedAt
sessionCompanyUuid
state
type
duration
customDuration
invoiceState
participants {
supporterIdentity {
id
}
supporterContact {
email
}
isSessionCreator
}
target {
computerId
computerDisplayName
}
clientCompany {
clientId
clientName
}
}
}
totalCount
batchLoadingIsStillActive
reportingActiveSince
}
... on Failure {
error
comment_for_dev
comment_for_user
}
}
}
Important Schema Notes:
The AcQuerySessionsHistory query returns a SessionProtocolEdge which contains a nodes array. Each node is of type SessionProtocolNode, which wraps the actual session data in a value field:
- Structure:
SessionProtocolEdge→nodes: [SessionProtocolNode]→value: SessionProtocol - Access Pattern: You must access session fields through
nodes { value { ... } }- you cannot query fields directly on the node - Common
SessionProtocolfields:id: TaskUuid- Unique session identifiername: SessionName- Session name/descriptionsessionCreatedAt: UnixTimeMs- When the session was created (Unix timestamp in milliseconds)sessionFinishedAt: UnixTimeMs- When the session finished (null if still active)sessionCompanyUuid: CustomerUuid- Company UUID associated with the sessionstate: SessionState- Current state of the session (e.g., “Open”, “Closed”)type: SessionType- Type of session (e.g., “Adhoc”, “RemoteHost”)duration: DurationMs- Calculated duration in millisecondscustomDuration: DurationMs- Custom duration override if setinvoiceState: TaskInvoiceStatus- Invoice statusparticipants: [SessionProtocolParticipant]- Array of session participantstarget: SessionProtocolMainTarget- Target computer/asset informationclientCompany: ClientCompany- Client company information if applicable
cURL Example:
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d '{
"query": "query GetWorkHistory($company: CustomerUuid!, $timeFrame: TimeFrame, $pagination: Pagination) { AcQuerySessionsHistory(company: $company, timeFrame: $timeFrame, pagination: $pagination) { ... on SessionProtocolEdge { nodes { value { id name sessionCreatedAt sessionFinishedAt sessionCompanyUuid state } } totalCount batchLoadingIsStillActive reportingActiveSince } ... on Failure { error comment_for_dev comment_for_user } } }",
"variables": {
"company": "12345678-1234-1234-1234-123456789012",
"timeFrame": {
"startDate": 1609459200000,
"endDate": 1640995200000
},
"pagination": {
"offset": 0,
"lengthStartingFromOffset": 10
}
}
}'
Note: The Pagination type requires lengthStartingFromOffset (required) and optionally accepts offset and cursor. The first and after fields are not part of this API’s pagination schema.
Response:
{
"data": {
"AcQuerySessionsHistory": {
"nodes": [
{
"cursor": "cursor_abc123",
"value": {
"id": "session-uuid-1",
"name": "Support Session",
"sessionCreatedAt": 1609459200000,
"sessionFinishedAt": 1609462800000,
"sessionCompanyUuid": "12345678-1234-1234-1234-123456789012",
"state": "Closed",
"type": "Adhoc",
"duration": 3600000,
"customDuration": null,
"invoiceState": "Invoiced"
}
}
],
"totalCount": 100,
"batchLoadingIsStillActive": false,
"reportingActiveSince": 1609459200000
}
}
}
Error Response Example:
{
"data": {
"AcQuerySessionsHistory": {
"error": "AccessDenied",
"comment_for_dev": "no matching allowed relationships found",
"comment_for_user": "Sie haben nicht die nötige Berechtigung, um diese Funktion zu nutzen!"
}
}
}
Query Parameters:
company(required): The customer UUID of the company (type:CustomerUuid)timeFrame(optional): Filter sessions by time range (type:TimeFrame)startDate: Unix timestamp in milliseconds (inclusive)endDate: Unix timestamp in milliseconds (inclusive)- Note: Both timestamps must be in milliseconds since Unix epoch (January 1, 1970). For example, to query the last 7 days: calculate
endDateas current time in milliseconds, andstartDateas current time minus 7 days in milliseconds.
filter(optional): Additional filtering criteria (type:Filter)sort(optional): Array of sort criteria (type:Sort)search(optional): Text search parameters (type:Search)pagination(optional): Pagination parameters (type:Pagination)lengthStartingFromOffset(required): Number of items to retrieve starting from the offsetoffset(optional): Starting position for pagination (defaults to 0 if not provided)cursor(optional): Cursor for pagination (alternative to offset)
Cursor-Based Pagination 🔗
The API supports cursor-based pagination, which is more efficient and reliable than offset-based pagination, especially for large datasets. Cursor pagination uses an opaque string (cursor) that represents a specific position in the sorted dataset.
How Cursor Pagination Works:
- Initial Request: Make your first request without a cursor (or with
offset: 0) - Extract Cursor: Each node in the response contains a
cursorfield - use the cursor from the last node of the current page - Next Page: Use that cursor in the
pagination.cursorfield for the next request - Repeat: Continue using the cursor from the last node of each page until you’ve retrieved all data
Key Advantages of Cursor Pagination:
- Stability: Cursors remain valid even if new data is added or existing data is modified
- Performance: More efficient for large datasets as it doesn’t require counting through all previous items
- Consistency: Avoids duplicate or skipped items when data changes between page requests
Example: Using Cursor Pagination
First Request (Get first 10 items):
query GetWorkHistory($company: CustomerUuid!, $pagination: Pagination) {
AcQuerySessionsHistory(
company: $company
pagination: $pagination
) {
... on SessionProtocolEdge {
nodes {
cursor
value {
id
name
sessionCreatedAt
}
}
totalCount
}
}
}
Variables:
{
"company": "12345678-1234-1234-1234-123456789012",
"pagination": {
"lengthStartingFromOffset": 10
}
}
Response:
{
"data": {
"AcQuerySessionsHistory": {
"nodes": [
{
"cursor": "cursor_abc123",
"value": { "id": "session-1", "name": "Session 1", ... }
},
{
"cursor": "cursor_def456",
"value": { "id": "session-2", "name": "Session 2", ... }
},
// ... 8 more nodes
{
"cursor": "cursor_xyz789",
"value": { "id": "session-10", "name": "Session 10", ... }
}
],
"totalCount": 100
}
}
}
Next Request (Get next 10 items using cursor):
{
"company": "12345678-1234-1234-1234-123456789012",
"pagination": {
"cursor": "cursor_xyz789", // Use cursor from the last node of previous page
"lengthStartingFromOffset": 10
}
}
cURL Example with Cursor:
# First page
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d '{
"query": "query GetWorkHistory($company: CustomerUuid!, $pagination: Pagination) { AcQuerySessionsHistory(company: $company, pagination: $pagination) { ... on SessionProtocolEdge { nodes { cursor value { id name } } totalCount } } }",
"variables": {
"company": "12345678-1234-1234-1234-123456789012",
"pagination": {
"lengthStartingFromOffset": 10
}
}
}'
# Next page (using cursor from previous response)
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d '{
"query": "query GetWorkHistory($company: CustomerUuid!, $pagination: Pagination) { AcQuerySessionsHistory(company: $company, pagination: $pagination) { ... on SessionProtocolEdge { nodes { cursor value { id name } } totalCount } } }",
"variables": {
"company": "12345678-1234-1234-1234-123456789012",
"pagination": {
"cursor": "cursor_xyz789",
"lengthStartingFromOffset": 10
}
}
}'
JavaScript Example:
async function fetchAllSessions(companyUuid, jwtToken) {
let allSessions = [];
let cursor = null;
let hasMore = true;
while (hasMore) {
const query = `
query GetWorkHistory($company: CustomerUuid!, $pagination: Pagination) {
AcQuerySessionsHistory(company: $company, pagination: $pagination) {
... on SessionProtocolEdge {
nodes {
cursor
value {
id
name
sessionCreatedAt
}
}
totalCount
}
}
}
`;
const variables = {
company: companyUuid,
pagination: {
lengthStartingFromOffset: 50,
...(cursor && { cursor })
}
};
const response = await fetch('https://api.example.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwtToken}`
},
body: JSON.stringify({ query, variables })
});
const result = await response.json();
const edge = result.data.AcQuerySessionsHistory;
if (edge.nodes && edge.nodes.length > 0) {
allSessions.push(...edge.nodes.map(node => node.value));
// Get cursor from the last node
cursor = edge.nodes[edge.nodes.length - 1].cursor;
// Check if we've retrieved all items
hasMore = allSessions.length < edge.totalCount;
} else {
hasMore = false;
}
}
return allSessions;
}
Important Notes:
- Cursor vs Offset: You can use either
cursororoffset, but not both. If you provide acursor, theoffsetis ignored. - Cursor Format: The cursor is an opaque string - you should not try to parse or modify it. Always use it exactly as returned.
- Cursor Validity: Cursors are valid for the specific query parameters (sort order, filters, etc.). If you change sort order or filters, you must start from the beginning (without cursor).
- Always Include Cursor in Query: Make sure to include
cursorin your GraphQL query selection so you can retrieve it from the response. - Last Page: When the number of returned nodes is less than
lengthStartingFromOffset, you’ve reached the last page.
Return Types:
The query returns either:
SessionProtocolEdge- Contains the session data with pagination informationFailure- Contains error information if the query fails
For detailed work history reference, see: Work History Reference
Integrator Integration 🔗
This section is a practical, end-to-end guide for building a helpdesk / ticket-system integration on top of the pcvisit API. It walks through the full lifecycle: provisioning a customer, registering a webhook, onboarding technicians, starting remote sessions from a ticket, matching devices, and receiving session results.
For vendor-level recommendations (responsibilities, pitfalls, rollout order, webhook/idempotency patterns), see the companion Integrator Best-Practice Guide.
The examples use DocBee as the integrator. Replace the integratorId and tokens with your own values. All operations are part of the regular GraphQL API and must be enabled (allowlisted) for your integrator API token.
Conventions: All GraphQL operations return a union of the success type and
Failure. Always handle theFailurebranch and showcomment_for_userto the end user (see Error Handling). Authenticate as described in Authentication: integrator/user API tokens viax-api-key, JWTs viaAuthorization: Bearer.
Integration Lifecycle Overview 🔗
The integration follows four phases. Phases 1–3 are the operational core; phase 4 can be triggered at any time to detach the integration.
| Phase | Goal | Key APIs |
|---|---|---|
| 1. Setup | Provision/link a company, activate the integration, register a webhook, sync assets | SiFindUserByMail, FirstTimeCustomerPlaceOrderOnBehalfOfIntegrator, FindCompanyTeam, CaRetrievePublicCompanyData, CaCreateIntegration, CaRegisterIntegrationWebhook, FindCurrentAssets / AmFindAssets |
| 2. Session start | Onboard the technician and open a remote session from a ticket | InviteToCompanyTeamOnBehalfOfIntegrator, SiRetrieveSignedJwt, RaCreate*Session, session-start URL |
| 3. Ticket assignment | Receive session results and match them to a ticket/device | Outbound SessionCompleted webhook, thirdPartyIds matching |
| 4. Teardown | Detach the integration | CaDeleteIntegration |
Step 1 Onboarding and Provisioning a Company 🔗
Before sessions can be started, the customer (an MSP) needs a pcvisit company with an activated integration. The integration runs fully over the API — there is no web handshake or redirect.
Pre-Check: Does a pcvisit Account Exist? (SiFindUserByMail) 🔗
Use SiFindUserByMail (with your partner-wide integrator token) to determine whether an account already exists for the admin’s e-mail. A User result means it exists; a Failure (e.g. DoesNotExist) means it does not.
query CheckUser($email: Email!) {
SiFindUserByMail(email: $email) {
... on User { id }
... on Failure { error }
}
}
The result decides the path: provision a new account (no account yet) or link an existing one.
Zero-Touch Provisioning (FirstTimeCustomerPlaceOrderOnBehalfOfIntegrator) 🔗
If no account exists, create the company and owner and assign the integrator’s start contract (trial) in one call. This mutation returns a list of result events (FirstTimeCustomerPlaceOrderResult); look for CaOrderWasPlaced, which carries the new company (CustomerUuid).
mutation Provision($company: CompanyUpdate!, $owner: ContactUpdate!) {
FirstTimeCustomerPlaceOrderOnBehalfOfIntegrator(company: $company, owner: $owner) {
... on CaOrderWasPlaced {
order_id
company # the newly created CustomerUuid (pcvisit company ID)
}
... on Failure {
error
comment_for_user
}
}
}
Variables:
{
"company": {
"billingContactCustomer": {
"firstName": "Max", "lastName": "Mustermann",
"email": "admin@msp-firma.de", "phone": "+4912345678"
},
"imprint": {
"companyName": "Mustermann IT GmbH",
"address": { "country": "DE", "postalCode": "12345", "city": "Musterstadt", "street": "Musterstrasse", "number": "42" }
}
},
"owner": { "firstName": "Max", "lastName": "Mustermann", "email": "admin@msp-firma.de" }
}
pcvisit creates the company, creates a verified owner account, assigns the configured start contract, and sends a separate welcome / password-setup e-mail to the owner. The optional password argument (PasswordAsClearText) can preset the owner password; it is not required for the SSO entry point.
Linking an Existing Account (FindCompanyTeam + CaRetrievePublicCompanyData) 🔗
If the account already exists, determine which companies the user may administer. FindCompanyTeam returns the team and its members (filter for owner/supporterAdmin roles), and CaRetrievePublicCompanyData enriches each eligible company with display data (e.g. name) so the admin can choose.
query Team($company: CustomerUuid!) {
FindCompanyTeam(company: $company) {
... on Team {
members { user role email }
}
... on Failure { error comment_for_user }
}
}
Once the company is chosen, continue with CaCreateIntegration (same path as the zero-touch case).
Activating the Integration (CaCreateIntegration) 🔗
Activate the integration for the company to obtain the company-specific integrator API token. The success type is CreatedIntegration; its token field is the integrator API token you store for this customer.
mutation Activate($customerUuid: CustomerUuid!, $input: CreateIntegrationInput!) {
CaCreateIntegration(customerUuid: $customerUuid, input: $input) {
... on CreatedIntegration {
token # the integrator API token for this company
createdAt
}
... on Failure { error comment_for_user }
}
}
Variables:
{
"customerUuid": "12345678-1234-1234-1234-123456789012",
"input": {
"integratorId": "DocBee",
"createdBy": "auth0|owner-or-system-id",
"expireAt": 4102444800000,
"tenantUrl": "https://tenant.docbee.example"
}
}
CreateIntegrationInput fields: integratorId (Integrators, e.g. DocBee), expireAt (token validity, UnixTimeMs), createdBy (Auth0Uuid), and optional tenantUrl (Url, a tenant-specific integrator URL that the pcvisit web app can display). Store the returned token securely; it becomes invalid when the integration is removed (see Step 9 Ending the Integration).
Step 2 Registering a Webhook 🔗
Right after activation, register your callback endpoint so pcvisit can push session results. Registration is an upsert: exactly one registration exists per integrator + webhookType, so calling it again updates the existing one.
mutation RegisterWebhook($customerUuid: CustomerUuid!, $input: RegisterIntegrationWebhookInput!) {
CaRegisterIntegrationWebhook(customerUuid: $customerUuid, input: $input) {
... on IntegrationWebhookRegistered {
webhook {
webhookType
callbackUrl
webhookSigningSecretSet
subscribedEvents
lastDeliveryStatus
lastDeliveryAt
}
}
... on Failure { error comment_for_user }
}
}
Variables:
{
"customerUuid": "12345678-1234-1234-1234-123456789012",
"input": {
"integratorId": "DocBee",
"webhookType": "SessionLifecycle",
"callbackUrl": "https://api.docbee.com/webhooks/pcvisit/sessions",
"signingSecret": "a-cryptographically-strong-secret",
"subscribedEvents": ["SessionCompleted"]
}
}
Notes:
webhookType(WebhookType) is currentlySessionLifecycle.subscribedEvents(WebhookEventType) selects the events to receive. For the pilot, focus onSessionCompleted;SessionStartedandSessionParticipantChangedare reserved for the future.signingSecretis write-only: it is stored encrypted at rest and never returned. The model only exposeswebhookSigningSecretSet: Boolean. pcvisit uses it internally to sign every webhook POST with HMAC-SHA256 (see Step 8 Receiving Session Results via Outbound Webhook).- There is no separate delete-webhook mutation; webhook registrations are removed implicitly (cascade) when the integration is deleted via
CaDeleteIntegration.
Step 3 Adding Technicians to the Team 🔗
A technician must be a member of the customer’s pcvisit team before an SSO session can be issued for them. There is no implicit auto-join — you add technicians explicitly.
Inviting (InviteToCompanyTeamOnBehalfOfIntegrator) 🔗
mutation Invite($company: CustomerUuid!, $guestUpdate: ContactUpdate!, $memberUpdate: TeammemberUpdate) {
InviteToCompanyTeamOnBehalfOfIntegrator(company: $company, guestUpdate: $guestUpdate, memberUpdate: $memberUpdate) {
... on Failure { error comment_for_user }
# success events are returned as a list of domain events
}
}
Variables:
{
"company": "12345678-1234-1234-1234-123456789012",
"guestUpdate": { "email": "neu@msp-firma.de", "firstName": "Anna", "lastName": "Schmidt" },
"memberUpdate": { "role": "supporter", "state": "Active" }
}
If no pcvisit account exists yet, the mutation creates one without a caller-set password and sends an activation / password-setup e-mail; no password is needed for the SSO entry point. Every add (and every removal) triggers an e-mail notification to the company owner. If the contract’s team-member limit is exceeded, the call fails with TeamUserLimitReached.
Removing (SiRemoveFromCompanyTeam) 🔗
mutation RemoveMember($company: CustomerUuid!, $id: Auth0Uuid!) {
SiRemoveFromCompanyTeam(company: $company, id: $id) {
... on Failure { error comment_for_user }
}
}
Removal likewise triggers the owner notification.
Step 4 Single Sign-On and Impersonation 🔗
To start a session on behalf of a technician without an extra login, request a short-lived JWT via SiRetrieveSignedJwt. Passing targetUserEmail issues the JWT for that technician (impersonation); the technician must already be a team member (see Step 3).
query GetSsoJwt(
$userApiToken: UserApiToken!
$integratorApiToken: IntegratorApiToken!
$targetUserEmail: Email
$requestEncryptionKey: Boolean
) {
SiRetrieveSignedJwt(
userApiToken: $userApiToken
integratorApiToken: $integratorApiToken
targetUserEmail: $targetUserEmail
requestEncryptionKey: $requestEncryptionKey
) {
... on SignedJwtWasRetrieved {
token
customerUuid
encryptionKey # JWK public key, only present when requestEncryptionKey is true
}
... on Failure {
error
comment_for_user
}
}
}
Behavior:
| Arguments | Result |
|---|---|
no targetUserEmail |
JWT for the integrator itself; encryptionKey is null. |
targetUserEmail, no requestEncryptionKey |
SSO JWT for the technician; no encryption key. |
targetUserEmail + requestEncryptionKey: true |
SSO JWT for the technician and an encryptionKey (JWK) for JWE credential encryption (see Step 5). |
If targetUserEmail is not a team member, the query returns Failure with error = SiSignedJwtTargetNotTeammember — add the technician first (Step 3). Only request requestEncryptionKey: true when you actually intend to pass credentials, since the RSA key pair generation is CPU-intensive and done on demand.
Step 5 Secure Credential Handover with JWE 🔗
You can pass remote-machine credentials (agent password and/or Windows logon) to a session encrypted so they never travel in clear text. Encrypt them as a JWE using the encryptionKey (public JWK) from Step 4, attach the JWE as the auth_token URL parameter (Step 6), and let the backend decrypt it once.
Algorithm: RSA-OAEP (key encryption) + A256GCM (content encryption).
Plaintext payload to encrypt (send only the fields you know):
{
"agentAccessPassword": "remote-host-password",
"windowsLogon": { "user": "Administrator", "pswd": "P@ssw0rd", "domain": "CORP" }
}
Encrypting (JavaScript, jose):
import { CompactEncrypt, importJWK } from "jose";
const publicKey = await importJWK(response.encryptionKey, "RSA-OAEP");
const jwe = await new CompactEncrypt(new TextEncoder().encode(JSON.stringify(payload)))
.setProtectedHeader({ alg: "RSA-OAEP", enc: "A256GCM" })
.encrypt(publicKey);
The pcvisit web app decrypts the auth_token once via SiDecryptAuthToken. The matching private key lives only on the backend in a short-lived, encrypted, shared store (TTL = JWT lifetime, one-time use):
query Decrypt($authToken: String!) {
SiDecryptAuthToken(authToken: $authToken) {
... on DecryptedAuthToken {
agentAccessPassword
windowsLogon { user pswd domain }
}
... on Failure { error comment_for_user }
}
}
Integrators normally do not call SiDecryptAuthToken directly — it is invoked by the pcvisit web app during the session. The credentials are used for the connection only, shown disabled/non-copyable in the UI, and never stored.
Step 6 Starting a Session with External Context 🔗
Session-Creation Mutations (RaCreate*Session) 🔗
The session-creation mutations accept an optional externalContext (ExternalSessionContext) that pcvisit stores for the session’s lifetime and returns when the session ends:
mutation StartSession(
$member: TeammemberIdentity!
$shareable: SharablePolicy!
$externalContext: ExternalSessionContext
) {
RaCreateDeviceAccessBySupporterSession(
member: $member
shareable: $shareable
externalContext: $externalContext
) {
... on SessionCreated { session sessionName sessionType }
... on Failure { error comment_for_user }
}
}
Variables:
{
"member": { "company": "12345678-1234-1234-1234-123456789012", "user": "auth0|technician-id" },
"shareable": "TeamOnly",
"externalContext": {
"integratorId": "DocBee",
"ticketId": "DOCBEE-4711",
"externalDeviceRef": "device-ref-123",
"metadata": [{ "key": "priority", "value": "high" }]
}
}
ExternalSessionContext fields: integratorId (Integrators, required), optional ticketId, externalDeviceRef, and metadata (list of key/value pairs). These come back in the SessionCompleted webhook (Step 8).
Opening a Session via Link (URL + Parameters) 🔗
In the common ticket flow you do not call the session-creation mutation yourself. Instead, after obtaining the SSO JWT (Step 4), you open a pcvisit web-app URL in the technician’s browser; the web app authenticates the technician with the JWT and creates the session, carrying over the ticket reference into externalContext.ticketId.
There are two URL forms depending on whether the target device is already known in pcvisit (from the asset sync, Step 7):
Known device (direct computer connection):
https://{host}/computer/{pcvisit_device_id}?ticket_ref={ticket_id}&token={jwt}&auth_token={jwe}
Unknown device (ad-hoc session, first contact):
https://{host}/create_adhoc_session/?ticket_ref={ticket_id}&token={jwt}
Parameters:
| Parameter | Required | Description |
|---|---|---|
{host} |
yes | Server host, e.g. app.pcvisit.de. This is an example; pcvisit provides the productive base URL. |
{pcvisit_device_id} |
known device only | The pcvisit asset UUID of the target computer (from FindCurrentAssets / AmFindAssets). |
token |
yes | The short-lived JWT from SiRetrieveSignedJwt. Authenticates the technician without a login and is processed automatically when the URL opens. |
ticket_ref |
optional | Your ticket ID. Stored in externalContext.ticketId and returned in the SessionCompleted webhook. |
auth_token |
optional, known device only | JWE-encrypted credentials (Step 5). Only meaningful for a known device, since the credentials are bound to a concrete target; decrypted once via SiDecryptAuthToken. |
Example (known device):
https://app.pcvisit.de/computer/abc-123-def?ticket_ref=DOCBEE-4711&token=eyJhbG...&auth_token=eyJhbGci...
For an ad-hoc session the target is not yet inventoried, so auth_token is omitted. After the session ends, you can still identify the device from the thirdPartyIds delivered in the webhook (Step 8).
Step 7 Synchronizing Assets and Device Matching 🔗
Sync the customer’s asset tree (folders and computers, including thirdPartyIds) to match pcvisit devices against your own database. There are two ways:
- One-time / periodic full fetch:
FindCurrentAssets(Query) - Live updates:
AmFindAssets(Subscription) — pushes changes (online/offline, new IDs, renames)
Both return an Edge (nodes { cursor index value } totalCount). Each node’s value is an AssetOfNode union — use inline fragments and do not assume every node is a computer.
query SyncAssets($company: CustomerUuid!, $baseDN: BaseDN!) {
FindCurrentAssets(company: $company, baseDN: $baseDN) {
... on Edge {
nodes {
value {
... on Computer {
id
displayName
hostName
onlineState
thirdPartyIds {
siteId teamViewerId anyDeskId rustDeskId screenConnectId
ninjaOneDeviceId dattoAgentId connectWiseAgentId serverEyeNodeId
ateraAgentId zabbixHostId intuneDeviceId
customIds { provider deviceId }
}
}
... on Folder { id displayName }
}
}
totalCount
}
... on Failure { error comment_for_user }
}
}
Variables:
{
"company": "12345678-1234-1234-1234-123456789012",
"baseDN": { "depth": "sub", "id": "ROOT_FOLDER" }
}
baseDN (BaseDN) defines where to start: depth (BaseDnDepth: base, one, sub) and id (an AssetUuid or a special folder such as ROOT_FOLDER). The subscription AmFindAssets takes the same arguments and yields the same Edge shape.
Device matching with thirdPartyIds: pcvisit collects identifiers of third-party tools on each managed computer and exposes them as ThirdPartyIds. Compare them against your own records for trivial, reliable matching.
| Field | Tool |
|---|---|
siteId |
pcvisit site hash (same siteId = same network/location) |
teamViewerId |
TeamViewer |
anyDeskId |
AnyDesk |
rustDeskId |
RustDesk |
screenConnectId |
ConnectWise ScreenConnect |
ninjaOneDeviceId |
NinjaOne / NinjaRMM |
dattoAgentId |
Datto RMM |
connectWiseAgentId |
ConnectWise Automate |
serverEyeNodeId |
Server-Eye (OCC connector) |
ateraAgentId |
Atera |
zabbixHostId |
Zabbix |
intuneDeviceId |
Microsoft Intune |
customIds |
Generic { provider, deviceId } fallback for other tools |
Availability:
siteId(backend-computed) andteamViewerIdare available immediately. The remaining IDs are collected by the native pcvisit agent and appear per device once the corresponding agent collector has been rolled out.
Step 8 Receiving Session Results via Outbound Webhook 🔗
After a session ends, pcvisit sends an HTTP POST to your registered callbackUrl. This is the only mechanism to receive session results — you do not poll. This is a plain REST callback (not GraphQL).
HTTP headers:
| Header | Content |
|---|---|
X-Pcvisit-Signature |
HMAC-SHA256 of the raw body, computed with your signingSecret |
X-Pcvisit-Event |
Event type, e.g. SessionCompleted |
X-Pcvisit-Delivery-Id |
UUID v4, unique per delivery attempt |
Content-Type |
application/json |
Signature verification (required): HMAC-SHA256(signingSecret, raw_request_body) == X-Pcvisit-Signature.
Payload — SessionCompleted:
{
"event": "SessionCompleted",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": 1718000000000,
"data": {
"taskId": "task-uuid",
"sessionName": "12345678",
"sessionType": "DeviceSessionForSupporter",
"companyUuid": "customer-uuid",
"externalContext": {
"ticketId": "DOCBEE-4711",
"externalDeviceRef": "device-ref-123",
"integratorId": "DocBee"
},
"target": {
"computerId": "asset-uuid",
"computerDisplayName": "PC-Buchhaltung",
"thirdPartyIds": {
"siteId": "a1b2c3d4...",
"teamViewerId": "123456789",
"anyDeskId": "987 654 321",
"zabbixHostId": "host-42"
}
},
"clientCompany": {
"clientId": "K-001",
"companyFolderDisplayName": "Mustermann GmbH"
},
"participants": [
{ "userId": "auth0|abc", "email": "max@msp-firma.de", "displayName": "Max Mustermann", "isSessionCreator": true }
],
"timing": {
"createdAt": 1718000000000,
"finishedAt": 1718003600000,
"totalDurationMs": 3600000,
"taxameterDurationSeconds": 3540
},
"comments": {
"byUser": "Router swapped, firmware updated",
"bySystem": "Remote restart at 14:32"
}
}
}
Note (future fields): Per-participant timing (
joinedAt,leftAt,participationDurationMs) is not yet part of the data model and therefore not delivered in the current payload; it is reserved for a later phase.
Response your endpoint must return:
Success (HTTP 2xx):
{ "status": "received" }
Error (HTTP 4xx) — include a user-friendly message; pcvisit stores it and shows it in the pcvisit admin UI:
{ "message": "Ticket DOCBEE-4711 was not found or is already closed." }
Retry behavior:
| Situation | Behavior |
|---|---|
| HTTP 2xx | Delivered successfully. |
| HTTP 4xx | No retry (client error). pcvisit stores your message as the failure reason. |
| HTTP 5xx / timeout | Automatic retry with exponential backoff (up to 5 immediate attempts). |
| All immediate retries failed | A pcvisit-internal recover job retries periodically (up to ~24h), then gives up. |
Idempotency: deliveryId is unique per delivery attempt and changes on retries, but data.taskId stays stable. De-duplicate on taskId so a session is processed only once.
Step 9 Ending the Integration 🔗
The integration can be detached at any time. CaDeleteIntegration deletes the integration and all associated webhook registrations (cascade) and invalidates the integrator API token. The pcvisit account and team remain intact.
mutation RemoveIntegration($customerUuid: CustomerUuid!, $integratorId: Integrators!) {
CaDeleteIntegration(customerUuid: $customerUuid, integratorId: $integratorId) {
... on IntegrationDeleted { eventName }
... on Failure { error comment_for_user }
}
}
If the integration is removed on the pcvisit side, your integration is not actively notified. Detect the now-invalid token on your next API call (e.g. SiRetrieveSignedJwt at session start, or FindCurrentAssets / AmFindAssets during sync), treat any integrator-token authentication failure as “integration detached”, and prompt the admin to re-link.