Docs
All docs

Partner API and webhooks

A versioned, scoped partner API for reads, support replies and status changes, plus durable signed webhooks for support and posture events.

TenantVigil only reads your Microsoft 365 tenant. It never changes a setting: you apply each fix yourself.

Need a hands-on starting point?

Start with one tenant or talk to the team if you want help evaluating fit, permissions, or rollout approach.

What the API is for

The partner API lets a managed service provider pull its customers' security posture and support requests into its own tools, such as a PSA, a ticketing system, a dashboard or a script, without signing in to the partner console, and answer support requests and keep their status in step from those tools. A key is read-only unless it was created with replies or status changes allowed: such a key can also post replies to support requests, or set their status, or both, and link a request to its ticket in your PSA, and nothing else. No key can change a customer's tenant or findings, add internal notes or attach files.

A key only ever sees the customers its partner account manages. A customer that is banned, or that another partner manages, answers as if it did not exist. Sending a tenant, partner or user id cannot give a key more access.

Keys

A partner admin creates keys on the partner console's API access page. A key is shown once, when it is created. TenantVigil stores a hash of it and its first 12 characters, which the API access page shows so you can tell keys apart; the whole key is never stored. A partner can have at most five keys that are neither revoked nor expired (a key being replaced does not count while its new key works, as the new key takes its place; if the new key is revoked or expires, the old key counts again; a key whose creator is no longer an admin still counts until it is revoked, by you or automatically the next time it is used; a key revoked that way does not come back even if its creator is made an admin again). At most 10 keys can work at once, counting keys being replaced: past that, creating or replacing a key is refused until a replaced key stops or you revoke one. A key can be revoked at any time on the same page, which stops it at once.

A key works only while the admin who created it is still a partner admin in good standing: if that person is suspended, demoted or removed, their keys stop working.

Every key expires. When creating a key, the admin chooses when: in 30, 90, 180 or 365 days (90 if none is chosen). Keys created before expiry dates were available have no expiry and work until they are revoked. From its expiry, a key gets 401 on every endpoint, exactly as a revoked key, and writes nothing; it is no longer listed on the API access page and no longer counts against the five. The API access page shows each key's expiry date, and "Expires in N days" when 14 days or fewer are left. Once a day, the partner admins get one email for each key with 14 days or fewer left, naming the key (its name, first characters and expiry) and how to replace it; the email never contains the key.

To change a key without a gap, use Replace on the API access page and choose when the new key expires. TenantVigil creates a new key with the same name and scopes and shows it once. The old key keeps working for 24 hours (or until its own expiry, if that is sooner), shown as "Being replaced: stops at <time>", so you can move your integration to the new key; after that, the old key gets 401. A key that is already being replaced, revoked or expired cannot be replaced. You can replace a key even when you have five, as the new key takes the old key's place while it works; the limit of 10 working keys, counting keys being replaced, still applies.

When creating a key, the admin can tick "Allow replies to support requests", "Allow status changes on support requests", or both; each is a scope of its own. The scopes are fixed when the key is created: to change them, create a new key and revoke the old one. Keys created with neither tick, including every key created before replies were available, are read-only, and no key created before status changes were available can set a status. The API access page shows each key's scopes. A key with either scope can also link a request to its PSA ticket.

Calling the API

Send the key in the Authorization header as a bearer token (Authorization: Bearer tvk_...) to https://tenantvigil.com/api/v1/. Every answer from the endpoints below carries Cache-Control: no-store, and every answer with a body is JSON. Times are ISO 8601 strings in UTC.

Each key can make 60 requests a minute, of which at most 30 can be writes to the reply, status and PSA ticket link endpoints together (every write counts, including refused ones and retries), and partner API calls from one IP address can make 300 a minute together, apart from the partner console's own calls from that address. Over any of these limits, the answer is 429 with a Retry-After header in seconds.

Each key also has a quota of 10,000 authenticated requests per UTC day, separate from every other key, including another key of the same partner. A request counts once its key is authenticated and it is within the minute limits above, the 30 writes a minute included for a write; retries and later validation or permission refusals count too. Requests refused by a minute limit, or because the daily quota is full, do not add to the count. The next request after 10,000 gets 429 with Retry-After seconds until the next 00:00 UTC. The quota resets then, and the API access page shows each key's requests today against its quota.

  • 401 { "error": "A valid API key is required." } with WWW-Authenticate: Bearer: no key, or a key that is malformed, unknown, revoked, expired or no longer usable.
  • 403 { "error": "This key is read-only." }: a reply posted, or a PSA ticket link set or removed, with a key that allows neither replies nor status changes.
  • 403 { "error": "This key does not allow replies." }: a reply posted with a key that allows status changes but not replies.
  • 403 { "error": "This key does not allow status changes." }: a status change with a key that does not allow status changes.
  • 404 { "error": "Not found" }: a customer or request your partner does not manage, or an id that does not exist. Any path under /api/v1/ that is not one of the endpoints below gives the same answer, with Cache-Control: no-store, for GET, HEAD, OPTIONS, POST, PUT, PATCH and DELETE, whatever the key.
  • 405 { "error": "Method not allowed" } with Cache-Control: no-store and an Allow header: POST, PUT, PATCH or DELETE on one of the GET endpoints below (Allow: GET, HEAD, OPTIONS), GET, HEAD, PUT, PATCH or DELETE on the reply and status endpoints (Allow: POST, OPTIONS), and GET, HEAD, POST or PATCH on the PSA ticket link endpoint (Allow: PUT, DELETE, OPTIONS).
  • 400 { "error": "<reason naming the parameter>" }: an unknown, repeated, empty or incorrectly typed query parameter, a cursor that cannot be used here, invalid JSON, an invalid or extra body field, or a missing or invalid required Idempotency-Key. Empty q is allowed and means no search. A new check says Invalid and the name, for example "Invalid limit", "Invalid cursor" or "Invalid tenantId", or "Invalid parameter" when the name is not 1 to 64 letters, digits, dots, underscores or hyphens. A body field an endpoint already had keeps its own message, for example "Write a message of up to 10,000 characters." or "Invalid status". Correct the named field before retrying.
  • 413 { "error": "The body is larger than 64 KB." }: a write body over 64 KB. Send a smaller body.
  • 422 { "error": "This Idempotency-Key was already used for a different reply." }: within 24 hours this API key used that value for a different normalized body or request. Use a new value for a new reply.
  • 429: a minute limit, the key's 10,000-request UTC daily quota, or one of the customer's write limits below was reached. Minute and per-key daily refusals say { "error": "Too many requests" }; customer write refusals describe the budget. All carry Retry-After in seconds: wait at least that long. There are at most 20 unknown-key lookups a minute from one IP address.
  • 500 { "error": "Internal error" }: TenantVigil could not complete the call. No database detail is returned. If a reply's outcome is uncertain, retry with the same Idempotency-Key and body within 24 hours.

Reading every page

The tenants, findings, support-request list and request conversation GETs all accept limit and cursor. limit is a whole number from 1 to 200, with 200 used when omitted. For example, start with /api/v1/tenants?limit=50. Every answer includes nextCursor: a string when another page exists, or null when this is the last page. Existing fields, including hasMore and hasMoreMessages where present, stay in the answer.

To continue, send the returned nextCursor as the next call's cursor, for example /api/v1/tenants?limit=50&cursor=<nextCursor>, URL-encoding its value. Keep the same API key, endpoint, customer, request and search or status filters. Treat the cursor as an opaque value: do not build or edit it. A malformed or changed cursor, or one from another endpoint, key, customer, request or set of filters, gets 400 naming cursor.

Tenants are ordered by name and then id, ascending. Findings are ordered by controlId, ascending. Support requests are ordered by lastActivityAt and then id, newest first. A conversation starts with its newest messages and your team's notes; each page is displayed oldest first and its cursor reads the next older entries. Entries at the same time have a fixed kind and id order.

A new entry inserted between calls does not shift entries across page boundaries. A new entry before your cursor is available on a fresh first page. Paging is not a frozen snapshot: changed findings, request activity or status, and customers moving partners can change later answers.

Send only the supported parameters

Send each query parameter at most once, even if the repeated value is the same. The tenants, findings and conversation GETs take only limit and cursor. The support-request list also takes status and q. No write endpoint takes query parameters. Unknown names, repeated parameters, empty values (except q) and values of the wrong type get an uncached 400 with { "error": "<reason naming the parameter>" }.

A JSON write body must be an object with only the fields the endpoint describes: body for a reply; status for a status change; system, ticketId and optional url for a PSA ticket link. DELETE takes no body: an empty body is accepted, and a body of one byte or more gets 400 { "error": "Invalid body" }. Extra fields, arrays, numbers, booleans and other wrong types get 400. For example, tenantId, partnerAccountId and userId are not accepted body or query fields and never change the key's access.

Every valid key can read. A reply requires replies allowed, a status change requires status changes allowed, and a PSA ticket link requires either. Missing permission gets 403. There are no client-supplied scope names or ids that add a capability, and the server grants nothing for a capability it does not recognise.

GET /api/v1/tenants

Your customers, as the partner console's Fleet page shows them: { "tenants": [ ... ], "hasMore", "nextCursor" }, each with id, name, domain, active, onboarded, score, scoreTrend, criticalFindings, highFindings, driftCount and lastScanAt. An answer holds at most limit customers (200 by default and at most 200), ordered by name and then id, ascending. When you have more, hasMore is true and nextCursor is set: send it as cursor until nextCursor is null to read every customer.

criticalFindings and highFindings count open findings by the Findings and Drift page's rule: a check resolved by hand, covered by an approved risk acceptance that has not expired, or passing since a later control refresh is not counted.

GET /api/v1/tenants/{id}/findings

Each check's latest result, including single-check refreshes since the customer's latest full scan: { "tenantId", "scanCompletedAt", "findings": [ ... ], "nextCursor" }, each with controlId, title, severity, framework, status, service, source, frameworks, readable, unreadReason, resolution and checkedAt. scanCompletedAt is when that full scan completed; checkedAt is when the finding's own result was checked (the full scan, or a later single-check refresh). A customer with no completed scan gives scanCompletedAt null and an empty list. Use limit and cursor to read every page, ordered by controlId, ascending.

status stays the raw scan result. It doesn't reflect checks resolved by hand or accepted risks: such a check keeps its scan status. So the findings can differ from the counts above in either direction. readable and resolution say how to read status.

service is the Microsoft 365 service the check belongs to, and source is the Microsoft data the check reads (for example a Microsoft Graph request, or where to look when it is checked by hand), as the check's page in the portal shows them. frameworks lists the framework references the check maps to. For a check that is not in TenantVigil's catalog, service and source are null and frameworks is an empty list.

readable is false when TenantVigil could not read the check: an error, a setting Microsoft did not return, licensing that could not be read, or a check Microsoft does not make available to apps, which must be checked by hand. Such a result is not a real warning, even when its status is warn: TenantVigil does not know whether the setting is right. unreadReason then gives the reason in plain words, as the portal shows it. It is null when readable is true.

resolution is the decision on the check as recorded with that result: open, in_progress or resolved. A check no one has decided on is open. A check marked resolved shows as open while TenantVigil could not read it, because a resolved mark counts only while the check can be read. An accepted risk does not show here. No evidence, notes or names are included.

GET /api/v1/tenants/{id}/support-requests

The customer's support requests, newest activity first and then id descending: { "tenantId", "limit", "hasMore", "requests": [ ... ], "nextCursor" }. limit is the page size, 200 by default and at most 200. hasMore is true when another page matches. Use nextCursor as cursor to continue. Add ?status=open, ?status=waiting_on_customer or ?status=resolved for one status.

Add ?q= to search as the Requests page does: 2 to 100 characters, matched without regard to case against each request's subject and the customer's name, so when the customer's name matches, every request of that customer is returned. % and _ match themselves. A full request id finds that request. A q under 2 or over 100 characters (after trimming spaces), or with a control character, is 400; an empty q is no search. q combines with status, and hasMore and nextCursor then say whether another page matches. Keep both filters when using the cursor.

Each request has id, subject, status, openedBy (the opener's email address, or null), createdAt, lastActivityAt, resolvedAt (set only when the request is resolved), assignee ({ name, email, hasAccess } or null), assigneeAccepted (below), tracks ({ type, id } or null, where type is control, message_center, defender_incident or service_issue), verification (below) and externalRef (the ticket in your PSA the request is linked to, as { system, ticketId, url }, or null: see the PSA ticket link endpoint below). Message text is not in the list.

hasAccess is true while the assignee can still reach the customer: they are in good standing, and a partner admin or a member with active access to that customer. It is false otherwise, for example after their access to the customer was revoked or expired, or they were suspended. The partner console shows such an assignee as "(no longer has access)" and counts the request as unassigned; you can do the same.

assigneeAccepted says whether the assignee has picked the request up. When someone hands a request to a team member, it waits for that person to accept it: false until they press Accept on the request or reply to it or change its status in the partner console, then true; a reply or status change through this API does not accept it. A request someone takes themselves (claiming it, tracking a finding, opening it) is accepted at once, and resolving a request ends the wait, so a resolved request is never waiting. It is null when assignee is null or hasAccess is false, because the console then counts the request as unassigned.

verification says whether the latest scan confirms the fix, as the request pages show it next to the status. It is set only for a resolved request that tracks a check, and is null for every other request, including one that was reopened. It is verified when the check's latest result passes; failing when the latest scan fails or warns on the check after the request was resolved; not_checked when its latest result fails but was recorded before the request was resolved, or there is no result yet; unverifiable when its latest result is an error, could not be read, or is not applicable; and accepted when it fails but the customer has an approved risk acceptance for the check that has not expired. The latest result is the one the check's page shows, a single-check refresh included.

GET /api/v1/tenants/{id}/support-requests/{requestId}

One request, as the list shows it, with its conversation: { "tenantId", "request", "messageLimit", "hasMoreMessages", "messages": [ ... ], "nextCursor" }. The first page has the newest entries, displayed oldest first, each with id, from (customer, msp, staff or system), internal, authorName, authorEmail, body and createdAt. messageLimit is the chosen limit, 200 by default and at most 200. Use nextCursor as cursor to read the next older page. hasMoreMessages is true while older entries remain. The body is plain text.

The conversation also holds your team's internal notes on the request, in time order with the messages: internal is true for an internal note (the customer never sees it) and false for every message the customer sees. The chosen limit and the 3.5 MB answer size budget count messages and notes together; a page can therefore be shorter than limit and still have a nextCursor. Notes written by your partner account stay included when their author leaves your team; another MSP's notes (for example the customer's previous MSP) and TenantVigil's own internal notes are never included.

POST /api/v1/tenants/{id}/support-requests/{requestId}/messages

Posts a reply to one of the customer's support requests, with a key that allows replies. Send the JSON body { "body": "<text>" } and an Idempotency-Key header of 1 to 200 visible characters (printable ASCII, no spaces), a new value for each reply, for example your ticketing system's own id for it. The text is plain text of up to 10,000 characters, after line breaks are made uniform, control characters are removed and surrounding spaces are trimmed.

The reply is your team's reply, as if posted from the partner console: it shows in the conversation from msp, signed with your brand name (or your company name if you have not set one) and no person's name or address; it moves the request to waiting_on_customer and stops its reply clock; and the customer is told as for a console reply. The audit log records which key posted it and from which IP address, never the text. A reply posted through the API never comes back to your webhook, which carries the customer's messages and status changes and TenantVigil's automatic resolves only.

An Idempotency-Key belongs to this API key only and is kept for 24 hours after the reply is written. Retrying with the same value, request and normalized text returns the first reply's JSON fields, including its original status even if the request's status changed later, and creates no second message, audit entry or notification. Two concurrent retries post once. Another API key can use the same value independently. After 24 hours the value can be reused and a new reply can be posted; the old message stays.

The API cannot add internal notes or attach files. To set a request's status, use the status endpoint below.

  • 201 { "messageId", "requestId", "status" }: the reply was posted; status is the request's new status.
  • 200 with the first answer's fields: within 24 hours this key already posted this normalized body to this request with this Idempotency-Key. Nothing new is posted; messageId and status are the earlier reply's. Retry with the same Idempotency-Key when you do not know whether a reply went through.
  • 422 { "error": "This Idempotency-Key was already used for a different reply." }: within 24 hours this key already used the Idempotency-Key with another normalized body or for another request.
  • 400: a body that is not JSON ("Invalid JSON body"), text that is empty or too long, or a missing or invalid Idempotency-Key header.
  • 413 { "error": "The body is larger than 64 KB." }: a request body over 64 KB (the rest of it is not read).
  • 429 with a Retry-After header in seconds: the API already posted 200 replies to this customer's requests in the last 24 hours, from all your keys together. A retry of a reply that was posted (the same Idempotency-Key and body within 24 hours) still answers 200 when the key's minute and daily request budgets allow the call.
  • 404 { "error": "Not found" }: as for the reads, and also when the customer moved to another MSP or was banned, the request is not that customer's, or the key was revoked while the reply was being posted. Nothing is posted.

POST /api/v1/tenants/{id}/support-requests/{requestId}/status

Sets the status of one of the customer's support requests, with a key that allows status changes. Send the JSON body { "status": "<status>" }, where the status is open, waiting_on_customer or resolved. There is no Idempotency-Key: setting a status twice changes nothing.

The change is your team's, as if made from the partner console: resolved sets resolvedAt, and any other status clears it; a status other than open stops the request's reply clock, and open leaves the clock as it is. The audit log records the change, which key made it and from which IP address. A status change made through the API, like one your team makes in the console, never comes back to your webhook.

Resolving a request through the API runs no scan. For a request that tracks a check, verification (above) reads the check's latest result as for a request resolved in the console: a check whose latest result passes shows verified, and one whose latest result is a failure recorded before the request was resolved shows not_checked until a later scan records the check again.

  • 200 { "requestId", "status", "previousStatus" }: the request now has the status; previousStatus is the status it had before. When it already had that status, nothing is changed or audited, and previousStatus is the same as status.
  • 400: a body that is not JSON ("Invalid JSON body"), or a status that is missing or not one of the three ("Invalid status").
  • 413 { "error": "The body is larger than 64 KB." }: a request body over 64 KB (the rest of it is not read).
  • 429 with a Retry-After header in seconds: the API already changed the status of this customer's requests 200 times in the last 24 hours, from all your keys together. Nothing is changed. Setting the status a request already has still answers 200.
  • 404 { "error": "Not found" }: as for the reads, and also when the customer moved to another MSP or was banned, the request is not that customer's, or the key was revoked while the status was being set. Nothing is changed.

PUT and DELETE /api/v1/tenants/{id}/support-requests/{requestId}/external-ref

Links one of the customer's support requests to its ticket in your PSA, with a key that allows replies or status changes (either is enough). Send PUT with the JSON body { "system": "<system>", "ticketId": "<id>", "url": "<link>" }, where system is connectwise, autotask, halo, servicenow, freshservice or other (in lower case), and url is optional. Send DELETE (no body) to remove the link. A request has at most one link: a PUT replaces the one it has.

ticketId is text of 1 to 100 visible characters, after line breaks and control characters become spaces, invisible and text-direction characters are removed, runs of spaces become one and surrounding spaces are trimmed. Send a numeric id as text, for example "48213". url, when you send it, must be an https URL of up to 2,048 characters with no user name or password in it; it is stored in its standard form (for example with the host in lower case), which can differ from what you sent. Send no url, or null, for a link without one. Never put a password, token or other secret in the url, its query string included: it is stored as a plain link, shown to your team and sent in your webhook events and API answers.

The link is your team's record of where the request is worked on. The partner console's request page shows it as "PSA ticket" with the system and ticket id, and opens the url in a new tab; the customer never sees it, on any of their pages. Each request in the API has it as externalRef, and your webhook's support events carry it as request.externalRef (null when there is none, and always null on support.request.created, which comes before any link), so your PSA can match an event to its ticket. Setting or removing a link sends no webhook event and notifies nobody.

A link belongs to your partner account. When the customer leaves you or is moved to another MSP, its requests' links are removed, and a link your account set is never shown to a later MSP: not on its request page, in its API answers or in its webhook events. If a link is still stored from before (for example by a previous MSP), it is not yours: a PUT sets yours in its place, even with the same values, and a DELETE has nothing of yours to remove.

The audit log records which key set or removed the link, the system and ticket id, and from which IP address, never the url.

  • 200 { "requestId", "externalRef" }: the request now has this link ({ "system", "ticketId", "url" }, url null when it has none) or, after DELETE, none (null). When it already had that link from your account, or had none for DELETE, nothing is changed or audited.
  • 400: a PUT body that is not JSON ("Invalid JSON body"), or a system, ticketId or url that is missing or not as above (the error says which). A DELETE with a body of one byte or more gets "Invalid body"; an empty body is accepted.
  • 413 { "error": "The body is larger than 64 KB." }: a PUT body over 64 KB (the rest of it is not read).
  • 429 with a Retry-After header in seconds: your keys together already set or removed PSA ticket links on this customer's requests 200 times in the last 24 hours (another MSP's changes do not count). Nothing is changed. Sending the link a request already has still answers 200.
  • 404 { "error": "Not found" }: as for the reads, and also when the customer moved to another MSP or was banned, the request is not that customer's, or the key was revoked while the link was being set. Nothing is changed.

Webhook event support.request.status_changed

When the customer resolves or reopens a request, or TenantVigil resolves it because a scan shows its check passing, your partner webhook (set on the partner console's Integrations page) gets the event support.request.status_changed, sent as the other support events are: { "request": { "id", "subject", "status", "previousStatus", "url", "externalRef" }, "customer": { "id", "name", "domain" }, "by" }. by is customer for the customer's change and system for TenantVigil's automatic resolve; url opens the request in the partner console; externalRef is the request's PSA ticket link (above), or null.

A customer reply is not a status change: it moves the request to open, reopening it if it was resolved, and arrives as support.request.replied, whose request status is open. support.request.status_changed is sent only when the customer resolves or reopens the request itself, or TenantVigil resolves it.

A status change made by your team, in the console or through the API, is not sent, so a change your ticketing system makes never comes back to it. One customer's own status changes are sent at most 200 times in 24 hours; TenantVigil's automatic resolves are not held back by that limit.

Who is named

The API names people as the partner console does. Replies from TenantVigil's own team are signed "TenantVigil Support" and automatic notes "TenantVigil", never with a person's name or address. A request's assignee is shown only while they belong to your partner account; its opener only while they belong to your partner account or to the customer; an MSP message author's address only while they belong to your partner account (their name still shows). So after a customer moves to another MSP, the previous MSP's people are not identified by address, and its assignees and openers are not shown.