MSP seat client guide
For teams building the Chrome, Edge, Firefox or Safari extension, the Outlook add-in, or the mobile app that an MSP's end users run. It covers activating a seat on a device, keeping its session alive, and running workflows as that seat.
01How seats work
An MSP (managed service provider) creates organizations for its clients and invites their staff as seats. A seat holder never creates a password. The invite email gives them two things: their Organization ID (for example ACME_CORP) and one activation code per product they were given, such as DNET-A7K2-9FQ4-MMX4-P3ZR. Your client turns those into two credentials, each accepted in a different place.
| Credential | Lifetime | Comes from | Send it to |
|---|---|---|---|
| installId | Life of the install | Your client: a UUID v4 generated on first run | activate, validate |
| License token | 7 days (+7-day grace) | license/activate, license/validate | license/validate, license/session, license/deactivate |
| Access token | 30 minutes | license/session, auth/v1/refresh | Workflow execution, reports, realtime ticket |
| Refresh token | 7 days, rotated on use | Same as the access token | auth/v1/refresh |
What your client stores
The installId (permanently), the licenseToken with its tokenExpiresAt, and the current access and refresh tokens. Use the platform's secure storage. You don't need the activation code again after a successful activation.
No payment flow, ever
Seats are billed to the MSP for each activated device. A seat is never asked for credits, a checkout, or a subscription, so there's no purchase or paywall screen to build.
02Activating a device
Activate
Each activation code works on exactly one device. Calling again with the same installId returns a fresh license token without using another seat, so the same call handles reinstalls and recovery.
| Field | Type | Notes |
|---|---|---|
| organizationIdrequired | string | The Organization ID from the invite email: 2–64 letters, digits, _ or -. Not case-sensitive. |
| coderequired | string | DNET-XXXX-XXXX-XXXX-XXXX, uppercase letters A–Z and digits 2–9. |
| installIdrequired | UUID v4 | Generated once on first run and reused for the life of the install. |
| platformconditional | "ios_app" | "android_app" | Required for a mobile-app code. Ignored for extension and Outlook codes, whose platform comes from the code itself. |
| extensionVersionoptional | string | Your client version, for example 1.0.3. |
| deviceFingerprintoptional | string | A hash of coarse device signals, used only for fraud signals. |
const res = await fetch(`${BASE_URL}/msp/v1/license/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
organizationId: "ACME_CORP",
code: "DNET-A7K2-9FQ4-MMX4-P3ZR",
installId, // crypto.randomUUID(), stored on first run
extensionVersion: "1.0.3",
}),
});
const license = await res.json();{
"status": "activated",
"licenseToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"tokenExpiresAt": "2026-10-09T09:30:00.000Z",
"plan": "msp",
"licenseExpiresAt": null
}| Status | Meaning |
|---|---|
| 200 | Activated, or re-activated on the same installId. licenseExpiresAt: null means the seat has no end date; it stops only when revoked. |
| 401 invalid_code | Wrong Organization ID, a code from another organization, or a revoked or expired code. All look the same on purpose. |
| 403 seat_limit_reached | This code is already active on another device. Deactivate it there first. |
| 400 | A malformed field, or a mobile-app code sent without platform. |
| 429 | More than 30 attempts a minute, or 300 an hour, from one IP address. Wait and try again; show a countdown rather than retrying in a loop. |
| 503 license_signing_unavailable | The server can't issue license tokens right now. Nothing was saved and no seat was used. Retry later with the same installId. |
Daily validate
Call it about every 24 hours and on every start. It confirms the seat is still valid and returns a new 7-day license token; replace the stored one each time. A token that expired up to 7 days ago is still accepted, so a device that was offline for a week recovers without the user doing anything.
const res = await fetch(`${BASE_URL}/msp/v1/license/validate`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${licenseToken}` },
body: JSON.stringify({ installId, extensionVersion: "1.0.4" }),
});
// Same shape as activate, with "status": "validated". Store the new licenseToken.| Status | Meaning |
|---|---|
| 200 | Seat is valid. Store the returned token. |
| 401 license_invalid | The seat or this device was revoked, or the organization or MSP is suspended. Lock protected features. |
| 401 license_expired | Expired more than 7 days ago. Show the activation screen; the same code and installId reactivate without using another seat. |
| 503 license_signing_unavailable | Temporary server problem. Keep the current token and try again later; don't lock the user out for this. |
Deactivate and verify locally
No body. Frees this device's slot so the code can activate somewhere else. Needs an unexpired token, so call validate first if you're unsure. Safe to repeat. Returns { "status": "deactivated" }.
The RS256 public keys that sign license tokens. Use them to check a token's signature and expiry offline. Cache for 24 hours, and pick the key whose kid matches the token header.
03Getting a session
The license token is never accepted for workflow calls. Exchange it once for a normal session, then keep that session alive the same way every other Datacove client does.
No body. Returns a token pair for the seat holder, carrying the workflows the MSP's plan includes.
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 1800
}401 license_invalid under the same conditions as validate.
Every call to license/session creates a new session, and one person can hold at most 5 sessions across all their devices. A sixth signs out their oldest device. So call it once after activation, or after the session is lost, and renew with POST /auth/v1/refresh about a minute before expiresIn runs out. Refresh re-reads the MSP's plan each time, so plan changes reach the device without a new exchange.
async function getAccessToken() {
const s = await storage.get("session");
if (s && Date.now() < s.expiresAt - 60_000) return s.accessToken;
if (s) {
const res = await fetch(`${BASE_URL}/auth/v1/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: s.refreshToken }),
});
if (res.ok) return save(await res.json());
}
// No session yet, or refresh was rejected: exchange the license again.
const res = await fetch(`${BASE_URL}/msp/v1/license/session`, {
method: "POST",
headers: { Authorization: `Bearer ${await storage.get("licenseToken")}` },
});
if (!res.ok) throw new LicenseError((await res.json()).error?.message); // license_invalid
return save(await res.json());
}
async function save({ accessToken, refreshToken, expiresIn }) {
await storage.set("session", { accessToken, refreshToken, expiresAt: Date.now() + expiresIn * 1000 });
return accessToken;
}Run renewals one at a time: if several calls need a new token at once, they should share one refresh. A refresh token that has already been replaced and is used again ends the session as a precaution. The Android guide's session section lists the 401 codes you may see (SESSION_EVICTED, SESSION_REVOKED, TOKEN_REUSED, REFRESH_INVALID).
04Client state rules
| When | Do this |
|---|---|
| Client starts with a stored license token | Call validate and store the new license token. Use the stored session if there is one; otherwise call license/session. |
| Access token within a minute of expiry | Call auth/v1/refresh. |
A workflow call returns 401 | Refresh once and retry. If refresh fails, call license/session. If that fails too, continue below. |
A license call returns license_invalid | The seat or device was revoked, or the organization was suspended. Stop protected features and show "Contact your administrator". |
A license call returns license_expired | Show the activation screen. The same code and installId work again without using another seat. |
| User signs out or uninstalls | Call license/deactivate first so the seat can move to another device. |
All devices one person activates share the same seat user, so runs from their browser extension and their phone appear together. Your client never sends its own identity; the server works it out from the activation.
05Running workflows
The MSP's plan with Datacove decides this, for every organization under that MSP. A workflow outside the plan behaves as if it doesn't exist: 404, not 403. Seat sessions can't read workflow definitions, so ship the ids and input keys you need with your client. The reference below lists them.
| Field | Type | Notes |
|---|---|---|
| idrequired | string | The workflow id, for example wf-sec-001. |
| inputrequired | object | One property per input key. |
| countryoptional | string | ISO country code, for example "in". |
const res = await fetch(`${BASE_URL}/workflows/v1/execute`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAccessToken()}` },
body: JSON.stringify({ id: "wf-sec-001", input: { url: "https://login-verify-account.example" } }),
});
const result = await res.json(); // instant workflow: this is the analysisSending files
Attach files to the execute call itself. Seat clients can't use /media/v1/upload, which needs an app API key. Send id as a plain text field, every non-file input in one text field named input as a JSON string ({} if there are none), and each file as a part named after its input key. Don't set Content-Type yourself; let the HTTP client add the multipart boundary.
const form = new FormData();
form.append("id", "wf-sec-008");
form.append("input", JSON.stringify({})); // non-file inputs, none here
form.append("document", fileBlob, "contract.pdf"); // part name = the input's key
const res = await fetch(`${BASE_URL}/workflows/v1/execute`, {
method: "POST",
headers: { Authorization: `Bearer ${await getAccessToken()}` },
body: form,
});
const { eventId } = await res.json(); // report workflow: see the next sectionInstant result
The 201 body is the answer. Nothing else to call. If the body contains "headers": { "success": false }, the workflow ran but couldn't produce an answer. Treat that as "no result", not as a verdict.
Report
The 201 confirms the run started: { "success": true, "eventId": "…", "message": "…" }. Keep the eventId.
| Status | Meaning |
|---|---|
| 201 | The result, or a report's eventId. |
| 400 | Missing id, or an input failing validation: a missing required key, wrong file type, or file too large. Also returned when the workflow itself rejects the input: error.message is The workflow could not process this input, with error.detail or error.fields ([{ field, issue }]) when available. Retrying the same input fails again. |
| 401 | Access token expired or the session ended. Renew and retry once. |
| 404 | Not in the MSP's plan, not enabled, or not a real id. |
| 409 | Only for workflows that cost credits, and for report workflows: this seat just submitted the same workflow with identical input and it's still running. Wait for it. Identical requests to a free instant workflow both run. |
| 502 | The workflow service failed or couldn't be reached. Show “couldn't check right now”, not a verdict, and retry with backoff. |
Every request runs the workflow again, so don't send the same check twice in a short time. For URL checks, keep each result for a while (10 to 30 minutes works well) and reuse it for repeat visits, other tabs and reloads. Also share one in-flight request when the same URL is checked again before the first answer arrives.
06Report results
Realtime (recommended) →
Mint a ticket with the access token, connect with Socket.IO, join the run with its eventId, and get told when it finishes.
Polling
Call GET /workflows/reports/v1/:eventId every 5 seconds, for up to 20 minutes.
| GET /workflows/reports/v1/:eventId | Meaning |
|---|---|
| 200 { "status": "GENERATING" } | Still running. |
| 200 (report body) | Finished. |
| 404 | The run failed, or there's no such report. These look the same on purpose. |
Report history, sharing and deletion aren't available to seat sessions. Keep your own list of eventIds if your client shows past results.
07Workflow reference
The security workflows MSP plans draw from, as currently defined. Your MSP's plan decides which ones a seat can run. Input keys and file limits can change; re-check this table when you update your client.
| Workflow | Result | Input key | Accepts | Max size |
|---|---|---|---|---|
| wf-sec-001 | Instant | url (text) | A full URL including https:// | — |
| wf-sec-002 | Report | document (file) | .pdf .doc .docx | 10 MB |
| wf-sec-003 | Report | document (file) | 10 MB | |
| wf-sec-004 | Report | document (file) | .jpg .jpeg .png .webp | 5 MB |
| wf-sec-005 | Report | document (file) | .mp4 .webm .mov .avi | 100 MB |
| wf-sec-006 | Report | document (file) | 5 MB | |
| wf-sec-007 | Report | image (file) | .jpg .jpeg .png .webp | 5 MB |
| wf-sec-008 | Report | document (file) | .pdf .doc .docx | 10 MB |
08Errors
Every error uses the same envelope. On license routes, error.message is a stable code such as invalid_code; branch on it.
{
"statusCode": 401,
"timestamp": "2026-10-02T09:30:00.000Z",
"path": "/api/msp/v1/license/activate",
"error": { "statusCode": 401, "error": "Unauthorized", "message": "invalid_code" }
}| Status | error.message | Where | What to do |
|---|---|---|---|
| 401 | invalid_code | activate | Ask the user to re-check the Organization ID and code. |
| 403 | seat_limit_reached | activate | The code is in use on another device. Deactivate it there, or ask the administrator. |
| 401 | license_invalid | validate, session | Revoked or suspended. Lock protected features. |
| 401 | license_expired | validate | Show the activation screen. |
| 409 | seat_user_conflict | activate | The seat holder's account couldn't be set up. Ask the administrator to contact support. |
| 503 | license_signing_unavailable | activate, validate | Temporary. Retry later; on validate keep the current token. |
| 401 | (has error.code) | refresh, workflows | The session ended. Exchange the license again with license/session. |
| 429 | (varies) | activate | Too many attempts from this IP address. Wait before trying again. |
| 400 | The workflow could not process this input | workflows | The workflow rejected this input. Show error.detail or error.fields; don't retry unchanged. |
| 502 | Workflow service unavailable. Try again later. | workflows | Temporary. Retry with backoff; treat the check as not done. |