Introduction
The PaperOS Developer API lets your app read and write the data in a PaperOS organization (a “workspace”): pull reports such as capital statements and investor lists, submit forms such as statements built from bank or accounting exports, add and update records, and keep your own database in sync.
This quickstart covers the common jobs. The full API reference documents everything else (OIDC clients, workflows, and more).
What counts as the Developer API
| Path | What it is |
|---|---|
/api/v1/* | The Developer API (this site). Stable, documented, use it. |
/api/public/* | Unauthenticated app routes used by the PaperOS UI. Not for you. |
/api/* | Internal app routes used by the PaperOS UI. Undocumented, changes. |
Only call /api/v1/*. If you need something that only exists on an internal
route, ask us to add it to the Developer API.
How a typical integration looks
- Your app is deployed on the PaperOS deploy platform, behind PaperOS SSO.
- A signed-in user loads a page; the SSO gate forwards their OAuth access token to your backend in a request header.
- Your backend sends that token to a Developer API URL naming the user's selected PaperOS workspace. PaperOS checks their access and obtains an account-scoped token internally.
- The API returns the requested data, such as reports. Your backend returns only the data your UI needs and, if needed, stores it in your database.
The browser never sees the token, and never calls PaperOS directly.
Base URLs
Pick a base URL
# Staging (prototypes, test data)
export PAPEROS_BASE_URL='https://staging.paperos.dev'
# Production
# export PAPEROS_BASE_URL='https://app.paperos.com'
// Staging (prototypes, test data): https://staging.paperos.dev
// Production: https://app.paperos.com
var paperBase = process.env.PAPEROS_BASE_URL;
| Environment | Base URL |
|---|---|
| Staging | https://staging.paperos.dev |
| Production | https://app.paperos.com (or your organization's branded domain) |
Use staging while you build. Data on staging is test data. Keep the base URL in an environment variable so switching to production is a config change, not a code change.
Some organizations also have a sandbox, at an address like
https://demo.example.c.paperos.net. Use one only if PaperOS has given it to you.
Authentication
Every request
Authorization: Bearer <token>
In your backend, the token arrives on each request from the SSO gate:
// Express
var token = req.get("X-Auth-Request-Access-Token");
var resp = await fetch(`${paperBase}/api/v1/orgs?updated_since=0`, {
headers: { Authorization: `Bearer ${token}` },
});
# For manual testing from a terminal, export a short-lived token you
# obtained yourself. Never commit it, log it, or paste it into chat.
export PAPEROS_TOKEN='xxxx.yyyy.zzzz'
curl "${PAPEROS_BASE_URL}/api/v1/orgs?updated_since=0" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
When your app runs behind PaperOS SSO on the deploy platform, the SSO gate
forwards the signed-in user's OAuth access token to your app backend in the
X-Auth-Request-Access-Token request header. Read it, and pass it on as the
Bearer token. This identifies the user; it does not select a PaperOS workspace.
The gate does not forward the ID token or refresh token to your app. Your backend does not need to convert the OAuth token into an ID token before using the API.
Which token is which?
| Token | What it represents | Default lifetime |
|---|---|---|
| OAuth access token | The signed-in user in the OAuth flow. This is what the SSO gate forwards. | 1 hour |
| PaperOS ID token | The user's identity, without choosing an account. Also accepted as a user token by these APIs. | 24 hours |
| PaperOS access token without an account | A user token returned by the internal exchange when no account is selected. It does not grant extra workspace access compared with the ID token. | 1 hour |
| Account-scoped PaperOS access token | A token containing a selected account after membership is checked. | 1 hour |
The response field name access_token alone does not tell you which of these
flows issued it. The token without an account still needs account scoping to
operate on workspace data, just like the ID token; the data API can perform that
step internally. None of these tokens grants membership the user does not have.
A refresh token renews the OAuth session. It is not an API bearer token and is not forwarded to your app. The client-ID/client-secret authentication in the full reference is a separate integration model, not a prerequisite for using the SSO gate's token.
Selecting a workspace
Use the original user-level token from the gate and put the selected workspace
in /api/v1/orgs/{org_id}/.... PaperOS checks membership and exchanges to an
account-scoped token internally before handling the request. You do not need to
make that exchange yourself. A PaperOS ID token follows the same user-level path.
Use a public org ID (org_...) returned by GET /api/v1/orgs. Here
org, workspace, and account refer to the PaperOS data context, not a
GitHub organization. account_id is its internal numeric ID. Numeric IDs are
accepted by some routes, but not consistently across the API.
Data requests return data; token requests return tokens
| Request | Response |
|---|---|
GET /api/v1/orgs/{org_id}/reports | The workspace's available reports. |
GET /api/v1/orgs/{org_id}/reports/{report} | The requested report's data. |
POST /api/v1/orgs/{org_id}/access-token | An account-scoped access token and metadata, not report data or an ID token. |
Putting an org ID in a reports URL does not turn the response into a token. Token exchange is an internal step on the way to the report data. See the current limitations before reusing an already account-scoped token.
Token expiry
The gate's OAuth access token normally expires after 1 hour. The gate is configured to attempt session renewal on requests after about 55 minutes; this is not a guarantee that every browser session renews successfully. So:
- read the header on every request; don't cache the token
- if PaperOS returns
401 UNAUTHORIZED(missing, malformed, expired, or invalid token), return 401 to your frontend and send the user back through sign-in if session renewal fails; do not retry in an endless reload loop
Because you must not store the token, run syncs while handling a user request (for example a “Sync now” button, or on page load).
Security do's and don'ts
Do
- call PaperOS only from your backend
- read the token from the request header per request
- use staging while building
Don't
- send the token to browser JavaScript, put it in cookies you set, or in URLs
- write the token to logs, error trackers, or your database
- store SSNs or EINs (reports mask them by default; leave it that way)
Optional: account-scoped access token
POST /api/v1/orgs/{org_id}/access-token
curl -X POST "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/access-token" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}/access-token`;
var resp = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
var orgToken = await resp.json();
Example Response:
{
"access_token": "<ACCOUNT_ACCESS_TOKEN>",
"token_type": "Bearer",
"expires_in": 3600,
"org_id": "org_01ewdxxpvgg2y19pbtbyddtvv8",
"account_id": 97
}
The workspace is selected by the URL; no account_id body is needed. The
response includes only the account access token and metadata, not an
id_token. expires_in is the remaining lifetime, which can be less than 3600
if the server reuses an existing token.
You don't need this call for normal data requests with the gate's user token. If you use it, send the resulting token only to URLs for the same workspace. Keep it backend-only, never logged or stored. Do not treat its account claim as a guarantee that it cannot be exchanged for another workspace the user can access.
Current workspace-scoping limitations
Source review of the server's staging revision f1ede6e07 on October 2, 2026
found these differences between endpoint families. These are documented
limitations, not fixes or a claim of live endpoint testing:
- Records: an already account-scoped token can retain its original account even when the URL names a different workspace. A request intended for B may read or write A. Use the original user-level token from the gate, or a token whose account matches the URL.
- Reports, forms, and the access-token endpoint: an already scoped token may be exchanged for the workspace in the URL if the user has access there. An account-scoped token is not an exclusive delegation boundary.
- Get one org: this route looks up the public
org_...ID, unlike newer routes that also accept numeric account IDs. Use public IDs from List Orgs.
These differences do not remove the membership requirement. Do not rely on cross-workspace token reuse behaving the same way across all endpoints.
Orgs
An org (organization, “workspace”) is a company, fund, or other entity in
PaperOS. Everything else (reports, forms, records, documents) lives inside an
org, so start here to find the org_id you'll use in every other call.
This is not a GitHub organization. The API's public org_... ID and its internal
numeric account_id identify the same PaperOS workspace. Prefer the public ID
returned below because numeric IDs are not accepted by every endpoint.
List Orgs
GET /api/v1/orgs?updated_since=0
curl "${PAPEROS_BASE_URL}/api/v1/orgs?updated_since=0" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
var url = `${paperBase}/api/v1/orgs?updated_since=0`;
var resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
var { orgs } = await resp.json();
Example Response:
{
"updated_at": 1677000469,
"orgs": [
{
"id": "org_01ewdxxpvgg2y19pbtbyddtvv8",
"name": "Example Fund I, LP",
"brand_id": "brand_00000000000000000000000000",
"created_at": "2021-01-19T18:18:46.000Z",
"updated_at": "2023-02-21T17:27:49.000Z"
}
]
}
Lists the orgs the signed-in user can access.
| Parameter | Default | Description |
|---|---|---|
updated_since | required; pass 0, or the updated_at from a prior call |
Get One Org
GET /api/v1/orgs/{org_id}
curl "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}`;
var resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
var org = await resp.json();
Returns a single org. Use the public ID (org_...); this route does not resolve
numeric account IDs. An inaccessible or unknown org returns 404. This older
route can return NOT_FOUND, while newer org routes use ORG_NOT_FOUND.
Reports
Reports are the tables you see in PaperOS: capital statements, investors, commitments, and so on. Each row is a PaperOS record. This is the easiest way to pull data out of an org.
List Reports
GET /api/v1/orgs/{org_id}/reports
curl "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/reports" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}/reports`;
var resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
var { reports } = await resp.json();
Example Response:
{
"org_id": "org_01ewdxxpvgg2y19pbtbyddtvv8",
"reports": [
{
"id": 1234,
"slug": "capital_statement_report",
"name": "Capital Statements",
"record_count": 42
},
{
"id": 1235,
"slug": "investor_list",
"name": "Investor Info",
"record_count": 18
}
]
}
Lists the reports available in the org, with how many records each has.
Which reports exist depends on the org. Slugs you will commonly see include
capital_statement_report, capital_contribution_report,
distribution_report, investor_list, k1_report, lp_company_summary,
spv_equity, and spv_financings. Use the slugs this endpoint returns rather
than hard-coding a list.
Get Report Data
GET /api/v1/orgs/{org_id}/reports/{report}
curl -G "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/reports/capital_statement_report" \
--data-urlencode "offset=0" \
--data-urlencode "limit=500" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
var report = encodeURIComponent("capital_statement_report");
var params = new URLSearchParams({ offset: 0, limit: 500 });
var url = `${paperBase}/api/v1/orgs/${orgId}/reports/${report}?${params}`;
var resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
var data = await resp.json();
Example Response:
{
"org_id": "org_01ewdxxpvgg2y19pbtbyddtvv8",
"report": {
"id": 1234,
"slug": "capital_statement_report",
"name": "Capital Statements"
},
"columns": [
{ "key": "Name", "type": "string" },
{ "key": "Email", "type": "string" },
{ "key": "Ownership Percentage", "type": "string" },
{ "key": "Total Contributions", "type": "string" },
{ "key": "Inception To Date Distributions", "type": "string" },
{ "key": "Inception To Date Ending Balance", "type": "string" },
{ "key": "Capital Statement Document", "type": "string" }
],
"record_count": 42,
"offset": 0,
"returned": 42,
"masked_columns": [],
"records": [
{
"id": 98765,
"record_id": "rec_01hcey7qcfeeqmh1af6x3xafa2",
"fields": {
"Name": "Jane Doe",
"Email": "jane@example.com",
"Ownership Percentage": "12.5",
"Total Contributions": "250000",
"Inception To Date Distributions": "0",
"Inception To Date Ending Balance": "250000",
"Capital Statement Document": "File Uploaded"
}
}
]
}
{report} can be the report's id, slug, or name (case-insensitive;
URL-encode names with spaces). An unknown report returns
404 REPORT_NOT_FOUND, with the names that do exist in available.
| Parameter | Default | Description |
|---|---|---|
offset | 0 | skip this many records |
limit | all | return at most this many records (max 5000) |
reveal_sensitive | false | true returns SSNs/EINs unmasked (default: masked to last 4) |
format | JSON | csv downloads the report as CSV (columns: record_id, id, then the report's) |
Things to know:
- Field keys are the report's display labels, exactly as shown in the
PaperOS table:
"Name","Email","Total Contributions", notname/total_contributions.columns[].keyis that same label. Labels differ between reports (investor_listuses"Full Legal Name","Email Address","Commitment Amount","SSN/EIN","Signatory Name";distribution_reportuses"Name","Amount","Distribution Date","Email"). Map the labels you need to your own column names explicitly, and tolerate a missing key: labels can be renamed in PaperOS. - Values are strings, exactly as stored (
"250000", not250000). Parse numbers and dates yourself. - Document columns show
"File Uploaded", not the file. Use Documents for files. - Each record has two ids.
record_id(rec_...) is the public record id: use it with the Records endpoints and as the key when syncing to your database.idis PaperOS's internal numeric id; it is stable, but the Records endpoints don't accept it. - For large reports, page with
offset/limituntil you've readrecord_countrecords. masked_columnslists columns whose values were masked. Avoidreveal_sensitive=trueunless you truly need the full value, and never store it.
Download as CSV
GET /api/v1/orgs/{org_id}/reports/{report}?format=csv
curl -G "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/reports/capital_statement_report" \
--data-urlencode "format=csv" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" \
-o capital_statement_report.csv
var url = `${paperBase}/api/v1/orgs/${orgId}/reports/capital_statement_report?format=csv`;
var resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
var csvText = await resp.text();
Same data as the JSON form, as a CSV file. The columns are record_id, id,
then the report's columns.
Forms
Form uploads let you submit data to PaperOS as a CSV: capital statements built from your accounting data, bank transactions from a bank statement export, and so on. Each row in the CSV is one form, so you can submit a single form or many forms in the same request. PaperOS creates the records (and, for capital statements and distribution notices, generates the PDF documents server-side).
The flow is always the same:
- get the template for the form
- build a CSV with exactly those headers, one row per form
- dry run it (validates, writes nothing)
- submit it once
| Type | Use it for |
|---|---|
capital_statements | investor capital statements (PDFs are generated) |
distribution_notices | distribution notices (PDFs are generated) |
capital_contributions | capital contributions received |
portfolio_investments | the fund's portfolio investments |
bank_transactions | lines from a bank statement export |
send_capital_calls | capital calls to send to investors |
List Forms
GET /api/v1/orgs/{org_id}/batch-types
curl "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/batch-types" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}/batch-types`;
var resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
var { batch_types } = await resp.json();
Example Response:
{
"batch_types": [
{
"type": "capital_statements",
"name": "Capital Statements",
"template_url": "/api/v1/orgs/org_01ewdxxpvgg2y19pbtbyddtvv8/batch-types/capital_statements/template"
}
]
}
The authoritative list of forms available for this org. Each form's type is
what you pass to the template and submission endpoints.
Get Form Template
GET /api/v1/orgs/{org_id}/batch-types/{type}/template
curl "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/batch-types/capital_statements/template" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}/batch-types/capital_statements/template`;
var resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
var template = await resp.json();
var headers = template.columns.map((c) => c.header);
Example Response:
{
"type": "capital_statements",
"columns": [
{ "header": "Capital Statement.name", "required": true },
{ "header": "Capital Statement.period_ending_date", "required": true },
{ "header": "Investor.name", "required": true },
{ "header": "Investor.email", "required": true },
{ "header": "Investor.send_email_yes_or_no", "required": true }
]
}
Returns the CSV columns for a form. Headers are the resource label, a
dot, and the field (for example Capital Statement.period_ending_date,
Investor.name, Investor.email). The example above is abridged; always build
from the template the endpoint returns.
Matching investors: rows are matched to existing investors by name and
email. Send the investor's name and email exactly as they appear in the
investor_list report ("Full Legal Name" and "Email Address"). A name that
doesn't match (or is blank) creates a new, duplicate investor instead of
attaching the row to the existing one.
Build your CSV header row from the header values, in order. Add
?format=csv to download a header-only CSV instead (handy as a spreadsheet
starting point; required columns are marked with * there).
| Parameter | Default | Description |
|---|---|---|
format | JSON | csv returns the header-only CSV |
Dry Run
POST /api/v1/orgs/{org_id}/batches?dry_run=true
curl -X POST "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/batches?dry_run=true&type=capital_statements&file_name=q3-statements.csv" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" \
-H "Content-Type: text/csv" \
--data-binary @q3-statements.csv |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}/batches?dry_run=true`;
var resp = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "capital_statements",
file_name: "q3-statements.csv",
csv: csvText,
}),
});
var check = await resp.json();
Example Response (200):
{
"dry_run": true,
"type": "capital_statements",
"rows": 12,
"columns": [
"Capital Statement.name",
"Capital Statement.period_ending_date",
"Investor.name",
"Investor.email",
"Investor.send_email_yes_or_no"
],
"missing": []
}
Example Error (400):
{
"status": 400,
"code": "MISSING_COLUMNS",
"message": "The CSV is missing required columns for this batch type.",
"missing": ["Investor.email"],
"template_url": "/api/v1/orgs/org_01ewdxxpvgg2y19pbtbyddtvv8/batch-types/capital_statements/template"
}
Example Error (400), a required value left empty:
{
"status": 400,
"code": "BLANK_REQUIRED_VALUES",
"message": "Some rows leave required columns empty. Fill them in or drop those rows.",
"blanks": [{ "line": 2, "column": "Investor.name" }]
}
Validates without writing anything: checks the type, that the CSV parses, that required columns are present, that no row leaves a required column empty, and counts rows. Always dry run first.
BLANK_REQUIRED_VALUES means a required column is in the header but empty in
one or more rows. blanks lists each line (CSV line number, header is line 1)
and column, up to 50 entries. Fill them in and dry run again. (Left
unchecked, a blank Investor.name would create a nameless duplicate investor.)
Form(s) Submission
POST /api/v1/orgs/{org_id}/batches
curl -X POST "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/batches?type=capital_statements&file_name=q3-statements.csv" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" \
-H "Content-Type: text/csv" \
--data-binary @q3-statements.csv |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}/batches`;
var resp = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "capital_statements",
file_name: "q3-statements.csv",
csv: csvText,
}),
});
var submission = await resp.json();
Example Response (201):
{
"org_id": "org_01ewdxxpvgg2y19pbtbyddtvv8",
"type": "capital_statements",
"file_name": "q3-statements.csv",
"file_id": "1234567890",
"rows": 12,
"batch_url": "https://staging.paperos.dev/portal",
"failed_lines": [],
"warnings": []
}
Submits one or more forms of the same type. The CSV is the header row from the template followed by one row per form:
- one form: a header row plus a single data row (for example, one investor's capital statement)
- many forms: a header row plus as many data rows as you need (for example, every investor's capital statement for the quarter)
One form:
Capital Statement.name,Capital Statement.period_ending_date,Investor.name,Investor.email,Investor.send_email_yes_or_no
Q3 2026 Statement,2026-09-30,Jane Doe,jane@example.com,No
Many forms:
Capital Statement.name,Capital Statement.period_ending_date,Investor.name,Investor.email,Investor.send_email_yes_or_no
Q3 2026 Statement,2026-09-30,Jane Doe,jane@example.com,No
Q3 2026 Statement,2026-09-30,John Smith,john@example.com,No
Q3 2026 Statement,2026-09-30,Acme Holdings LLC,ops@acme.example,No
rows in the response is the number of forms submitted. To submit different
form types, send one request per type.
Send the CSV either way:
- raw CSV:
Content-Type: text/csv, with?type=...&file_name=...in the query string - JSON:
{ "type", "csv": "<raw csv text>", "file_name" }
| Parameter | Default | Description |
|---|---|---|
type | form type (query string, raw CSV form only) | |
file_name | name to record for the file (raw CSV form only) | |
dry_run | false | true validates only; nothing is written |
Limits and checks: max 5 MB; unknown type, empty CSV, missing required
columns (MISSING_COLUMNS), or blank required values (BLANK_REQUIRED_VALUES)
return 400, the same checks as the dry run. Check failed_lines and warnings in the response, and
open batch_url to see the submission in PaperOS.
Submissions are slow; set a long timeout. The request returns only after
PaperOS has created the records and generated the PDFs. On staging a one-row
capital statement submission took about 7 seconds; more forms take
proportionally longer. Give the POST a client timeout of several minutes
(for example 10), and make sure any proxy in front of your backend allows it.
Records
A record is a single entry in your workspace, such as an investor, an investment, or the org itself. Every report row is a record, so use these endpoints to look up or change the data behind your reports.
Use the original user-level token from the SSO gate, or an account-scoped token matching the workspace in the URL. The current records implementation can use an already-scoped token's account even when the URL names another workspace. See workspace-scoping limitations before reading or writing records across workspaces.
Record URLs take the public record id, rec_...: the record_id of a report
row, or the rec_id returned when you create a record. (A report row's numeric
id is not accepted here.)
Use records to add or update one thing at a time. For many rows at once, use Forms.
Record Types and Fields
Every record has a type, and each type has its own set of fields. You'll
need both to create and update records. A few common types:
| Type | What it is |
|---|---|
individual | a person, such as an investor or team member |
org | an organization, such as an entity investor |
capital_statements | an investor's capital statement for a period |
financing | a financing round or investment |
For the full list of record types and the fields relevant to your use case, reach out to your PaperOS contact.
List Records
GET /api/v1/orgs/{org_id}/records?type={type_slug}
curl -G "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/records" \
--data-urlencode "type=individual" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
var params = new URLSearchParams({ type: "individual" });
var url = `${paperBase}/api/v1/orgs/${orgId}/records?${params}`;
var resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
var records = await resp.json();
Example Response:
[
{
"id": 17413,
"name": "Jane Doe",
"resource_type_id": 1,
"account_id": 97,
"created_at": "2023-09-22T19:50:13.000Z",
"updated_at": "2023-09-22T19:50:13.000Z",
"finalized": 0,
"archived": 0,
"is_draft": 0,
"features": {
"name": "Jane Doe",
"email": "jane@example.com"
}
}
]
| Parameter | Description |
|---|---|
type | record type slug, such as individual (* for all types) |
rec_ids | comma-separated record ids (rec_...) |
Get One Record
GET /api/v1/orgs/{org_id}/records/{rec_id}
curl "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/records/${rec_id}" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}/records/${recId}`;
var resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
var record = await resp.json();
Returns one record, in the same shape as the list items above. {rec_id} is
the public rec_... id.
Create a Record
POST /api/v1/orgs/{org_id}/records
curl -X POST "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/records" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" \
-H "Content-Type: application/json" \
--data-raw '{
"type": "individual",
"name": "Jane Doe",
"fields": {
"email": "jane@example.com"
}
}' |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}/records`;
var resp = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "individual",
name: "Jane Doe",
fields: { email: "jane@example.com" },
}),
});
var { rec_id } = await resp.json();
Example Response:
{
"success": true,
"rec_id": "rec_01hcey7qcfeeqmh1af6x3xafa2"
}
Store the returned rec_id in your database immediately, so a retry
doesn't create a second record.
Update a Record
PATCH /api/v1/orgs/{org_id}/records/{rec_id}
curl -X PATCH "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/records/${rec_id}" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" \
-H "Content-Type: application/json" \
--data-raw '{
"fields": {
"email": "jane.doe@example.com"
}
}' |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}/records/${recId}`;
var resp = await fetch(url, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
fields: { email: "jane.doe@example.com" },
}),
});
var result = await resp.json();
Example Response:
{
"success": true,
"changes": ["email"]
}
Body: { "name": "...", "fields": { "<field>": "<value>" } }. name is
optional; send only the fields you're changing.
There is no conflict check: the last write wins, and updated_at is set by
the server. If someone (or another system) may edit the record in PaperOS,
re-read it right before patching so you don't overwrite their change with stale
data.
Documents
Documents in an org: uploaded files, generated documents (such as capital statement PDFs from a form upload), and documents waiting for signatures.
List Documents
GET /api/v1/orgs/{org_id}/documents
curl "${PAPEROS_BASE_URL}/api/v1/orgs/${org_id}/documents" \
-H "Authorization: Bearer ${PAPEROS_TOKEN}" |
jq
var url = `${paperBase}/api/v1/orgs/${orgId}/documents`;
var resp = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
var { documents } = await resp.json();
Example Response:
{
"success": true,
"total": 1,
"count": 1,
"type": "[]<document>",
"documents": [
{
"path": "/Investors/Capital Statements",
"filename": "Q3 Capital Statement - Jane Doe.pdf",
"recipients": [],
"url": "https://staging.paperos.dev/api/public/documents/820701358552/eyJ0eXAiOiJKV1Qi...",
"pub_id": "doc_0000000000p6a290bsjjqmkfk1"
}
]
}
| Parameter | Description |
|---|---|
email | only documents for this person, e.g. ?email=jane@example.com |
urlis a temporary download link. Don't store it; storepub_idand list again when you need a fresh link.- If a document needs signatures, the signers are in
recipients[].
Syncing With Your Own Database
Your app keeps its own Postgres database. These patterns keep it consistent with PaperOS without duplicates or lost edits.
Ground Rules
- PaperOS is the source of truth for investor and fund records. Your database is a copy plus your app's own data.
- Key every synced row by
(org_id, paperos_record_id), with a unique index, wherepaperos_record_idis the publicrec_...id (a report row'srecord_id, or therec_idreturned on create). Store it as text. Don't key on the numericid; the Records endpoints don't accept it. - Never store tokens, SSNs, or EINs. Reports mask SSNs/EINs by default;
don't pass
reveal_sensitive=truein a sync. - Build against staging (
staging.paperos.dev); data there is test data.
Suggested Tables
Postgres
-- One row per PaperOS record you've pulled from a report.
CREATE TABLE paperos_records (
id BIGSERIAL PRIMARY KEY,
org_id TEXT NOT NULL, -- e.g. org_01ewdx...
paperos_record_id TEXT NOT NULL, -- records[].record_id (rec_...)
report_slug TEXT NOT NULL, -- e.g. capital_statement_report
fields JSONB NOT NULL, -- records[].fields, keyed by display label
last_synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
removed_upstream_at TIMESTAMPTZ, -- set when missing from a full pull
UNIQUE (org_id, paperos_record_id)
);
-- One row per batch upload attempt; prevents double submission.
CREATE TABLE batch_uploads (
id BIGSERIAL PRIMARY KEY,
org_id TEXT NOT NULL,
batch_type TEXT NOT NULL, -- e.g. capital_statements
file_name TEXT NOT NULL,
csv_sha256 TEXT NOT NULL, -- hash of the exact CSV sent
row_count INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
-- pending | submitted | succeeded | failed | unknown
file_id TEXT, -- from the 201 response
batch_url TEXT, -- from the 201 response
response JSONB, -- failed_lines, warnings, error
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, batch_type, csv_sha256)
);
These are starting points; add your own columns (for example, typed copies of the fields you query often).
Report fields are keyed by the report's display labels ("Name",
"Email", "Total Contributions", …), not snake_case. When you copy values
into typed columns, map each label to your own column name explicitly in one
place, and treat a missing label as “no value” rather than an error: labels can
be renamed in PaperOS.
Pulling (PaperOS to your DB)
- Fetch the report (page with
offset/limituntil you haverecord_countrecords). - In one transaction, upsert each record by
(org_id, paperos_record_id)and setlast_synced_atto the time the sync started. - After a full pull, rows for that org and report that weren't touched
were removed upstream: set
removed_upstream_at(soft delete). Don't hard delete them.
See the end-to-end example for the code.
Pushing (your DB to PaperOS)
Single records: POST to create, then store the returned rec_id
immediately. PATCH to update, sending only changed fields. PATCH is last
write wins, so re-read the record first if it may have been edited elsewhere.
Statements and other bulk data: use a form upload.
- Build the CSV from the template headers. Use each
investor's name and email exactly as
investor_listhas them ("Full Legal Name","Email Address") so rows match existing investors, and setInvestor.send_email_yes_or_notoNounless you mean to email them. - Hash the CSV and insert a
batch_uploadsrow (pending). If the unique constraint fails, this exact CSV was already sent; stop. - Dry run. Fix any
MISSING_COLUMNSorBLANK_REQUIRED_VALUESbefore going on. POSTit once, with a client timeout of several minutes (PDFs are generated before it returns). Savefile_id,batch_url,failed_lines, andwarnings, and mark the rowsucceeded.- If the POST errored or timed out, mark the row
unknownand don't retry. Check the report orbatch_urlto see whether it landed. - After success, re-pull the affected report so your DB has the new records.
End-to-End Example
A small Node.js (18+) + Express backend that:
- reads the user-level OAuth access token the SSO gate forwards
- lists reports and syncs one into Postgres (with
pg) - builds a CSV from your own data, dry-runs it, and uploads it once
The code is in the JavaScript tab on the right. It uses the tables from Suggested Tables.
Setup and Helper
server.js: setup and a PaperOS helper
// npm install express pg
import crypto from "node:crypto";
import express from "express";
import pg from "pg";
const PAPEROS_BASE_URL = process.env.PAPEROS_BASE_URL; // https://staging.paperos.dev
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
app.use(express.json({ limit: "5mb" }));
// Report fields are keyed by display label ("Name", "Total Contributions", ...).
// Map the labels you use to your own names in one place; labels can be renamed
// in PaperOS, so a missing label becomes null instead of an error.
const CAPITAL_STATEMENT_FIELDS = {
name: "Name",
email: "Email",
ownership_pct: "Ownership Percentage",
total_contributions: "Total Contributions",
itd_distributions: "Inception To Date Distributions",
itd_ending_balance: "Inception To Date Ending Balance",
};
function pickFields(fields, mapping) {
const out = {};
for (const [col, label] of Object.entries(mapping)) {
out[col] = fields?.[label] ?? null;
}
return out;
}
// Call the Developer API as the signed-in user.
// The token is read per request and never stored or logged.
async function paperos(req, path, init = {}) {
const token = req.get("X-Auth-Request-Access-Token");
if (!token) {
throw Object.assign(new Error("not signed in"), { status: 401 });
}
const resp = await fetch(`${PAPEROS_BASE_URL}${path}`, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${token}` },
// Form uploads generate PDFs before returning (~7s for one form, longer
// for more forms), so allow several minutes.
signal: init.signal ?? AbortSignal.timeout(10 * 60 * 1000),
});
const type = resp.headers.get("content-type") || "";
const body = type.includes("json") ? await resp.json() : await resp.text();
if (!resp.ok) {
throw Object.assign(new Error(body?.message || `PaperOS ${resp.status}`), {
status: resp.status,
code: body?.code,
body,
});
}
return body;
}
paperos() reads the X-Auth-Request-Access-Token header on every request,
calls PaperOS from the backend with a generous timeout, and turns error
responses into exceptions that carry status and code. pickFields() turns
a report record's label-keyed fields into your own column names.
Pull a Report Into Postgres
List reports, then sync one
// GET /api/orgs/:orgId/reports -> the org's reports (for a picker in your UI)
app.get("/api/orgs/:orgId/reports", async (req, res, next) => {
try {
const orgId = encodeURIComponent(req.params.orgId);
const { reports } = await paperos(req, `/api/v1/orgs/${orgId}/reports`);
res.json(reports);
} catch (err) {
next(err);
}
});
// POST /api/orgs/:orgId/sync/:report -> full pull of one report
app.post("/api/orgs/:orgId/sync/:report", async (req, res, next) => {
const { orgId, report } = req.params;
const startedAt = new Date();
const pageSize = 5000;
try {
// 1. fetch every page
const records = [];
let reportSlug;
for (let offset = 0; ; offset += pageSize) {
const page = await paperos(
req,
`/api/v1/orgs/${encodeURIComponent(orgId)}/reports/` +
`${encodeURIComponent(report)}?offset=${offset}&limit=${pageSize}`,
);
reportSlug = page.report.slug;
records.push(...page.records);
if (page.returned < pageSize || records.length >= page.record_count) {
break;
}
}
// 2. upsert in one transaction, 3. soft-delete what disappeared
const db = await pool.connect();
try {
await db.query("BEGIN");
for (const rec of records) {
await db.query(
`INSERT INTO paperos_records
(org_id, paperos_record_id, report_slug, fields, last_synced_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (org_id, paperos_record_id) DO UPDATE
SET fields = EXCLUDED.fields,
report_slug = EXCLUDED.report_slug,
last_synced_at = EXCLUDED.last_synced_at,
removed_upstream_at = NULL`,
[orgId, rec.record_id, reportSlug, rec.fields, startedAt],
);
}
await db.query(
`UPDATE paperos_records
SET removed_upstream_at = now()
WHERE org_id = $1 AND report_slug = $2
AND last_synced_at < $3 AND removed_upstream_at IS NULL`,
[orgId, reportSlug, startedAt],
);
await db.query("COMMIT");
} catch (err) {
await db.query("ROLLBACK");
throw err;
} finally {
db.release();
}
res.json({ report: reportSlug, synced: records.length });
} catch (err) {
next(err);
}
});
// GET /api/orgs/:orgId/capital-statements -> synced rows in your own shape
app.get("/api/orgs/:orgId/capital-statements", async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT paperos_record_id, fields FROM paperos_records
WHERE org_id = $1 AND report_slug = 'capital_statement_report'
AND removed_upstream_at IS NULL`,
[req.params.orgId],
);
res.json(
rows.map((r) => ({
paperos_record_id: r.paperos_record_id,
...pickFields(r.fields, CAPITAL_STATEMENT_FIELDS),
})),
);
} catch (err) {
next(err);
}
});
Use the same org_id form (public org_xxx id) everywhere you store it, so
the unique key matches across syncs.
Build a CSV, Dry Run, Upload
Upload capital statements built from your own data
function toCsv(headers, rows) {
const cell = (v) => {
const s = v == null ? "" : String(v);
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return [headers, ...rows.map((r) => headers.map((h) => r[h]))]
.map((line) => line.map(cell).join(","))
.join("\r\n");
}
// POST /api/orgs/:orgId/statements
// body: { file_name, rows: [{ "Investor.name": "...", "Investor.email": "...", ... }] }
// Each row's keys are template headers; map your bank/accounting data to them first.
// Use each investor's "Full Legal Name" / "Email Address" from investor_list exactly,
// or the upload creates a duplicate investor. Investor.send_email_yes_or_no = "Yes"
// emails investors their statements: keep it "No" while testing.
app.post("/api/orgs/:orgId/statements", async (req, res, next) => {
const { orgId } = req.params;
const type = "capital_statements";
const base = `/api/v1/orgs/${encodeURIComponent(orgId)}`;
let uploadId;
let posted = false;
try {
// 1. template -> CSV with exactly those headers
const template = await paperos(req, `${base}/batch-types/${type}/template`);
const headers = template.columns.map((c) => c.header);
const csv = toCsv(headers, req.body.rows);
const fileName = req.body.file_name || "statements.csv";
const hash = crypto.createHash("sha256").update(csv).digest("hex");
// 2. ledger row; the unique key blocks re-sending the same CSV
const ins = await pool.query(
`INSERT INTO batch_uploads (org_id, batch_type, file_name, csv_sha256)
VALUES ($1, $2, $3, $4)
ON CONFLICT (org_id, batch_type, csv_sha256) DO NOTHING
RETURNING id`,
[orgId, type, fileName, hash],
);
if (!ins.rowCount) {
return res.status(409).json({ error: "This exact CSV was already uploaded." });
}
uploadId = ins.rows[0].id;
const payload = () => ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, file_name: fileName, csv }),
});
// 3. dry run: validates, writes nothing
// (MISSING_COLUMNS, BLANK_REQUIRED_VALUES etc. throw here)
const check = await paperos(req, `${base}/batches?dry_run=true`, payload());
await pool.query(
`UPDATE batch_uploads SET row_count = $2, status = 'submitted',
updated_at = now() WHERE id = $1`,
[uploadId, check.rows],
);
// 4. upload ONCE (not idempotent: never retry automatically)
posted = true;
const batch = await paperos(req, `${base}/batches`, payload());
await pool.query(
`UPDATE batch_uploads
SET status = 'succeeded', file_id = $2, batch_url = $3,
response = $4, updated_at = now()
WHERE id = $1`,
[uploadId, batch.file_id, batch.batch_url,
{ failed_lines: batch.failed_lines, warnings: batch.warnings }],
);
// 5. then re-pull the affected report (see the sync route above)
res.status(201).json(batch);
} catch (err) {
if (uploadId && !posted) {
// Failed before the real upload: nothing was sent, free the ledger slot.
await pool.query("DELETE FROM batch_uploads WHERE id = $1", [uploadId]);
} else if (uploadId) {
// A 4xx means PaperOS rejected it. Anything else (5xx, timeout, network)
// means we don't know whether it landed: check the report before resending.
const status = err.status >= 400 && err.status < 500 ? "failed" : "unknown";
await pool.query(
`UPDATE batch_uploads SET status = $2, response = $3, updated_at = now()
WHERE id = $1`,
[uploadId, status, JSON.stringify(err.body ?? { message: err.message })],
);
}
next(err);
}
});
Error Handling
Pass PaperOS errors through safely
app.use((err, req, res, next) => {
// Log the code and message only; never log request headers (they hold the token).
console.error("request failed:", err.status, err.code, err.message);
if (err.status === 401) {
// Return through SSO; sign in again if renewal fails. Do not loop on reload.
return res.status(401).json({ code: "UNAUTHORIZED" });
}
res.status(err.status || 500).json({
code: err.code || "INTERNAL",
message: err.message,
...(err.body?.missing && { missing: err.body.missing }),
...(err.body?.blanks && { blanks: err.body.blanks }),
});
});
app.listen(process.env.PORT || 8000);
On the frontend, call your own /api/... routes with fetch. On 401, return
through the SSO gate and ask the user to sign in again if session renewal fails.
Avoid automatic reload loops. The browser never talks to PaperOS directly.
Errors
Error shape
{
"status": 400,
"code": "MISSING_COLUMNS",
"message": "The CSV is missing required columns for this batch type.",
"missing": ["Investor.email"]
}
Errors return a non-2xx status and a JSON body with status, a stable code,
a human-readable message, and sometimes extra fields. Branch on code, not
on message.
| Status | code | Meaning and what to do |
|---|---|---|
| 400 | MISSING_COLUMNS | CSV lacks required columns; see missing. Fix the header row. |
| 400 | BLANK_REQUIRED_VALUES | A required column is empty in some rows; see blanks (line, column; up to 50). Fill them in. Dry run catches it. |
| 400 | UNKNOWN_BATCH_TYPE | Not a valid form type; see List Forms. |
| 400 | EMPTY_CSV | The CSV has no data rows. |
| 400 | INVALID_CSV | The CSV couldn't be parsed (check quoting and line endings). |
| 401 | UNAUTHORIZED | Token missing, malformed, expired, or invalid. Return through the SSO gate; sign in again if session renewal fails. Do not loop on reload. |
| 404 | ORG_NOT_FOUND | The org doesn't exist or this user can't access it. |
| 404 | REPORT_NOT_FOUND | No such report; see available for report names. |
| 409 | BATCH_PROJECT_MISSING | The org isn't set up for this form yet. Contact PaperOS. |
| 413 | CSV_TOO_LARGE | Over 5 MB. Split into several files. |
| 502 | UPSTREAM_ERROR | A service PaperOS depends on failed. Safe to retry a GET; for a form POST, check first. |
Other statuses you may see: 403 (no access), 405 (wrong method), 429
(slow down), 500 (our bug; please tell us), 502 (the app may be
restarting).
