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 at any time.
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.
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).
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": [ … ], "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": { … },
"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.
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}/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}/urlHowever 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"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 then POSTs an event to that endpoint whenever an engagement changes:
engagement.invited- a client invited your firm to an engagement.engagement.accepted- your firm accepted an engagement (access is now active).engagement.revoked- an engagement was revoked or expired.
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.