API guide
Base URL https://beta.185-211-6-64.sslip.io · OpenAPI · Power Automate · llms.txt
Authentication
Create a key in Settings > API (owner or admin). It is shown once; keep it secret. Send it as a bearer token (Authorization: Bearer vgl_…; the key alone is accepted too). A key acts on one organization only.
A key that writes (write:*) or exports (read:export) expires within a year: plan its renewal. 14 days before a key ends, the owners and admins are told in the application and by e-mail, and GET /api/v1/me gives its expiresAt. A key keeps working when the member who created it leaves; the owners and admins are asked to review it. For an integration in production, create the key from a dedicated account of the organization (for example integrations@your-company), not from a person's account.
A key can be limited to the addresses of your servers (Settings › API keys › Addresses: addresses or ranges, IPv4 or IPv6, 20 at most; changed without a new key). A call from another address returns 403 api.ipNotAllowed, whose message names the address seen; GET /api/v1/me lists the key's allowedIps.
Scopes: read:sites, read:parties, read:requirements, read:submissions, read:reminders, write:sites, write:parties, write:requirements, write:submissions, write:reminders, write:portal-links, write:import, read:export, read:calendar, read:audit, write:webhooks, read:settings, write:settings, read:members, write:members, write:reviews. A missing scope returns 403 api.insufficientScope. Test a key with GET /api/v1/me.
curl
curl -H "Authorization: Bearer $VIGILO_KEY" https://beta.185-211-6-64.sslip.io/api/v1/me
JavaScript
const res = await fetch(`${BASE}/api/v1/me`, { headers: { Authorization: `Bearer ${process.env.VIGILO_KEY}` } });
console.log(await res.json());Python
import os, requests
r = requests.get(f"{BASE}/api/v1/me", headers={"Authorization": f"Bearer {os.environ['VIGILO_KEY']}"})
print(r.json())Reading data
Endpoints: /api/v1/portfolios, /api/v1/sites, /api/v1/units, /api/v1/assets (equipment), /api/v1/parties, /api/v1/contacts, /api/v1/relationships, /api/v1/compliance-types, /api/v1/requirements, /api/v1/findings and /api/v1/submissions (both below), each with /{id}. Filters: code, managerUserId (or none) (portfolios), code, portfolioId, country, managerUserId (or none) (sites), siteId, reference (units), siteId, unitId, assetKind, reference (equipment), externalRef, registrationNumber and vatNumber (punctuation, spaces and case ignored), role, employerPartyId (the individuals who work for a company), complianceStatus, q (name, legal name or email containing the text), email (exact), managerUserId (or none) (parties), partyId, email (contacts), partyId, siteId, unitId, status (relationships), code, subjectType (compliance types), status (one or several, comma-separated), bucket, includeClosed, complianceTypeId, typeCode, ownerUserId (or none), subjectType, siteId, unitId, assetId, portfolioId, partyId, dueBefore, dueAfter, direction, handling, q (the screen's search: type, subject, site or custom field value) (requirements). Archived records are hidden unless includeArchived=true; deactivated compliance types unless includeInactive=true. A query parameter that is not documented returns 400 api.unknownParameter and a repeated one 400 api.invalidParameter: a misspelt filter never returns the whole list.
status is one of not_requested, requested, submitted, in_review, rejected, compliant, expiring_soon, overdue, expired, waived, closed. The dashboard counts them in four groups: overdue, expired or isBlocked (an open blocking finding) = non-compliant; compliant or expiring_soon = compliant; closed = excluded; waived = compliant or excluded depending on the organization setting; the others = pending. Closed requirements (ended relationship, requirement no longer applicable) are returned like the others: add includeClosed=false to leave them out, as the application's list does. Each requirement gives its group in bucket; bucket=non_compliant (with the default direction) counts what the dashboard counts.
Incremental sync: portfolios, sites, units, equipment, parties, contacts, relationships and requirements accept updatedSince (ISO 8601 date and time). Keep the time you started the previous sync, send it minus a few minutes as updatedSince (a change being written at that moment may carry an earlier time), page through the result and deduplicate by id; add includeArchived=true to learn what was archived since. A requirement's updatedAt moves with every change of what it answers: status (including the daily change to expired or expiring soon), due date, owner, document sent, review, reminder sent; the daily check that changes nothing leaves it as it is.
direction defaults to inbound (collected from the subject): outbound requirements (produced by the organization and sent to a party) only appear with direction=outbound or direction=all, so an integration that never sends direction keeps seeing exactly what it always has.
partyId (filter and field) is the third party that provides the evidence: the subject itself for a third-party requirement, or the provider in charge of a site, unit, equipment or organization requirement (inspection body, maintenance company…). To compute a party's own compliance, keep the rows whose subjectType is party.
Compliance types (scope read:requirements) are what the organization follows: code, name by language, subject type, country, remindersEnabled and reminderLevel, and currentVersion (null until published) with the recurrence, the fields collected with each document and the collection rules: expiringSoonDays, graceDays, reviewMode, checklist, findingsBlockCompliance, minFiles, maxFileMb, acceptedMimeTypes (what POST /files accepts), provider, otpRequired, documentPerRequirement. A field with personal true identifies a person (an inspector's name, a personal certificate number): Vigilo never asks an AI for it. A relationship (scope read:parties) ties a third party to a site or a unit: a lease, a contract, an assignment; its status (upcoming, active, ended) follows its dates. A requirement gives its relationshipId, a reminders object (count, lastSentAt, nextAt, pausedUntil, block), and checkpointDoneAt when your own reminder step (Settings › Statuses and steps) was ticked. When two duplicate third parties are merged, a requirement found on both is kept once: the other one is closed and archived, and its keptRequirementId gives the requirement to follow from now on (null for every other requirement). With the managed service, handling says who follows a type or a requirement up: platform (the Vigilo team calls the parties who do not answer and reviews their documents) or client (your team); a requirement's handlingSetting is inherit when it follows its type. Self-service organizations read client. A key with write:settings changes it: PATCH /api/v1/compliance-types/{id} with handling, PATCH /api/v1/requirements/{id} with handlingSetting (write:requirements as well); the starter workbook's « Calls and review » column (« Handled by » before October 2026, still read) does the same for its requirements, also for one that already exists.
Lists are paged by cursor: limit (1-200, default 50); when nextCursor is not null, pass it as after. Dates are ISO 8601, fields are camelCase. An id of another organization returns 404.
curl
curl -H "Authorization: Bearer $VIGILO_KEY" "https://beta.185-211-6-64.sslip.io/api/v1/requirements?status=expired&limit=100"
Python (all pages)
after, rows = None, []
while True:
r = requests.get(f"{BASE}/api/v1/requirements", params={"limit": 200, **({"after": after} if after else {})}, headers=H).json()
rows += r["data"]
after = r["nextCursor"]
if not after: breakCreating and updating
POST /api/v1/sites and POST /api/v1/parties create (201, scopes write:sites / write:parties); PATCH /api/v1/sites/{id} and PATCH /api/v1/parties/{id} change the fields sent and keep the others (null clears an optional field). The body is one JSON object (64 KB at most); the answer is the record as GET returns it.
POST /api/v1/relationships adds a lease or contract to an existing third party (201, scope write:parties): partyId, siteId or unitId, relationType, startsOn, optional reference and endsOn. PATCH /api/v1/relationships/{id} changes relationType, reference, startsOn or endsOn; the party, site and unit never change. When the ERP knows a contract has ended, send endsOn: once that date has passed, the requirements of the relationship are closed and no longer reminded (they reopen if the end date is removed). Add closeUnlinked: true to also close the party's own requirements tied to no relationship, when no other relationship of the party runs after that date (422 ref.partyStillRelated otherwise, nothing written). A relationship entered by mistake (wrong party, site or unit) is archived with DELETE /api/v1/relationships/{id} (204, since 21 October 2026; its requirements closed, POST /api/v1/relationships/{id}/restore brings it back); a contract that ends keeps its history with endsOn.
POST /api/v1/requirements creates a requirement by hand (201, scope write:requirements): complianceTypeId or complianceTypeCode (see GET /api/v1/compliance-types), subjectType, subjectId (plus relationshipId for a third party), optional dueDate, ownerUserId or ownerEmail (a member of the organization), priority, direction; creating the same type on the same subject again returns 422 obl.duplicate. Without ownerUserId the owner is the third party's manager (a third-party subject), else the site manager, else the portfolio manager, each only if they see the requirement, else nobody. A site's, portfolio's or third party's managerUserId (POST / PATCH, see GET /api/v1/members) must be an owner, admin or manager who sees it (422 ref.invalidManager, ref.managerOutOfScope); a third party's manager is also told of its deposits and messages. direction defaults to inbound; outbound requires subjectType party or organization (the subject is the recipient, not a source). PATCH /api/v1/requirements/{id} changes the due date (dueDate with a reason of 10 characters at least; confirmPastDue for a past date) and/or the owner (ownerUserId or ownerEmail, ownerUserId null to clear) and/or custom field values (customFields, the fields not sent keep their value); a body with none of them returns 400 api.invalidBody.
Provider in charge (partyId): a site, unit, equipment or organization requirement can be entrusted to a third party (inspection body, maintenance company…) with PATCH /api/v1/requirements/{id} {"partyId": "<party id>"} (or partyId in the POST body); that party then receives the requests, the reminders and the portal link, the subject stays the site or equipment. {"partyId": null} removes it. A third-party requirement (the party provides its own evidence) answers 422 obl.providerPartySubject, an outbound one 422 obl.providerOutbound; an unknown party 422 ref.invalidReference, an archived one 422 obl.providerArchived (both with fieldErrors.partyId).
Waiver and exclusion (PATCH /api/v1/requirements/{id}, scope write:requirements, each with a reason of 10 characters or more, kept in the history under the key's name): {"waived": true, "waivedUntil": "2027-03-31", "reason": "Chiller stopped until its replacement"} waives the requirement (status waived, reminders stop) until that day, or without end if waivedUntil is absent; {"waived": false, "reason": …} lifts the waiver. {"excluded": true, "reason": "This supplier does no work at height"} closes it as « does not apply » (closedReason manual, closedNote = the reason, reminders stop); {"excluded": false, "reason": …} reinstates it, as it does a requirement closed by undoing an import (closedReason manual, closedNote naming the import). Both name a target state: a replay changes nothing and records nothing. excluded: false also reopens a third party's own requirement closed with its last relationship. A requirement closed for another reason is refused with a code naming the way out (422): obl.closedWithRelationship (change the relationship's end date), obl.closedWithSubject (restore the archived element), obl.typeInactive (reactivate the type); before 20 October 2026 the code was obl.notExcluded. A closed requirement cannot be waived (422 obl.closed). When the organization requires a second approval (Settings › Statuses and steps), waived: true records a pending request instead, read back as waiverRequest { reason, until, requestedAt } with the status unchanged; another owner or administrator approves or refuses it, in the application or with a write:reviews key of theirs (POST /api/v1/requirements/{id}/decide-waiver, see « Approving or rejecting a document »); {"waived": false, "reason": …} withdraws it. The organization's own reminder step (Settings › Statuses and steps), read back as checkpointDoneAt, is ticked with {"checkpointDone": true} and unticked with false (422 obl.checkpointDisabled when the organization has no such step). A third party that answered « This does not concern me » on its portal: notConcerned { reason, reportedAt } on the requirement (GET /api/v1/requirements?notConcerned=true lists them), its reminders paused until a member decides — exclude it, waive it, entrust it to another provider or change the pause.
DELETE /api/v1/parties/{id} (scope write:parties) archives a third party you no longer work with, as the screen does (204, also when already archived): its contacts and relationships are archived, its requirements closed (closedReason subject_archived), its reminders stop and its portal links stop working; checks entrusted to it as a provider are released. DELETE /api/v1/sites/{id} (scope write:sites) archives a site sold or closed with its units, equipment and leases, their requirements closed. POST /api/v1/parties/{id}/restore and /sites/{id}/restore bring them back (see « Undoing an action and following the history »). write:parties and write:sites include these DELETE since 30 September 2026, keys created before included: revoke and recreate a key that must not archive.
The same rules as the application apply (required fields, duplicates, custom fields, assignment rules). A refused write returns 422 with a code, and fieldErrors for invalid fields. Writes are recorded in the audit journal under the API key.
Retries without duplicates: send an Idempotency-Key header (a new UUID per write) with any POST or PATCH, and the same value when you retry after a timeout or a lost answer. Within 24 hours, the same key with the same request (method, path, query, body) answers the first success again, with the header Idempotent-Replayed: true, and writes nothing more. The key is bound to your API key. The same key with another request answers 422 api.idempotencyKeyReused; while the first request still runs, 409 api.idempotencyKeyInProgress with Retry-After. An error answer (400, 403, 422, 5xx) is not kept: the request wrote nothing, correct it and send it again with the same key. One exception: a PATCH whose fields apply in turn (a requirement, a finding) refused after one of them applied keeps that refusal (4xx) as its answer — send the corrected request with a new key. POST /api/v1/files (raw file bytes) ignores the header: a retried upload is a second draft file, which DELETE /api/v1/files/{id} removes. After a party is erased, the answers kept for your keys lose their body: a replay answers {} with the first status and still writes nothing.
curl
curl -X POST -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -d '{"name":"Lyon warehouse","code":"LYO-1","city":"Lyon"}' https://beta.185-211-6-64.sslip.io/api/v1/sitesPortfolios, units and equipment
The whole asset register goes through the API with the scopes read:sites and write:sites: portfolios (groups of sites), sites, units (the apartments, shops or offices of a site: what a lease is about) and equipment (lifts, extinguishers, boilers…: what the periodic inspections are about). A property management system keeps the sites and units up to date, a maintenance tool (CMMS) the equipment; the assignment rules then create the requirements of each new unit or equipment, as they do on the screen.
GET /api/v1/portfolios, /api/v1/units and /api/v1/assets list them (each with /{id}, updatedSince and includeArchived). To find the record your system knows: code (portfolios), siteId with reference (units, case ignored), reference (equipment, case ignored), or siteId, unitId and assetKind.
POST creates (201): a portfolio needs a name; a unit a siteId and a reference, unique in its site; equipment a siteId, a name and an assetKind (one of the kinds listed in /api/v1/openapi.json or added in the organization settings), with an optional unitId of the same site, reference, serialNumber, manufacturer, model, installedOn, locationDetail and quantity (12 identical extinguishers followed as one line). A unit or equipment never changes site: archive it and create it on the other site.
A replayed POST finds the record instead of doubling it: a portfolio name or code, a unit reference in its site, or an equipment reference another non-archived equipment of the site already carries answers 422 (ref.duplicateName, ref.duplicateCode, ref.duplicateReference) with existingId; PATCH that record. Give each equipment the identifier of your maintenance tool as reference: equipment without reference cannot be recognized. An archived unit keeps its reference and an archived portfolio its code: existingId then names an archived record, which a PATCH refuses (ref.archived); restore it in the application, or choose another reference.
PATCH changes the fields sent. DELETE /api/v1/units/{id} and DELETE /api/v1/assets/{id} archive (204, also when already archived), as the screen does: the requirements of equipment taken out of service are closed (closedReason subject_archived) and their reminders stop; a unit takes its equipment and its leases with it, their requirements closed. POST /api/v1/units/{id}/restore and /assets/{id}/restore bring them back and reopen their requirements; DELETE /api/v1/portfolios/{id} archives a portfolio no longer used (since 21 October 2026): holding active sites, 422 ref.hasActiveChildren unless ?cascade=true, which archives them with it — or move them first (PATCH /api/v1/sites/{id} portfolioId); archiving a site: see Creating and updating. write:sites includes these DELETE since 30 September 2026, keys created before included: revoke and recreate a key that must not archive.
curl
curl -X POST -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -d '{"siteId":"<site id>","assetKind":"lift","name":"Lift A","reference":"CMMS-00412","installedOn":"2019-05-14"}' https://beta.185-211-6-64.sslip.io/api/v1/assetsContacts of a third party
The requests and reminders go to the contacts of a third party, not to the party itself: when the person who answers for a supplier changes, update the contact and the next reminder goes to the right person. Scopes read:parties and write:parties.
GET /api/v1/contacts?partyId=… lists them (filters partyId, email: exact address, case ignored; updatedSince; includeArchived); GET /api/v1/contacts/{id} reads one. Each contact gives firstName, lastName, email, phone (E.164), jobTitle, locale, isPrimary (one per party), receivesRequests and fromPartyEmail (made from the party's own email: it receives the requests only while no other contact does). The contacts a portal delegation created (the supplier's insurer or broker) are not the party's own: they are not listed.
POST /api/v1/contacts creates one (201): partyId, at least a first or last name (ref.contactNameRequired) and an email or a phone (ref.contactChannelRequired); the phone is checked with the party's country. The first contact you add becomes primary in place of the one made from the party's email; isPrimary: true makes a later one primary. An active contact of the same party with the same email (case ignored) answers 422 ref.duplicateContactEmail with existingId: a POST replayed after a lost answer, or the contact of a party you created with that email, is found instead of doubled; PATCH it.
PATCH /api/v1/contacts/{id} changes the fields sent. A new email revokes the portal links sent to the previous address; receivesRequests: false stops the requests and revokes the links already sent. The primary contact cannot be set to isPrimary: false (422 ref.primaryContactRequired): make another one primary. DELETE /api/v1/contacts/{id} archives the contact (204, also when it already is): its links stop working and the oldest remaining contact becomes primary. POST /api/v1/contacts/{id}/restore brings it back.
A contact may change its own address from its portal: the new one applies once the contact types the code sent to it, the contact's updatedAt moves and the members following the party are told. For 30 days the contact carries portalEmailChange { id, previousEmail, changedAt, revertibleUntil }. If the link was in the wrong hands, POST /api/v1/contacts/{id}/revert-email-change with {"changeId": portalEmailChange.id} puts previousEmail back, as « Undo this change » on the party page: every portal link of the contact stops, the one used for the change included (issue a new one with POST /api/v1/portal-links). Already undone, the address changed again or more than 30 days: 409 prt.emailChangeNotRevertible.
curl
curl -X POST -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -d '{"partyId":"<party id>","firstName":"Ana","lastName":"Silva","email":"ana.silva@supplier.example","jobTitle":"Accountant","isPrimary":true}' https://beta.185-211-6-64.sslip.io/api/v1/contactsFindings and your maintenance tool
A finding is a reservation of an inspection report or an observation of a visit, raised on a requirement: title, description, severity (critical, major, minor, observation), status, assigneeUserId and dueDate (correct before). Scopes read:requirements and write:requirements: a key that already holds them reads and writes the findings too. While a critical finding is open, the requirement is not compliant when its compliance type says so (isBlocked, openFindingsCount on the requirement).
GET /api/v1/findings lists them (filters requirementId, siteId, partyId, status and severity: one or several values, comma-separated; overdue=true: still open and past dueDate; updatedSince); GET /api/v1/findings/{id} reads one, with createdBy, resolvedBy and verifiedBy ({ type, id }: member, former_member, support, api_key). sharedWithParty: the finding is listed on the third party's portal; when the third party marks it resolved there, resolvedBy is null and resolvedByContactId names its contact (finding.status_changed follows, as for a member). It turns false when the requirement changes provider, and the finding's updatedAt moves. To open a work order for each new finding, poll status=open,in_progress with updatedSince (raised, assignee or date changed, status changed) and keep the finding id on the work order; or subscribe to the webhook events finding.created and finding.status_changed (Webhooks, below: no polling, a flow starts at each new finding) and read the finding with its findingId. A finding has no archive date of its own: when its requirement is archived it leaves the list (includeArchived=true keeps it) and can no longer be changed (422 ref.parentArchived); follow the requirement's archivedAt to cancel the work order.
POST /api/v1/findings raises one (201): requirementId, title, severity, and optionally description, dueDate, assigneeUserId or assigneeEmail (a member who sees the requirement's site). The requirement must be neither archived nor closed (422 obl.closed). An open finding of the same requirement with the same title (case and spacing ignored) answers 422 fnd.duplicateOpen with existingId: a POST replayed after a lost answer finds the finding instead of doubling it; give two findings of one report different titles (place, equipment). A requirement holds 200 open findings at most (422 fnd.tooManyOpen).
PATCH /api/v1/findings/{id} corrects the finding while it is open (422 fnd.notOpen afterwards): title, description, severity (raised by a key; lowering it may unblock the requirement, an owner's or administrator's decision on the screen: 403 auth.lowerSeverity), dueDate and the assignee. It also records the progress of the correction: status in_progress when the work order is taken in charge, status resolved with resolutionNote (what was done) when it is closed, whoever the assignee is, and proofFileIds: up to 5 files the key uploaded first for the requirement (POST /api/v1/files?requirementId=, scope write:submissions), the intervention report or a photo, listed under the finding as on the screen. The same status sent again answers 200 and writes nothing. A key never verifies: a member other than the one who created the key checks the correction on the screen (verified), sends it back (in_progress) or decides it will not be corrected (wont_fix); any other move through the API answers 422 (fnd.invalidTransition, or validation.invalid naming status).
curl (work order closed)
curl -X PATCH -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -d '{"status":"resolved","resolutionNote":"Fire door closer replaced, work order WO-2481"}' https://beta.185-211-6-64.sslip.io/api/v1/findings/<finding id>Reminders sent to third parties
Every email Vigilo sends to a third party (request, reminder before or after the due date, final notice, receipt) is kept as a proof of sending. Scope read:reminders: GET /api/v1/reminders lists them, newest last (filters requirementId, partyId, status: queued, sent or failed, trigger: reminder_step, manual or system, and queuedSince); GET /api/v1/reminders/{id} adds bodyText, the text sent with the portal link masked, and contentSha256 proves the content. sentBy says who asked: a member or an API key for a « remind now », system for the planned steps and the receipts. The addresses of the third party's contacts are in these emails: give read:reminders only to a tool that may see them.
Scope write:reminders: POST /api/v1/reminders { requirementId } reminds the third party now (201, data: the emails sent, one per contact that receives the requests), even while its reminders are suspended; the planned reminders then continue from it. At most one reminder a day for a requirement through the API: a second call the same day (local time of the third party), a replayed call included, answers 409 rmd.alreadyRemindedToday with lastRemindedAt, and nothing is sent. A third party that already received twice the weekly cap of reminder emails (manual and automatic, Settings › Reminders; 6 by default) in the last 7 days answers 409 rmd.capExceeded: only an owner or admin sends another one, from the requirement's page, with a reason. Refused (422): a requirement that is compliant, under review, waived, closed or sent by the organization (rmd.notRemindable), no third party (rmd.noParty), no contact that receives the requests (rmd.noRecipient: add one with POST /api/v1/contacts). rmd.sendFailed: the mail server refused the email; it is listed with status failed: check the contact's address.
To suspend the automatic reminders of a requirement (supplier on leave, dispute), PATCH /api/v1/requirements/{id} with remindersPausedUntil (a date, at most one year ahead; scope write:requirements); null resumes, and one reminder then goes at the next slot, no catch-up burst. GET /api/v1/requirements/{id} gives reminders.pausedUntil (the end of that day, as an instant), count, lastSentAt and nextAt. When the third party answered « This does not concern me » on its portal, its reminders are paused for a year: any change of remindersPausedUntil settles that answer, and a submission, a waiver, an exclusion or a new provider settle it and clear the pause it set. A requirement entrusted to another provider (PATCH partyId) starts over for it: count back to 0, lastSentAt null, and its first email is the request (the overdue reminder once the due date has passed), never the final notice the former provider was heading to.
curl (remind now)
curl -X POST -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -d '{"requirementId":"<requirement id>"}' https://beta.185-211-6-64.sslip.io/api/v1/remindersMessages with third parties
Each requirement has a thread between the organization and its third party: the third party writes on its portal (« the certificate arrives next week »), a member answers on the requirement page. Scope read:reminders: GET /api/v1/messages lists the messages, newest last (filters requirementId, partyId, from: party or organization, createdSince; cursor paging); GET /api/v1/messages/{id} reads one. from is party (written by a contact: author { type: contact, id }) or organization (a member or an API key). A message is never edited; its text reads *** once erased at the third party's request (GDPR). Subscribe to the webhook event requirement.message_received to learn when a third party writes, then read GET /api/v1/messages?requirementId=.
Scope write:reminders: POST /api/v1/messages { requirementId, body } writes to the third party as the organization (201, the message): each of its contacts that receives the requests gets an email with a portal link limited to this requirement (the email does not carry the text), the third party reads the organization's name, the members read the key's name. The same text by the same key on the requirement within 24 hours answers 409 api.alreadySent with existingId (no second email), and a key writes 10 messages an hour at most on a requirement (422 prt.messageRateLimited). A requirement without third party answers 422 prt.noParty, without a contact that receives the requests 422 prt.noContact. read:reminders and write:reminders include the messages since 30 September 2026, keys created before included.
curl (write to the third party)
curl -X POST -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -d '{"requirementId":"<requirement id>","body":"Please send the certificate for 2027."}' https://beta.185-211-6-64.sslip.io/api/v1/messagesSending the deposit link from your tool
A portal link opens the space of a third party to one of its contacts, without an account: the contact reads what is asked and sends the documents. Vigilo puts one in each request and reminder email. Your purchasing tool or ERP can send its own instead (in a purchase order, a welcome email, a supplier portal). Scope write:portal-links (a scope of its own: a link gives access to the third party's space): POST /api/v1/portal-links { partyId } for every open requirement of the third party, or { requirementIds: [...] } for some of them (same third party, 50 at most), with contactId to choose the contact (by default the one the screen chooses: a contact who receives the requests, the primary one first, else the oldest; else the primary contact). 201: the link with its url, given in this answer only (Vigilo keeps its hash): send it at once, never store it in a log. The link lasts 30 days.
The links a key issues are its own. The same contact and scope again answers 409 api.alreadySent with existingId (the url cannot be read again); regenerate: true revokes the key's link and gives a new one. The links of Vigilo's emails and those a member copied are never touched, and the screen never revokes the key's links. A requirement of a sensitive type asks for a code: it goes to the contact's email, never to your tool (422 prt.otpNoEmail for a contact without email). 20 active links at most per key and third party (422 api.tooManyLinks). DELETE /api/v1/portal-links/{id} revokes any link of the organization, whoever issued it (204, again 204). Revoking the key revokes the links it issued (revokedReason key_revoked): after a key rotation, issue new links with the new key; the next request or reminder email of Vigilo carries a link of its own.
Scope read:reminders: GET /api/v1/portal-links (filters partyId, contactId, active; cursor paging) and GET /api/v1/portal-links/{id}, never the url: active, lastUsedAt (last opening) and useCount answer « the supplier says he received nothing » together with GET /api/v1/reminders?partyId=. createdBy is the member, the key, or system for a link of an email. A document sent through a link of a key cannot be approved by the member who created the key when the organization separates the duties.
A contact can forward one requirement from its space to its insurer, accountant or a colleague, who gets a link of its own (30 days, stopped with the contact's link). GET /api/v1/portal-links?delegated=true lists these forwarded links: delegatedFromId is the contact's link, delegate the name and address the contact typed (contactId null: that person is not a contact of the third party). DELETE /api/v1/portal-links/{id} on a forwarded link revokes it alone (a mistyped address); the contact can also cancel it from its space.
curl (a link for the purchase order)
curl -X POST -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -d '{"partyId":"<third party id>"}' https://beta.185-211-6-64.sslip.io/api/v1/portal-linksLoading a whole base: the starter workbook
The starter workbook is the screen's « Complete file »: one .xlsx with a sheet per kind, in the order of import (portfolios, sites, units, third parties, equipment, requirements, team), each row naming the rows of the sheets above by their code or name. Scope write:import (a scope of its own: one call creates a whole base; keys are created by owners and admins, who import on the screen). GET /api/v1/imports/workbook gives the template (example=true with example rows to replace, locale=fr for French headers and lists): fill it from your tool, or generate the same sheets yourself.
POST /api/v1/imports/workbook with the .xlsx as the body (raw bytes, 10 MB and 5 000 rows at most) checks it: 200 with the report, nothing written. Each remark gives the sheet, the line, the field, the value received and its sentence (« « Igloo » is not in the list (sheet « Lists »). », « 165 characters, 160 at most. »). Then commit=true imports all or nothing: if the workbook still has an error, nothing is written and the answer is 422 imp.workbookHasErrors with the same report. A known portfolio (same name or code), site (same code), unit (same site and reference), equipment (same site and reference) or third party (same registration number) is not created again (existing, and differing when a filled cell differs from its record); add update=true to apply those cells to the known portfolios, sites, units and equipment (updated: an empty cell keeps the value; third parties are never updated by a file). A code of an archived site or unit is an error (imp.archivedKnown: restore it first); an external reference already used is an error (ref.duplicateExternalRef). Give each site, unit and equipment its code and each third party its externalRef (your tool's id) or its registration number: a retry after a lost answer then writes nothing twice, and its report says what is already there. A workbook sent while another of the organization is being checked or imported answers 409 api.importRunning, or waits for it before writing: send the next one when the answer arrives. firstRequests says how many first requests the new requirements will send. The Team sheet invites one person per row (email, role manager / viewer, portfolios and sites by code or name, access until for a viewer, invitation language) in the name of the member who created the key; it needs the write:members scope too (403 api.insufficientScope otherwise) and its emails leave once the workbook is imported (invitations: sent, failed). An address already a member or invited is existing; administrators are invited on the screen (auth.adminOnScreen).
A nightly file from your system (there is no SFTP drop): have your scheduler send the same workbook every night with commit=true&update=true. Rows already known are updated (an empty cell keeps the value), new rows are created, nothing is created twice; a file that still has an error writes nothing (422 imp.workbookHasErrors): keep the report to correct the source. A row removed from the file is not archived: archive it by the API (DELETE) or on the screen.
curl (template, then check, then import)
curl -H "Authorization: Bearer $VIGILO_KEY" -o starter.xlsx "https://beta.185-211-6-64.sslip.io/api/v1/imports/workbook?locale=en" curl -X POST -H "Authorization: Bearer $VIGILO_KEY" --data-binary @starter.xlsx https://beta.185-211-6-64.sslip.io/api/v1/imports/workbook curl -X POST -H "Authorization: Bearer $VIGILO_KEY" --data-binary @starter.xlsx "https://beta.185-211-6-64.sslip.io/api/v1/imports/workbook?commit=true"
Exporting everything: data and documents
POST /api/v1/exports (scope read:export, which only an owner can give a key) requests the full export of Settings > Data: one ZIP with data.xlsx (a readable sheet per table), data/<table>.jsonl (every table, no row limit: the audit journal and the e-mails sent with their text included), the documents (files/<type>/<subject>/<file>), sha256.txt (the fingerprint of each document) and manifest.json (tables, columns, counts, fingerprints, what is not shipped and why). The answer is 202 with the export (status pending), its Location and Retry-After; an export already under way, requested on the screen or by a key, is answered instead (200): never two builds.
The export is built in the background (the builder runs every 5 minutes, then a few seconds to a few minutes depending on the documents). GET /api/v1/exports/{id} until status is done, waiting the seconds of Retry-After (until the builder's next pass while pending, 30 seconds while running), then GET its downloadUrl with the same key. Compare the SHA-256 of the file received with sha256 (also the ETag). The download resumes where it stopped: Range: bytes=<bytes received>- answers 206 with the rest (curl -C -). Every request is recorded in the audit journal under the key's name.
An export requested by a key is kept 48 hours, then deleted (status expired, 410 api.exportExpired), and sends no e-mail: no link travels, the key downloads it. At least 12 hours between two exports requested by the keys of the organization (429 api.exportTooSoon with Retry-After): a daily backup always passes. A failed export (error.code imp.exportTooLarge: beyond 64 000 documents or 4 GiB) does not count; write to the support. GET /api/v1/exports lists the 20 latest, whoever requested them.
Daily backup: schedule the script below once a day (cron, Windows Task Scheduler, an Azure or AWS function). For changes only, between two full exports, read the lists with updatedSince.
bash (request, wait, download, check)
id=$(curl -s -X POST -H "Authorization: Bearer $VIGILO_KEY" https://beta.185-211-6-64.sslip.io/api/v1/exports | jq -r .id)
while :; do status=$(curl -s -H "Authorization: Bearer $VIGILO_KEY" https://beta.185-211-6-64.sslip.io/api/v1/exports/$id | jq -r .status); case $status in pending|running) sleep 60;; *) break;; esac; done
[ "$status" = done ] || { echo "export $status"; exit 1; }
curl -C - -H "Authorization: Bearer $VIGILO_KEY" -o vigilo-export.zip https://beta.185-211-6-64.sslip.io/api/v1/exports/$id/file
curl -s -H "Authorization: Bearer $VIGILO_KEY" https://beta.185-211-6-64.sslip.io/api/v1/exports/$id | jq -r .sha256
sha256sum vigilo-export.zipConfiguration: lists, custom fields and rules
The configuration an owner or admin sets in Settings goes through the API too, with the same checks and the same audit: read:settings reads it, write:settings changes it (the key then acts as an admin for these endpoints only, never elsewhere). Set up a new organization, or copy the configuration of one to another, from a script.
GET /api/v1/settings: the site types and equipment kinds the organization added to the delivered ones (deliveredSiteTypes, deliveredAssetKinds), and the default delays of a new compliance type (initialDueDays, expiringSoonDays, graceDays). PATCH /api/v1/settings changes the fields sent; a list sent replaces the stored one, so read it, add the value, send it back. A delivered value (its code or its name), the same value twice, or a value still carried by a site, an asset or a rule answers 422 naming it. A site or an asset then takes an added value in siteType / assetKind, as typed. partyIdentifiers (Settings › Third parties) says whether a company needs its registrationNumber (registrationRequired) and whether an invalid number or VAT number is refused (rejectInvalid): POST and PATCH /parties then answer 422 ref.registrationNumberRequired, ref.registrationNumberInvalid or ref.vatNumberInvalid on the field; a party recorded before keeps its other edits.
GET /api/v1/custom-fields (entity site, party or requirement; includeArchived): the definitions behind the customFields of sites, third parties and requirements. POST creates one (entity, key, fieldType, label in the organization's language, options of a list); PATCH changes label, required, options, and archived: true / false archives or restores (the stored values are kept). The key and the type never change. A choice removed from options stays on the items that hold it: sent back unchanged it is accepted, a new item cannot take it. While an administrator converts the type of a field on the screen, writes of its items wait 3 s at most, then 409 ref.customFieldsLocked: retry after Retry-After.
GET /api/v1/rules and /rules/{id}: the assignment rules, their criteria (selector, camelCase: siteTypes, siteIds, partyRoles, countries…, customFields: conditions on a custom field of the third party or the site; an empty list is not used) and due date (duePolicy). POST /api/v1/rules creates one (complianceTypeId or complianceTypeCode); with isActive: true it is evaluated in the same call and lastEvaluation says what it created, as on the screen; holdRequests: true keeps the reminders of the new requirements paused. PATCH changes the fields sent; a selector sent replaces every criterion, so send back the ones you read — except customFields: left out, the stored conditions stay ([] removes them). isActive: false keeps every requirement the rule created: those another active rule of the type targets move to that rule, the others are flagged out of rule. POST and PATCH answer 409 rul.busy while another rule of the type is being changed: try again after Retry-After. Before saving, send the same body to POST /api/v1/rules/preview (a creation) or POST /api/v1/rules/{id}/preview (a change; an empty body previews the rule as stored): checked as the save checks it, the answer gives what the rule editor shows — subjects covered (matched), requirements to create (toCreate), those already followed (existing), the third parties written to first (firstRequests: null when nothing leaves, e.g. with holdRequests: true) — and skippedReason when the save itself would create nothing (rule_inactive, type_inactive, type_not_published); nothing is written.
PATCH /api/v1/compliance-types/{id} sets the switches of a type's page: isActive (false: no new requirement, its rules stop; its open requirements stay followed, or are closed with closeRequirements: true; true: those its deactivation closed come back and its active rules are evaluated), remindersEnabled, reminderLevel (gentle, standard, firm; null: the organization's calendar) and handling. Every value is checked before anything is written; the same value again records nothing. POST /api/v1/compliance-types/{id}/preview with the same body gives the counters of the screen's dialog: openRequirements, closed and reopened by the call, activeRules. Versions, fields and message templates stay on the screen for now.
curl (add a site type, then a rule on it)
curl -X PATCH -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -d '{"siteTypes":["Data center"]}' https://beta.185-211-6-64.sslip.io/api/v1/settings
curl -X POST -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -d '{"name":"Data center audits","complianceTypeCode":"DC_AUDIT","selector":{"siteTypes":["Data center"]},"isActive":true}' https://beta.185-211-6-64.sslip.io/api/v1/rulesMembers and invitations
Review who has access and provision the team from your directory or HR tool, until single sign-on: read:members reads, write:members invites, changes and removes. A write:members key acts in the name of the member who created it: once they are no longer an owner or administrator, it answers 403 auth.keyCreatorNotAdmin (create a new key). It manages managers and viewers only; administrators are appointed and removed in Settings › Members (403 auth.adminOnScreen). Prefer a key created by a service account, restricted to the addresses of your server.
GET /api/v1/members (role, email): name, address, role, scope (scopeAll, or portfolioIds and siteIds), accessUntil of a temporary access, absence (from, until, deputyMemberId; null when none or ended), lastSignInAt (null: never signed in) and joinedAt; userId is what ownerUserId of a requirement names. GET /api/v1/invitations (email): the pending invitations, with expiresAt and expired.
POST /api/v1/invitations (email, role manager or viewer, portfolioIds / siteIds to restrict the scope, accessUntil for a viewer, locale of the email): the person receives the same email as from the screen, valid 7 days, and becomes a member when they accept. The limits are the screen's: 100 pending invitations, 3 invitation emails per address and hour, an address already a member or already invited is refused (422 naming it). DELETE /api/v1/invitations/{id} revokes one: to send a new link, revoke and invite again.
PATCH /api/v1/members/{id} changes the fields sent (role, scope, accessUntil, absence); absence { from, until, deputyMemberId } plans a manager's leave from your HR tool (null ends it): meanwhile their notices and the internal reminders of their outbound requirements also go to the deputy, an owner, admin or manager whose scope covers theirs (422 org.invalidDeputy, org.deputyAbsent); nothing changes owner. DELETE /api/v1/members/{id} removes the member: their access ends at their next request and the requirements they owned lose their owner, as on the screen; with ?replacementMemberId=<member id> (an owner, admin or manager), their requirements, sites, portfolios and open findings go to that member first, within that member's scope (a departure from the HR system: nothing is left without an owner). Every change is in the audit journal under the key's name, with the address of the call.
curl (invite an auditor for one portfolio until the end of the year)
curl -X POST -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -d '{"email":"auditor@example.com","role":"viewer","portfolioIds":["<portfolio id>"],"accessUntil":"2026-12-31"}' https://beta.185-211-6-64.sslip.io/api/v1/invitationsCalendar feed
GET /api/v1/calendar.ics?key=… returns the deadlines of the organization as an iCalendar feed (one all-day event per open requirement: the expiry of the approved document, or the due date; a due date passed stays on today's date until the requirement is settled, its title says what to do: to provide, to review, to request, overdue, expires). Past 2,000 events the earliest are kept and one more says how many are left out. Subscribe to it from Google Calendar, Outlook or Apple Calendar (subscribe from URL); apps refresh it every few hours.
Calendar apps cannot send a header, so the key goes in the query string. Create a key with the read:calendar scope only (it cannot be combined with other scopes: a URL ends up in logs and browser history) and treat the address as private; revoke the key to cut the feed. Google Calendar, Outlook.com and iCloud read the address from their own servers and keep a copy of the deadlines (third-party names included): for them, leave the key without allowed IP addresses.
curl
curl "$VIGILO_URL/api/v1/calendar.ics?key=$VIGILO_CALENDAR_KEY"
Audit journal (SIEM)
GET /api/v1/audit-events (scope read:audit) returns the audit journal of the organization: who did what, when, with the values before and after (the IP address is recorded for sign-ins and account events, which are not in this feed: ip is normally null for the actions it lists). It is the journal of Settings > Journal, with the same redactions: never a secret, never the internal notes of Vigilo staff, and a Vigilo staff member acting on your request appears as support, without identity. A change of owner, reviewer, finding assignee, manager or deputy holds the member's id (ownerId, reviewerId, findingAssigneeId, managerUserId, deputyUserId in before and after) and, next to it, their name read at the time of the call (owner, reviewer, findingAssignee, manager, deputy: null once the member has left the organization or deleted their account); requirement.owner_changed events written before 20 October 2026, member.absence_set and member.absence_cleared events written up to 20 October 2026 may hold the name alone (owner, deputy), the name of the time.
Polling (Splunk, Microsoft Sentinel, Elastic…): the first call sends since (ISO 8601); each next call sends after=<id of the last event received>. Page with limit (up to 200) while nextCursor is not null, then poll again a few minutes later with the id of the last event. Events come in the order the changes were committed (nearly chronological: occurredAt may step back by a few seconds), each one as soon as every change started before it is committed, so the cursor never skips an event; a long change (a large import) delays the feed by its duration. Filters: category, action (exact), entityType, entityId. An after id that no longer exists (purged after the retention period) returns 400: start again with since.
actor.type is member, former_member, support, system (scheduled jobs), contact (a third party through its portal link) or api_key; actor.email is given for current members. The sign-ins of your members (auth.login_succeeded, auth.login_failed, auth.account_locked; entity: the account) are in the feed of each organization the person belongs to, never in another one; a failed sign-in has actor.type anonymous. ip is given on the sign-ins, on every action of an API key and on the sensitive actions of a member (exports, API keys, roles and removals, ownership, closure), and erased after 12 months. New actions appear over time: treat an unknown action as a generic event.
Python (poll)
after = state.get("after")
params = {"limit": 200, **({"after": after} if after else {"since": "2026-10-01T00:00:00Z"})}
while True:
r = requests.get(f"{BASE}/api/v1/audit-events", params=params, headers=H).json()
for event in r["data"]:
siem.send(event)
state["after"] = event["id"]
if not r["nextCursor"]: break
params = {"limit": 200, "after": r["nextCursor"]}Undoing an action and following the history
Every change made through the API can be undone through the API, and leaves a line in the audit journal: who (the key, or the member it acts for), when, the values before and after. Undoing never erases the history: the document withdrawn, the requirement reinstated stay readable with their journal.
A document sent by mistake and not decided yet: POST /api/v1/submissions/{id}/withdraw (write:submissions; any document waiting for review, whoever sent it); the document it had replaced comes back to review; with ?eraseFiles=true its files are erased too (a wrong file holding another person's data; refused under a legal hold). A requirement excluded, or closed with its third party's last relationship: PATCH /api/v1/requirements/{id} with excluded: false and a reason. A waiver: waived: false. A relationship ended too early: PATCH /api/v1/relationships/{id} with endsOn null or a later date, which reopens the requirements it closed. A due date, an owner, a provider, a custom field: PATCH again with the previous value (the journal holds it).
A decision taken by mistake (write:reviews, in the name of the key's creator): a document rejected, or whose approval was revoked, goes back to review with POST /api/v1/submissions/{id}/return-to-review while no newer document was sent — then approve or reject it again; an approval is revoked with POST /api/v1/submissions/{id}/revoke and a reason (the creator must be owner or admin). A finding verified or accepted without correction is reopened with POST /api/v1/findings/{id}/decide, action reopen and a reason; a resolution is sent back with action reject.
An element archived by mistake (on the screen, by DELETE, or by an import undone): POST /api/v1/{collection}/{id}/restore for sites, portfolios, units, assets, parties, contacts and relationships (write:sites or write:parties). What was archived with it comes back (a site's units, equipment and leases; a party's contacts and relationships), the requirements it closed reopen with their status recomputed; under an archived parent, restore the parent first (422 ref.parentArchived). Portal links revoked by the archive stay revoked: issue new ones. An archived element stays readable with includeArchived=true.
A wrong file imported (write:import): GET /api/v1/imports lists the last 10 imports, screen and API; the answer of POST /api/v1/imports/workbook?commit=true gives its importId. GET /api/v1/imports/{id} says what an undo would reach (undoImpact); POST /api/v1/imports/{id}/undo archives what the import created and releases its codes, so that the corrected file loads at once. Within 30 days, once; the key's creator must be owner or admin (403 auth.keyCreatorNotAdmin). Rows updated by the import are not undone: PATCH them back (the journal holds the previous values).
Not undone, by design: the erasure of a third party or of an account (GDPR), the merge of two third parties, a revoked key, an email already sent (suspend the next reminders instead), a deleted webhook (DELETE /api/v1/webhooks/{id}: its delivery history goes with it; create it again for a new secret), and, on the screen, a deleted note, saved view or scheduled send. A compliance type deactivated by mistake: PATCH /api/v1/compliance-types/{id} with isActive: true reopens the requirements its deactivation closed. A webhook paused: PATCH /api/v1/webhooks/{id} with active: true (the events of the pause are not sent later). An assignment rule activated (PATCH /api/v1/rules/{id}, isActive: true) creates its requirements at once — preview it first with POST /api/v1/rules/{id}/preview, or keep their reminders paused with holdRequests: true; deactivating it leaves them, marked out of rule.
To follow the history: GET /api/v1/audit-events?entityId={id} lists every change of one element with its values before and after; GET /api/v1/submissions?requirementId={id} lists every document of a requirement, withdrawn and rejected ones included (status).
Errors and limits
Errors are JSON: { "error": { "code", "message", "requestId", "docUrl" } }. Codes: api.unauthorized (401), api.insufficientScope (403), api.ipNotAllowed (403, the key does not allow the calling address), api.notFound (404, also for a path that does not exist), api.methodNotAllowed (405, with an Allow header), api.invalidParameter, api.unknownParameter or api.invalidBody (400), api.payloadTooLarge (413), api.importRunning (409, a workbook of the organization is already being checked or imported), api.invalidIdempotencyKey (400), api.idempotencyKeyInProgress (409) and api.idempotencyKeyReused (422) (see Idempotency-Key in « Creating and updating »), api.rateLimited (429), api.internal (500), file.storeUnavailable or file.scanUnavailable (503: the document store or the virus scan does not answer for now, nothing was written or lost — retry after Retry-After), org.restoring (503 on a write: the data of the organization are being restored, a few minutes; reads go on, nothing was written — retry after Retry-After); writes add validation.invalid with fieldErrors and business codes such as ref.duplicateCode (422). Match on code, not on message: message is an English sentence that may be reworded.
While the service restarts, the proxy in front of it may answer 503 with Retry-After and a shorter body { "error": { "code": "service.unavailable", "message" } } (no requestId: the request never reached Vigilo); nothing was written, retry after Retry-After.
A refused write lists each field in fields: [{ field, code, message, allowed? }] (allowed: the accepted values of a list field), next to fieldErrors (field -> codes). A duplicate gives existingId, the id of the element that already holds the value: ref.duplicateCode (site code), ref.duplicateExternalRef and ref.duplicateRegistration (third party), obl.duplicate (requirement already followed). Read it with a GET, or PATCH it, instead of creating again.
Every answer carries an X-Request-Id header (also requestId in an error body); the audit events of a write carry the same requestId. Quote it when you contact the support.
Limit: 300 requests per minute per key by default; the limit that applies is in X-RateLimit-Limit. Responses carry X-RateLimit-Limit and X-RateLimit-Remaining; a 429 carries Retry-After (seconds). An address that presents more than 60 unknown, revoked or expired keys in a minute gets 429 for the rest of that minute; the keys it already used successfully in that minute go on, as far as the service can tell them from the flood: an integration that calls from an address of its own is never caught in another's.
Stability and versions
/api/v1 only gains things: new endpoints, new response fields, new optional parameters or body fields, new enum values in responses, new error codes. Nothing is removed, renamed or made stricter in v1. Ignore fields you do not know and treat an unknown enum value as "other".
Requests are strict: a body field the endpoint does not document is refused with 400 api.unknownField, which names it, so a misspelt field is never lost in silence.
A breaking change goes to /api/v2, with v1 kept alongside for at least 12 months. A deprecated operation is marked deprecated in the OpenAPI document and its responses carry the Deprecation and Sunset headers.
Webhook bodies carry version (1 today) next to type, timestamp and data; data follows the same rule.
Webhooks
Create endpoints in Settings > API, Webhooks section, or through the API (below): https only, public hosts, 25 per organization, one address once. Events: requirement.status_changed, submission.created, submission.reviewed, requirement.created, requirement.contested, requirement.message_received, relationship.status_changed, party.merged, party.compliance_changed, finding.created, finding.status_changed, organization.restored. The body is { type, version, timestamp, data }; data carries identifiers and statuses only: requirement.status_changed { requirementId, status, previousStatus, direction, isBlocked }, submission.created { submissionId, requirementId, direction }, submission.reviewed { submissionId, requirementId, decision: approved | rejected, direction }, requirement.created { requirementId, status, source, direction } (one per requirement: an assignment rule creates many at once, so it is not checked by default), requirement.contested { requirementId, direction } (the third party says the request does not concern it: reminders pause), requirement.message_received { requirementId, direction } (the third party wrote a message on its portal), relationship.status_changed { relationshipId, partyId, status, previousStatus } (upcoming, active, ended: a lease or contract starts or ends), party.merged { partyId, mergedPartyId } (two records of the same third party were merged: replace mergedPartyId, which no longer exists, with partyId), party.compliance_changed { partyId, status, previousStatus } (the third party's compliance changed: see below), finding.created { findingId, requirementId, severity, status, direction } and finding.status_changed { findingId, requirementId, severity, status, previousStatus, direction } (a finding of an inspection report was raised, taken in charge, resolved, verified, sent back or discarded, on the screen or through the API; a new assignee or due date is no event: read GET /api/v1/findings?updatedSince=), organization.restored { restoredTo, kind: restore | undo } (at an owner's request, the organization's data went back to a night of the back up: what was written since restoredTo is gone and the rows removed are seen by no updatedSince — synchronize everything again). requirement.status_changed is sent for every change of status, whoever makes it: a review, the daily recalculation, a waiver or its end, an exclusion (closed), a reinstatement, a new due date, an archive (closed). It is also sent when a finding, or a new version of the compliance type, starts or stops blocking the requirement: isBlocked changes, status and previousStatus can be equal; read status and isBlocked together (compliant and blocked is not compliant: bucket non_compliant in GET /api/v1/requirements). An endpoint keeps the events it subscribed: to receive an event added later (finding.created, finding.status_changed, organization.restored) on an existing endpoint, delete it and create it again (new signing secret). direction is inbound (evidence you collect) or outbound (a document you send to a customer). The schema of each body is under webhooks in the OpenAPI document. Read the requirement, site or third party through the API, and the document with GET /api/v1/submissions/{submissionId} (scope read:submissions).
Subscribe through the API (REST hooks, as Power Automate, Logic Apps, Zapier or Make do), with a key that has the write:webhooks scope: POST /api/v1/webhooks { url, events, description? } answers 201 with the endpoint, its signing secret (this once) and a Location header; DELETE that Location to unsubscribe (204). GET /api/v1/webhooks lists the key's own endpoints: a key never sees the endpoints created in the application or by another key. An endpoint subscribed by a key lives with it: revoking the key disables it at once, an expired key disables it at the next event (active: false); subscribe again with a new key. Subscribing an address this key already subscribed, or one held by an endpoint of a revoked or expired key, replaces that endpoint (new id and secret: a flow saved again, or moved to a new key once the old one is revoked, subscribes again); an address held by another endpoint answers 422 webhook.duplicateUrl. Admins see these endpoints in Settings > API with the name of the key.
Requests follow the Standard Webhooks scheme: headers webhook-id, webhook-timestamp, webhook-signature (HMAC-SHA256 of "id.timestamp.body" with the secret whsec_…, base64, prefixed v1,). Reply 2xx within 10 s; otherwise 8 attempts with growing delays, then the delivery is marked failed and can be replayed from the screen. Deliveries leave within the minute of the event; redirects are not followed (answer from the final address). An endpoint whose last 5 deliveries all failed is disabled, the owners and admins are told and the journal says why (webhook.disabled, reason failures); once the receiver is fixed, enable it again in the same section or with PATCH /api/v1/webhooks/{id} { "active": true } (same secret). The same PATCH with active: false pauses an endpoint of the key: the events of the pause are never sent later and the deliveries still waiting for a retry are marked failed; catch up with updatedSince. Deduplicate on webhook-id.
Renew the signing secret (it leaked, or your security policy asks for a rotation) without losing an event: « Renew the signing secret » in Settings > API, or POST /api/v1/webhooks/{id}/rotate-secret for an endpoint of the key (200, the endpoint with its new secret, this once). For 24 hours the previous secret still signs: webhook-signature then lists two signatures separated by a space, the new secret's first; accept the request when one of them matches (the example below does), and update your secret within that time. previousSecretExpiresAt says until when; {"keepPreviousSecret": false} (or unticking the box on the screen) stops the previous secret at once. A second renewal within the 24 hours drops the oldest secret.
curl (subscribe)
curl -X POST https://beta.185-211-6-64.sslip.io/api/v1/webhooks -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/vigilo", "events": ["requirement.status_changed"]}'Node.js (verify)
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, headers, rawBody) {
// Refuse a replayed request: the timestamp (seconds) must be within 5 minutes of now.
const sent = Number(headers["webhook-timestamp"]);
if (!Number.isInteger(sent) || Math.abs(Date.now() / 1000 - sent) > 300) return false;
const key = Buffer.from(secret.replace("whsec_", ""), "base64");
const signed = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;
const expected = "v1," + createHmac("sha256", key).update(signed).digest("base64");
return headers["webhook-signature"].split(" ").some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}Documents and their files
GET /api/v1/submissions and GET /api/v1/submissions/{id} (scope read:submissions) return the documents sent for the requirements: status (pending_review, in_review, approved, rejected, superseded by a later document, withdrawn), channel, who sent it (submittedBy: a member, a contact of the third party through its portal link, an API key, or support for Vigilo staff, without identity), the values entered with it (fieldValues, keys of the fields of complianceTypeVersionId), validFrom, validUntil, the review (reviewedAt, reviewedBy, rejectionReasonCode, rejectionComment as the third party reads it), isCurrent, and its files. With include=review (list or single document), the trace of an approval as the requirement page shows it: reviewComment, for a four-eyes review firstReviewedAt, firstReviewedBy and firstReviewComment, and checklist (the points of its version's checklist, each with checked) — your team's comments, never shown to the third party: do not pass them on. Without it these fields are null. Internal notes are never returned. Filters: requirementId, partyId (the third party that provides the evidence), status (comma-separated), updatedSince; documents of archived requirements with includeArchived=true.
Each file gives name, mimeType, sizeBytes, sha256 (the fingerprint recorded at upload) and contentUrl: GET it with the same key to receive the bytes (Repr-Digest carries the SHA-256, compare it to keep a probative copy). When Vigilo keeps a compressed copy instead of the uploaded bytes (30 days after the decision on the deposit at the earliest, at once for a document reusing a file already compressed: the review always sees the uploaded file; a photo turned into WebP, a PDF optimized; never a signed PDF nor a PDF/A), storedCopy gives its name, mimeType, sizeBytes and sha256: contentUrl serves that copy, and the fields above stay the uploaded file's; the document's updatedAt moves at that moment, so a sync by updatedSince sees it. An organization whose own archive needs the uploaded bytes switches on Settings > Workflow > « Keep files exactly as deposited » (its next files are never compressed). Every download is recorded in the audit journal (file.downloaded, by the key). A file whose content was erased (GDPR erasure, or the document withdrawn with eraseFiles) keeps its name and fingerprint, contentUrl is null and the address answers 410 api.fileErased.
The latest valid certificate of a supplier: GET /api/v1/requirements?partyId={id}, then lastApprovedSubmissionId of the requirement, then its document and files. To archive every approved document in a document management system: webhook submission.reviewed with decision approved, or a nightly GET /api/v1/submissions?status=approved&updatedSince=… .
Documents may hold personal data, health data included (compliance types with personalData health): give read:submissions only to a system allowed to keep them.
Python (approved documents of a supplier)
r = requests.get(f"{BASE}/api/v1/submissions", params={"partyId": party_id, "status": "approved"}, headers=H).json()
for doc in r["data"]:
for file in doc["files"]:
if file["contentUrl"]:
# the name comes from the third party: keep its extension only, never use it as a path
open(file["id"] + os.path.splitext(file["name"])[1], "wb").write(requests.get(file["contentUrl"], headers=H).content)Sending a document
With a key that has the write:submissions scope, send a document for a requirement in two calls, as the deposit screen does. First POST /api/v1/files?requirementId={id}&name=attestation.pdf with the file itself as the body (raw bytes, Content-Type application/octet-stream or the file's own; no multipart): 201 with its id, sha256 and expiresAt. The file is checked like an upload on the screen (the type read from the content must be one the requirement's type accepts and match the extension, size within the type's limit, a PDF not encrypted, no virus) and kept 24 hours for this key. Then POST /api/v1/submissions { requirementId, fileIds, fieldValues, notes }: 201 with the document, as GET /api/v1/submissions/{id} returns it, and a Location header.
fieldValues holds the values of the fields of the requirement's type (GET /api/v1/compliance-types/{complianceTypeId}, currentVersion.fields: key, type, required, options): dates as YYYY-MM-DD, numbers as numbers, an option by its code. A missing or invalid value answers 422 validation.invalid with fieldErrors["fieldValues.<key>"]; the files stay uploaded: correct and send again with the same fileIds (do not upload them again: 20 files at most wait for a requirement; DELETE /api/v1/files/{id} removes a wrong one). The same call repeated within 10 minutes (e.g. after a timeout) answers the first document instead of a second one; the same files already sent with other values answer 409 api.alreadySent with the first document in existingId (upload the files again to send a corrected document).
The document waits for a member's review (status pending_review): the requirement's owner is told, the automatic reading proposes the values it finds, and reminders to the third party stop as for any document received. It is approved at once only when the type is approved automatically and nothing needs a human. A document waiting and not held by a reviewer is replaced by the new one; held: 409 sub.pendingReserved. With four eyes on (Settings > Review), the member who created the key does not approve what it sends, and a type approved automatically waits for another member. The key sends; it never approves.
To load the certificates you already hold: GET /api/v1/requirements?partyId={id}&status=pending (or by complianceTypeCode through GET /api/v1/compliance-types), then one upload and one POST per document. 300 requests per minute per key: pace a large load.
curl
FILE=$(curl -s -X POST "https://beta.185-211-6-64.sslip.io/api/v1/files?requirementId=$REQ&name=attestation.pdf" -H "Authorization: Bearer $VIGILO_KEY" \
-H "Content-Type: application/octet-stream" --data-binary @attestation.pdf | jq -r .id)
curl -X POST https://beta.185-211-6-64.sslip.io/api/v1/submissions -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" \
-d "{\"requirementId\": \"$REQ\", \"fileIds\": [\"$FILE\"], \"fieldValues\": {\"valid_until\": \"2027-03-31\"}}"Python
with open("attestation.pdf", "rb") as f:
up = requests.post(f"{BASE}/api/v1/files", params={"requirementId": req_id, "name": "attestation.pdf"}, data=f, headers=H).json()
doc = requests.post(f"{BASE}/api/v1/submissions", json={"requirementId": req_id, "fileIds": [up["id"]], "fieldValues": {"valid_until": "2027-03-31"}}, headers=H)
if doc.status_code == 422: print(doc.json()["error"]["fields"]) # what to correctApproving or rejecting a document
When the review happens in your own tool (a GRC or ticketing workflow, your broker's platform), a key with the write:reviews scope records the decision: POST /api/v1/submissions/{id}/approve or /reject. The key decides in the name of the member who created it, with their rights: their role (capped at manager) and their scope — a document outside it answers 404. Once that person may no longer review (viewer, removed, blocked), the key answers 403 auth.keyCreatorCannotReview: create a new key. Prefer a key created by the person who answers for the decisions.
Read the documents to decide with a key that also has read:submissions (GET /api/v1/submissions?status=pending_review,in_review&include=review: in_review is a document a member has open, or the second approval of a four-eyes review) and send its lockVersion with the decision: if a member opened or decided it meanwhile, 409 sub.concurrentUpdate (read it again) or sub.reservedByOther (a member has it open: try again after the Retry-After seconds, the end of the member's reservation); already decided, 409 sub.notReviewable. The key never takes a document over from a member. Send an Idempotency-Key with each decision: a call repeated after a lost answer then returns the first answer instead of 409.
approve: fieldValues holds your corrections only (same formats as POST /submissions; the other values stay as sent), checklist the items of the document's version ticked (a blocking item unchecked: 422 sub.checklistIncomplete), comment an internal note, required to approve a document already expired (422 sub.commentRequired). An unknown field or checklist key answers 422 validation.invalid, with api.unknownField under fieldErrors.fieldValues.<key> or fieldErrors.checklist.<key>. reject: reasonCode (unreadable, expired, wrong_document…, see the schema), comment shown to the third party (required with other), internalNote kept for you. The third party receives the reason by email with a link to send a new document, as from the screen; the webhooks submission.reviewed and requirement.status_changed follow. A decision taken by mistake is undone with POST /submissions/{id}/return-to-review (a rejection: the document waits for a decision again) or /revoke (an approval, with a reason; the creator must be owner or admin, else 403 auth.keyCreatorNotAdmin).
Four eyes stay: for a type reviewed by two people (reviewMode double), the first approval leaves the document in_review until another member approves it, and the key counts as its creator — never twice (409 sub.sameReviewer). With the rule « whoever submits for a party does not approve », a document the creator sent for the third party, or sent by a key or a portal link of theirs, cannot be approved by this key (409 sub.ownDeposit, sub.ownKey, sub.ownLink). A document whose requirement was closed since it was sent: 422 sub.requirementClosed. Documents with health data are decided in Vigilo only (403 auth.healthDataOnScreen). The audit journal names the key, the address of the call and the member it decided for (onBehalfOfUserId); the document names that member as reviewer.
The same key decides on findings: POST /api/v1/findings/{id}/decide with action verify (never by whoever resolved it, nor by a key of theirs: 422 fnd.selfVerification), reject (the resolution sent back), wont_fix (accepted without correction: owner or admin, a reason of 10 characters at least) or reopen (a verified or accepted finding open again, with a reason). Raising, taking in charge and resolving stay with write:requirements (PATCH /findings/{id}).
The same key decides on waiver requests, when the organization requires a second approval of waivers: POST /api/v1/requirements/{id}/decide-waiver with action approve (the requirement is waived as asked; an end date already past: 422 obl.waiverEndPast) or refuse with a reason of 10 characters or more (told to the requester), and requestedAt = waiverRequest.requestedAt as read (required: the decision applies to that request only; replaced or withdrawn since, 409 obl.noWaiverRequest). Keys with write:reviews created before 20 October 2026 decide on waiver requests too. The creator must be owner or administrator (403 auth.keyCreatorNotAdmin) and not the one who asked — for a request sent by a key, its creator (409 obl.ownWaiverRequest).
curl
DOC=$(curl -s "https://beta.185-211-6-64.sslip.io/api/v1/submissions/$ID" -H "Authorization: Bearer $VIGILO_KEY")
curl -X POST https://beta.185-211-6-64.sslip.io/api/v1/submissions/$ID/approve -H "Authorization: Bearer $VIGILO_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: approve-$ID" \
-d "$(echo "$DOC" | jq '{lockVersion, comment: "Checked against the insurer register"}')"Python
doc = requests.get(f"{BASE}/api/v1/submissions/{doc_id}", headers=H).json()
r = requests.post(f"{BASE}/api/v1/submissions/{doc_id}/reject", headers={**H, "Idempotency-Key": f"reject-{doc_id}"}, json={"lockVersion": doc["lockVersion"], "reasonCode": "expired", "comment": "The certificate ended on 31 March."})
if r.status_code == 409: ... # changed or open meanwhile: read it againHold a supplier's payment in your ERP
Every third party carries compliance: { status, since, applicable, compliant, nonCompliant, pending }. status is non_compliant (at least one requirement overdue, expired or blocked by a finding), pending (none of those, at least one awaiting its document or its review), compliant (every requirement in good standing) or none (nothing to check). It counts the inbound requirements whose subject is the third party, as the dashboard does (archived ones left out, waived ones as the organization setting says), and follows a change within a minute. since is when the status last changed. An archived third party, or the duplicate of a merge, moves to none: its requirements are closed.
Keep your ERP in step in either way. Webhook party.compliance_changed { partyId, status, previousStatus }: sent once per change, never twice for the same status; on non_compliant set the payment block (SAP S/4HANA: the supplier's PaymentBlockingReason; Coupa: supplier on hold; Sage Intacct: ONHOLD), on compliant lift it. Or a nightly job: GET /api/v1/parties?complianceStatus=non_compliant, every page, matched to your suppliers by externalRef (your supplier number) or registrationNumber; block these, unblock the others. Every change since a date: GET /api/v1/audit-events?action=party.compliance_changed.
Vigilo never blocks a payment itself: the rule is yours (hold non_compliant only, or pending too for a new supplier). Why a supplier is not in good standing: GET /api/v1/requirements?partyId={id}&bucket=non_compliant.
Each party also carries registry: the last answer of the official registers about its numbers — Sirene for a French SIREN / SIRET (active, ceased, establishment_closed, not_found), VIES for an EU VAT number (valid, invalid) — asked by a member or by the organization's periodic check (Settings > Third parties, every 6 months by default; PATCH /api/v1/settings { partyIdentifiers: { registryRecheckMonths } }). A supplier ceased at Sirene is closed whatever its documents: hold it too. A changed answer (a company ceased, a VAT number no longer confirmed) moves the party's updatedAt, so a nightly GET /api/v1/parties?updatedSince= sees it; the same answer dated again does not. Every check since a date: GET /api/v1/audit-events?action=party.registry_checked&since=.
A company may correct its legal name, identifiers, phone or address from its portal (« Your company » › « Suggest a correction »): the party is never written by the portal. The proposal waits on the party as identityProposal { id, proposedAt, contactId, proposed } (proposed: the value of each field that still differs, null = cleared); GET /api/v1/parties?identityProposal=true lists the parties awaiting a decision (a proposal does not move updatedAt). If your ERP holds the supplier master data, decide there: POST /api/v1/parties/{id}/decide-identity with {"action": "apply", "proposalId": …, "fields": ["city", "postalCode"]} takes the fields you keep (all of them without fields) through the same checks as PATCH (a registration number another party holds is refused); {"action": "dismiss", "proposalId": …} leaves the party as it is. The supplier sees the decision on its portal for 30 days. A new proposal replaces the pending one: an old proposalId answers 409 ref.identityProposalStale. A PATCH that gives the party every proposed value closes the proposal too.
Other ERPs and purchasing suites (Oracle, Ivalua, Workday, Sage 100 or X3…): the same two ways, applied to the supplier's payment or purchase hold of your tool, with the supplier number kept in externalRef. These recipes are indicative: Vigilo ships no connector in an ERP's catalogue, and the name of the hold differs from one version to the next; your integrator checks it on your instance.
Python (nightly)
held, after = set(), None
while True:
r = requests.get(f"{BASE}/api/v1/parties", params={"complianceStatus": "non_compliant", "limit": 200, **({"after": after} if after else {})}, headers=H).json()
held |= {p["externalRef"] for p in r["data"] if p["externalRef"]}
after = r["nextCursor"]
if not after: break
# block the suppliers in held, unblock the othersSites and equipment from your maintenance or property tool
Decide first which tool holds what. Your maintenance (CMMS) or property tool stays the source of the sites, units and equipment (names, addresses, references); Vigilo holds the compliance (requirements, documents, findings). Never edit on both sides: a site renamed in Vigilo is renamed again at the next sync. The keys are yours: the site's code, the unit's reference in its site, the equipment's reference (your tool's identifier), the third party's externalRef.
Nightly sync, for each site of your tool: GET /api/v1/sites?code={code}; none: POST /api/v1/sites with an Idempotency-Key built from your identifier (a retry after a lost answer creates nothing twice); found: PATCH /api/v1/sites/{id} with the fields that differ. Then each equipment: GET /api/v1/assets?siteId={id}&reference={reference}, POST or PATCH the same way. Gone from your tool: DELETE archives it (its requirements are closed and stop reminding); an archived site keeps its code and is restored on the screen, never created again. First load: the starter workbook (above), then the API for the changes.
Back to your tool: subscribe to requirement.status_changed (a check that becomes overdue or expired) and to finding.created (a reservation of an inspection report: open a work order, see Findings above), or read GET /api/v1/requirements?assetId={id}&bucket=non_compliant for the equipment page of your tool. Indicative recipe: Vigilo ships no connector of Maximo, Planon, Archibus or another tool; the fields to map are your integrator's.
Python (one site of your tool)
found = requests.get(f"{BASE}/api/v1/sites", params={"code": site["code"]}, headers=H).json()["data"]
body = {"name": site["name"], "code": site["code"], "city": site["city"], "country": "FR"}
if found:
requests.patch(f"{BASE}/api/v1/sites/{found[0]['id']}", json=body, headers={**H, "Idempotency-Key": f"site-{site['code']}-{run_id}"})
else:
requests.post(f"{BASE}/api/v1/sites", json=body, headers={**H, "Idempotency-Key": f"site-{site['code']}"})Statuses in your data warehouse
Changes only, every night: for each list your reports need (requirements, parties, sites, submissions, findings, relationships), GET it with updatedSince = the start of the previous run minus a few minutes and includeArchived=true, every page, and upsert by id (the same row may come twice: it is the same id). An archived row comes back with its archivedAt; nothing is deleted, except the personal data of a third party erased at its request (the row stays, emptied, with erasedAt: erase it in your copy too, GDPR art. 17 and 19). The requirement carries what most dashboards need: status, dueDate, validUntil, the subject, the site, the owner, and compliance on each third party.
A complete snapshot: the full export (Exporting everything, above: every table as JSON lines with its manifest), at most every 12 hours by a key; load it into an empty schema, then compare its counts with manifest.json. History of the changes, for an audit trail in the warehouse: GET /api/v1/audit-events (read:audit).
Reports scheduled by e-mail (Reports › Schedule) deliver a CSV or Excel file to a mailbox: fine for a person, not for a warehouse (a file travels by e-mail, and its columns follow the screen). Indicative recipe: no push to a storage bucket or a warehouse is built in.
Python (requirements changed since the last run)
since, after = last_run_started - timedelta(minutes=5), None
while True:
params = {"updatedSince": since.isoformat(), "includeArchived": "true", "limit": 200, **({"after": after} if after else {})}
page = requests.get(f"{BASE}/api/v1/requirements", params=params, headers=H).json()
upsert("requirements", page["data"]) # by id
after = page["nextCursor"]
if not after: breakWas it compliant on a past date?
After a claim or for an audit: GET /api/v1/parties/{id}/compliance-at-date?date=2026-03-12 (or /api/v1/sites/{id}/compliance-at-date) returns compliance { status, applicable, compliant, nonCompliant, pending } as it stood then, and one line per requirement followed at that moment: status (rebuilt from the audit trail), statusSince, currentStatus, and evidence, the approved document whose validity contains the date (approvedAfterDate true: approved later, e.g. a certificate valid since the 1st handed in on the 25th).
Without time, the end of that day in each requirement's time zone; time=14:30 for a precise moment (an accident at 14:30); today, or no date at all, means now (a time needs its date). Requirements created after the date are only counted (addedLater). A date in the future is refused (validation.dateInFuture), one older than 60 months too (aud.atDateTooOld: the audit trail keeps business events five years). Scope read:requirements; ids only, no names.
curl
curl -H "Authorization: Bearer $VIGILO_KEY" "https://beta.185-211-6-64.sslip.io/api/v1/parties/$PARTY_ID/compliance-at-date?date=2026-03-12&time=14:30"
Power Automate, Power Apps and Logic Apps
Custom connector, no code: in Power Automate (or Power Apps, Logic Apps), Custom connectors > New custom connector > Import from URL, paste the address below (OpenAPI 2.0, the only version these tools import), then Create connector. On the first connection, paste an API key created in Settings > API, with or without "Bearer ".
Actions: list, get, create and update portfolios, sites, units, equipment, third parties, relationships and requirements; list compliance types; list and get submitted documents and the content of their files; request and download the full export; send a document (Upload a file with the file content of a previous step, then Send a document with its id). The portfolio, site, third party and compliance type fields offer a list read from Vigilo (the first 50; Enter custom value beyond). Give the key only the scopes the flows need: a missing scope answers 403 with its name.
Trigger "When an event occurs": choose the events (a document submitted, a status that changes…); the flow subscribes when it is saved and unsubscribes when it is turned off or deleted. The trigger needs the write:webhooks scope; the endpoint lives with the key (a revoked key stops the flow's events) and is listed in Settings > API with the key's name. To move a connection to a new key: revoke the old key, update the connection, then save the flow again.
Example: when a requirement becomes expired, post in a Teams channel. Trigger "When an event occurs" with requirement.status_changed; Condition data.status is equal to expired; Get a requirement with data.requirementId; post the site and the due date.
Example: file every approved document in SharePoint. Trigger "When an event occurs" with submission.reviewed; Condition data.decision is equal to approved; Get a submitted document with data.submissionId; Apply to each files: Get the content of a file (id = data.submissionId, fileId = the file id), then SharePoint Create file with the file name and that content. The key needs read:submissions and write:webhooks.
Import from URL
https://beta.185-211-6-64.sslip.io/api/v1/swagger.json
Machine-readable spec
OpenAPI 3.1: /api/v1/openapi.json. OpenAPI 2.0 for Power Platform connectors: /api/v1/swagger.json. Every operation has an operationId (ListSites, CreateRequirement…). For an AI assistant: /llms.txt (this guide as plain text).