Partner API (for audit firms)
A firm-level API for auditors to pull the engagements they have been granted and export each one into their own system.
The Partner API lets an audit firm's own systems pull the engagements it has been granted and export each one, without a per-client credential or an interactive session. It is separate from the tenant API: a tenant key belongs to one client; a firm token belongs to your firm and spans every client that has granted your firm a live engagement.
Like the rest of the v1 API, this is a contract: additive changes only.
Authentication
Send your firm token as a bearer token:
Authorization: Bearer jpa_...Base URL: https://api.joopler.com.
Your Joopler contact issues the token, and you can view and copy it any time from your partner portal (it is stored encrypted and shown only to your signed-in firm). The token is:
- Never touches the client's program. It cannot change a client's controls, policies, or evidence. The only writes are your own engagement actions - accept, raise a request, decide a fulfillment, message the client, ask the client for more time or a colleague, and staff an engagement the client has asked you to staff - the same set the interactive portal exposes. (Approving those asks is the client's action, never the token's.)
- Staffing is the one write that creates access, and it is bounded twice: the client must have engaged your firm, and the person you assign must already be on your firm's roster and on your own domain. Your firm decides who; the client decides how long and how much.
- Scoped to your live engagements. Every request against a client is authorized against a live, non-revoked, non-expired grant from that client to your firm. Revoke or expiry takes effect immediately. The one exception is the staffing routes below, where no grant exists yet by definition - those are authorized by the client's own request that your firm staff the engagement, and are scoped to your firm's domain.
- Firm-wide. One token reaches all your current engagements.
- Revocable by you, from your partner portal, without asking us. A firm token spans every one of your clients, so if it leaks you should not have to wait on a vendor's support queue to kill it.
The API is full parity with the interactive portal, so a firm can run entirely on the API, entirely in the portal, or mix the two.
Get staffed on a new engagement
A client engages your firm, not an individual, and then waits for you to say who will do the work. Until you assign somebody, nobody at your firm can see anything - the request carries no access at all.
Start with the demo workspace
Your firm starts with one engagement already waiting: the Joopler demo
workspace, a fully populated compliance programme to explore before a real client
arrives. It appears in GET /v1/partner-api/staffing like any other request, and
you take it the same way - assign one of your auditors, accept, then read it end to
end. That is deliberate: the demo is not a special mode, so whatever you build
against it works unchanged on your first real client. Decline it if you would
rather not have it, and it will not come back.
GET /v1/partner-api/staffing{
"firm": "yourfirm.com",
"count": 1,
"awaitingStaffing": [
{
"id": "0f7c...",
"tenant": "acme",
"framework": "SOC 2",
"accessWouldEndAt": "2027-03-01T00:00:00.000Z",
"requestedAt": "2026-08-31T18:40:11.000Z",
"note": null,
"staffAt": "https://app.joopler.com/partners/portal"
}
]
}This is deliberately separate from GET /v1/partner-api/engagements?status=pending,
which means something different: there, "pending" is a grant that exists but your
auditor has not accepted yet. Here, nobody is assigned at all. Only one of the two is
waiting on your firm to choose a person.
Assign one of your own people:
POST /v1/partner-api/staffing/{id}/staff
{ "email": "senior@yourfirm.com" }That creates their access on the client's workspace, emails them, and tells the client
who you assigned. accessWouldEndAt above is the expiry the client set - assigning
does not let you change it.
The assignee must already be one of your firm users and on your firm's domain; add them
to your firm roster first if they are missing. A 400 means exactly that.
Or turn it down, so the client is not left waiting:
POST /v1/partner-api/staffing/{id}/decline
{ "reason": "Independence conflict" }Both are idempotent-safe by returning 409 if the engagement was already staffed or
declined - including by a colleague in the portal a moment earlier, so two people
acting at once cannot put two auditors on one client.
List your engagements
GET /v1/partner-api/engagements
GET /v1/partner-api/engagements?status=pending # awaiting your acceptance
GET /v1/partner-api/engagements?status=active # already acceptedReturns every client that has granted your firm a live engagement, each with its
lifecycle status (pending = invited but not yet accepted by your firm;
active = accepted), an exportUrl, and - while pending - an acceptUrl. The
response also carries counts: { pending, active } so you can poll "what is
waiting on us" without filtering client-side.
Export an engagement
GET /v1/partner-api/engagements/{tenant}/exportOne importable document with everything an auditor works from: controls and their
status/findings/attestations, policies and sign-offs, the evidence index, access
reviews, the workforce roster, vendors/subprocessors, risks, training coverage,
trust documents, the system description, audit windows, and
NIST OSCAL System Security Plan + Assessment
Results your system can ingest directly. Evidence is scoped to your firm's audit
window(s), and exercises lists what was actually performed.
Scope is your FIRM's, not one auditor's. This token is firm-level, so the clip is
the union of every live grant held at your firm's email domain for that client: if
a colleague is scoped to Q2 and you are scoped to Q1, the token returns both. If
any live grant at your domain has no window at all, the token returns the whole
ledger and no clipping is applied. Read engagement.evidenceScope to see what was
actually applied rather than assuming your own grant's dates.
The shape (abridged):
{
"engagement": { "tenant", "exportedAt", "frameworks": ["SOC2"], "systemDescription": {…}, "auditWindows": [ { "name", "framework", "startsAt", "endsAt" } ], "evidenceScope": { "windowed": true, "windows": [ { "startsAt", "endsAt" } ] } },
"controls": [ { "key", "title", "status": "PASS|FAIL|PENDING|ERROR", "applicability": "applicable|not_applicable", "findings": [ … ], "exceptedFindings": [ … ], "attested", "attestedBy", "attestEvidenceSeq", "naRationale", "naEvidenceSeq" } ],
"policies": [ { "key", "title", "status": "approved|seeded", "owner", "recurrence", "approvedAt", "nextReviewAt", "docDrift" } ],
"evidence": [ { "seq", "evidenceId", "payloadHash", "recordHash", "createdAt", "signed", "timestamped" } ],
"accessReviews": [ … ], "people": [ … ], "vendors": [ … ], "risks": [ … ], "training": { … },
"exercises": [ { "kind", "title", "control", "performedBy", "performedAt", "completedAt", "completionSignedAt", "completionSignedRetrospectively", "voided", "voidReason", "artifacts": [ { "name", "sha256", "size" } ], "findingsOpen", "findingsClosed" } ],
"trustDocuments": [ { "id", "docType": "soc2|pentest|baa|dpa", "fileName", … } ],
"oscal": { "ssp": { "system-security-plan": { … } }, "assessmentResults": { "assessment-results": { … } } }
}A control's status is the live assessed result (PASS / FAIL / PENDING / ERROR)
with a separate applicability; the OSCAL Assessment Results normalize applicable
controls to satisfied / not-satisfied and drop not-applicable ones, so a signed
exclusion is never reported as a gap.
Read exceptedFindings before you rely on a PASS. When it is present, the
control reads PASS because every outstanding finding is covered by a live, scoped
exception somebody accepted - not because the checks passed. Each entry is a
finding that was excused. The exception itself is signed into the evidence ledger
as a control-exception: record. The field is absent when a control passes on its
own evidence, so its presence is the signal.
exercises is what was actually performed: penetration tests, disaster-recovery
tests, continuity exercises, internal audits, scans. artifacts[].sha256 is the
hash of the report, which is how you tie an exercise to the signed ledger record
holding the same bytes. completionSignedAt: null means the completion is not in
the chain at all - the report may be signed while the assertion that the exercise
was performed is not - and completionSignedRetrospectively: true means the
signature was applied after the fact and attests to a stored record rather than
witnessing the event. Voided records are included and flagged rather than
filtered, because a withdrawn record is a fact about the register.
The status, exceptedFindings, policy and acceptance figures are CURRENT state.
The audit window clips the evidence ledger; it does not reconstruct posture as of a
past date. engagement.evidenceScope tells you what was actually clipped.
Granular reads and downloads
Pull a single slice instead of the whole bundle, or download an artifact:
GET /v1/partner-api/engagements/{tenant}/oscal/ssp
GET /v1/partner-api/engagements/{tenant}/oscal/assessment-results
GET /v1/partner-api/engagements/{tenant}/controls
GET /v1/partner-api/engagements/{tenant}/policies
GET /v1/partner-api/engagements/{tenant}/company-profile
GET /v1/partner-api/engagements/{tenant}/people
GET /v1/partner-api/engagements/{tenant}/access-reviews
GET /v1/partner-api/engagements/{tenant}/vendors
GET /v1/partner-api/engagements/{tenant}/risks
GET /v1/partner-api/engagements/{tenant}/training
GET /v1/partner-api/engagements/{tenant}/audit-windows
GET /v1/partner-api/engagements/{tenant}/trust-documents
GET /v1/partner-api/engagements/{tenant}/evidence
GET /v1/partner-api/engagements/{tenant}/evidence/{seq}/bundle
GET /v1/partner-api/engagements/{tenant}/evidence/{seq}/document
GET /v1/partner-api/engagements/{tenant}/trust-documents/{id}/url
GET /v1/partner-api/engagements/{tenant}/vendors/{vendorId}/documents/{docId}/urlThe standalone evidence index returns records, not evidence:
{
"records": [ { "seq", "evidenceId", "payloadHash", "recordHash", "createdAt", "signed", "timestamped" } ],
"windowed": true,
"windows": [ { "startsAt", "endsAt" } ],
"truncated": false,
"total": 1329
}The key differs from the evidence key inside the full export bundle, which is a
wart we are keeping rather than breaking anyone's client over. Two things to code
against:
truncated: truemeans more records exist than were returned. Page or narrow the window; do not treat the array as the whole chain.windowUnavailable: truemeans a windowed read could not be served.recordsis EMPTY and that emptiness means "not retrieved", never "nothing happened in your window". We refuse rather than hand you a filtered page and call it the window, because an auditor cannot tell a quiet period from a failed query.
However a customer added a document, uploaded, or linked from Google Drive, Joopler binds the actual bytes into the customer's immutable evidence vault and signs them, so you download the real document, not a link you cannot open.
Verify evidence yourself
Every evidence record is hash-chained and KMS-signed, so you do not have to take our word for it:
GET /v1/partner-api/engagements/{tenant}/evidence/{seq}/verifyreturns our own check plus everything to verify independently, offline: the
signature over the payload digest, the hash-chain links, and the tenant's public
key (also served, unauthenticated, at GET /v1/public/signing-key/{tenant}). Verify
the ECDSA (P-256 / SHA-256) signature against the public key, and recompute
recordHash = SHA-256(prevHash || payloadHash) to walk the chain.
Run the engagement (accept, requests, messages)
Beyond pulling data, the firm token can drive the engagement itself - the same actions the interactive auditor portal offers. These are the only writes it can make, and each is scoped to an engagement your firm actually holds.
Accept an engagement (the bilateral step the client sees). Optional
independenceAttested; optional auditorEmail only when your firm holds several
grants on one client. Accepting fires your engagement webhook and notifies the
client.
POST /v1/partner-api/engagements/{tenant}/accept
{ "independenceAttested": true }Evidence requests (PBC). Raise a request, watch the client fulfill it, then accept or reject it.
GET /v1/partner-api/engagements/{tenant}/requests
POST /v1/partner-api/engagements/{tenant}/requests
{ "title": "Q2 access-review evidence", "kind": "evidence" } // or "sample" with "sampleSize"
POST /v1/partner-api/engagements/{tenant}/requests/{id}/decide
{ "decision": "accepted" } // or "rejected"A fulfilled request carries attachments. Two kinds matter and they are not
equivalent:
{ "kind": "evidence", "name": "Access-review evidence - 2026-Q2",
"seq": 1204, "sha256": "…", "evidenceId": "access-review:2026-Q2" }
{ "kind": "link", "name": "Signed MSA", "url": "https://…" }kind: "evidence" is bound to a record in the client's ledger. The sha256 is
the payload digest Joopler holds, resolved server-side when the client attached
it - never a value the client supplied - and the attach is refused unless the
artifact still resolves, so a binding you receive is one we can still produce.
Pull it with
GET /v1/partner-api/engagements/{tenant}/evidence/{seq}/bundle and verify it
like any other record.
kind: "link" is an external URL. It is a document outside the chain, we make no
claim about its contents, and it can change after the client attached it. Treat
the two differently in your workpapers: only the first is evidence we vouch for.
Messages. The bi-directional client thread; posting emails the client.
GET /v1/partner-api/engagements/{tenant}/messages
POST /v1/partner-api/engagements/{tenant}/messages
{ "body": "Please share the Q2 access-review export." }Engagement requests. Ask the client to extend your access (more time) or add a colleague auditor. The client approves or declines in their app; deciding is the client's action, so it is deliberately not on your token.
GET /v1/partner-api/engagements/{tenant}/engagement-requests
POST /v1/partner-api/engagements/{tenant}/engagement-requests
{ "type": "window_change", "endsAt": "2026-09-30", "reason": "audit running long" }
{ "type": "add_auditor", "email": "colleague@yourfirm.com" }window_change extends your access END date (a forward-looking time-box). It is
independent of the audit window, which is the observation period being reviewed and is
often in the past - so access is never gated by it.
Requests and messages are attributed to your firm; pass a firm-domain
auditorEmail in the body to attribute a specific auditor, otherwise they record
as auditor@yourfirm.com.
Webhooks (optional)
If your firm runs its own systems, you can have Joopler push engagement events to you instead of polling. Register one HTTPS endpoint in your partner portal (and rotate it there whenever you need to); Joopler stores it encrypted, one endpoint per firm.
Joopler POSTs an event to that endpoint when an engagement changes. Delivery runs on a daily sweep rather than instantly, so treat a webhook as a reliable catch-up rather than a real-time trigger; the person it concerns is emailed immediately.
engagement.requested- a client has engaged your firm and is waiting for you to staff it. This is the one that starts the relationship: the payload carries the engagement id, framework and any note, andPOST /staffing/{id}/staffis the action it is asking for. Nobody is assigned yet by definition, soengagement.auditorEmailisnullon this event and onlyfirmDomainidentifies the recipient.engagement.invited- a client invited a named auditor at your firm.engagement.accepted- your firm accepted an engagement (access is now active).engagement.revoked- an engagement was revoked, or reached its expiry. An expiry carriesreason: "expired"in the payload so you can tell the two apart.
The payload is engagement metadata plus links back into the Partner API to pull the full data - never raw evidence in the hook itself. So a webhook is a signal to go fetch (with your firm token), not a data-exfiltration channel.
Verify authenticity on every delivery. Joopler signs the request body with an
HMAC and sends it in the X-Joopler-Signature header as sha256=<hex>. Recompute
the HMAC-SHA256 of the raw body with your firm's signing secret and compare, and
reject any delivery whose signature does not match.
When a delivery fails
A failed delivery is retried on each following sweep up to ten times, then given up on. Dropped events are listed in your partner portal next to the webhook, and over the API:
GET /v1/partner-api/webhook/dropped{
"firm": "yourfirm.com",
"count": 1,
"dropped": [
{ "id": "9c2a...", "event": "engagement.requested", "attempts": 10, "lastError": "timeout", "occurredAt": "2026-09-28T08:02:00.000Z" }
],
"catchUp": ["/v1/partner-api/engagements", "/v1/partner-api/staffing"]
}So an endpoint that was down for a stretch does not turn into unexplained silence.
Nothing is lost either way: the webhook is a nudge, and the current state is always
available from GET /v1/partner-api/engagements and GET /v1/partner-api/staffing.
Polling those two on a schedule is a perfectly good integration on its own.