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:

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:

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.

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.

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:

  1. JWT Tokens - Obtained via the SiRetrieveSignedJwt query
  2. 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:

Important Notes:

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.

How to decode it on jwt.io:

  1. Copy the token value (only the eyJ... part). If you have Authorization: Bearer <token>, remove the Bearer prefix.
  2. Open jwt.io and paste the JWT into the “Encoded” field.
  3. Inspect the decoded parts:
    • Header: check alg (algorithm), typ, and optionally kid (key id).
    • Payload: check exp (expiration), iat (issued at), and any identity/permission claims used by the API.
  4. Validate the time-based claims:
    • If exp is in the past, request a new JWT via SiRetrieveSignedJwt and retry.
    • If nbf is in the future, verify your system clock (clock skew can break auth).

Signature verification on jwt.io (optional):

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:

  1. Authentication Errors:

    • AccessDenied: Invalid or missing JWT token
    • TokenExpired: JWT token has expired
    • InvalidValue: Invalid API tokens provided
  2. Authorization Errors:

    • InsufficientRights: User lacks required permissions
    • AccessDenied: Access denied to the requested resource
  3. Validation Errors:

    • InvalidValue: Invalid input parameters
    • MissingValue: Required parameters are missing
    • DoesNotExist: Requested resource does not exist

    Integrator / SSO-specific Errors:

    • SiSignedJwtTargetNotTeammember: The targetUserEmail passed to SiRetrieveSignedJwt is not (yet) a member of the customer’s pcvisit team. Add the technician explicitly via InviteToCompanyTeamOnBehalfOfIntegrator first (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 via InviteToCompanyTeamOnBehalfOfIntegrator.
    • 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_user carries a context-specific, user-ready message.

    For every Failure, error is a stable, machine-readable code (enum ErrorIDs) and comment_for_user is a ready-to-display end-user message (default German). Integrations should display comment_for_user directly in their UI and react logically on error without parsing free text.

  4. Server Errors:

    • ApplicationFailure: Internal application error
    • BackendFailure: Backend service error
  5. Rate Limiting Errors:

    • TryAgainLater: Rate limit exceeded (for integrator API calls)
      • The comment_for_dev field contains HTTP-style Retry-After information
      • 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_dev also includes the configured rate limit (e.g., “Rate limit: 100 requests per minute”)
      • Important: When receiving a TryAgainLater error due to rate limiting, wait for the specified duration before retrying the request

Error Handling Best Practices:

  1. Always check for the Failure type in union responses
  2. Log the comment_for_dev for debugging purposes
  3. Display comment_for_user to end users
  4. Implement retry logic for transient errors:
    • For TryAgainLater errors due to rate limiting, parse the Retry-After value from comment_for_dev and wait before retrying
    • Extract the retry delay (in seconds) from the Retry-After header format
    • Use exponential backoff for other transient errors
  5. Handle token expiration by refreshing the JWT
  6. Rate Limiting: Integrators are subject to per-integrator rate limits configured in the system. When a rate limit is exceeded, the API returns a TryAgainLater error with retry information in the comment_for_dev field. Always respect the Retry-After timing 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:

If you receive ErrorIDs.TokenExpired:

If you receive ErrorIDs.InsufficientRights:

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:

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:

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:

  1. Initial Request: Make your first request without a cursor (or with offset: 0)
  2. Extract Cursor: Each node in the response contains a cursor field - use the cursor from the last node of the current page
  3. Next Page: Use that cursor in the pagination.cursor field for the next request
  4. Repeat: Continue using the cursor from the last node of each page until you’ve retrieved all data

Key Advantages of Cursor Pagination:

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:

Return Types:

The query returns either:

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 the Failure branch and show comment_for_user to the end user (see Error Handling). Authenticate as described in Authentication: integrator/user API tokens via x-api-key, JWTs via Authorization: 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:

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).

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:

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) and teamViewerId are 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.