NAV
Developer API

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

PathWhat 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

  1. Your app is deployed on the PaperOS deploy platform, behind PaperOS SSO.
  2. A signed-in user loads a page; the SSO gate forwards their OAuth access token to your backend in a request header.
  3. 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.
  4. 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;
EnvironmentBase URL
Staginghttps://staging.paperos.dev
Productionhttps://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?

TokenWhat it representsDefault lifetime
OAuth access tokenThe signed-in user in the OAuth flow. This is what the SSO gate forwards.1 hour
PaperOS ID tokenThe user's identity, without choosing an account. Also accepted as a user token by these APIs.24 hours
PaperOS access token without an accountA 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 tokenA 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

RequestResponse
GET /api/v1/orgs/{org_id}/reportsThe workspace's available reports.
GET /api/v1/orgs/{org_id}/reports/{report}The requested report's data.
POST /api/v1/orgs/{org_id}/access-tokenAn 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:

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

Don't

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:

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.

ParameterDefaultDescription
updated_sincerequired; 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.

ParameterDefaultDescription
offset0skip this many records
limitallreturn at most this many records (max 5000)
reveal_sensitivefalsetrue returns SSNs/EINs unmasked (default: masked to last 4)
formatJSONcsv downloads the report as CSV (columns: record_id, id, then the report's)

Things to know:

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:

  1. get the template for the form
  2. build a CSV with exactly those headers, one row per form
  3. dry run it (validates, writes nothing)
  4. submit it once
TypeUse it for
capital_statementsinvestor capital statements (PDFs are generated)
distribution_noticesdistribution notices (PDFs are generated)
capital_contributionscapital contributions received
portfolio_investmentsthe fund's portfolio investments
bank_transactionslines from a bank statement export
send_capital_callscapital 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).

ParameterDefaultDescription
formatJSONcsv 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:

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:

ParameterDefaultDescription
typeform type (query string, raw CSV form only)
file_namename to record for the file (raw CSV form only)
dry_runfalsetrue 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:

TypeWhat it is
individuala person, such as an investor or team member
organ organization, such as an entity investor
capital_statementsan investor's capital statement for a period
financinga 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"
      }
   }
]
ParameterDescription
typerecord type slug, such as individual (* for all types)
rec_idscomma-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"
      }
   ]
}
ParameterDescription
emailonly documents for this person, e.g. ?email=jane@example.com

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

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)

  1. Fetch the report (page with offset/limit until you have record_count records).
  2. In one transaction, upsert each record by (org_id, paperos_record_id) and set last_synced_at to the time the sync started.
  3. 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.

  1. Build the CSV from the template headers. Use each investor's name and email exactly as investor_list has them ("Full Legal Name", "Email Address") so rows match existing investors, and set Investor.send_email_yes_or_no to No unless you mean to email them.
  2. Hash the CSV and insert a batch_uploads row (pending). If the unique constraint fails, this exact CSV was already sent; stop.
  3. Dry run. Fix any MISSING_COLUMNS or BLANK_REQUIRED_VALUES before going on.
  4. POST it once, with a client timeout of several minutes (PDFs are generated before it returns). Save file_id, batch_url, failed_lines, and warnings, and mark the row succeeded.
  5. If the POST errored or timed out, mark the row unknown and don't retry. Check the report or batch_url to see whether it landed.
  6. 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:

  1. reads the user-level OAuth access token the SSO gate forwards
  2. lists reports and syncs one into Postgres (with pg)
  3. 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.

StatuscodeMeaning and what to do
400MISSING_COLUMNSCSV lacks required columns; see missing. Fix the header row.
400BLANK_REQUIRED_VALUESA required column is empty in some rows; see blanks (line, column; up to 50). Fill them in. Dry run catches it.
400UNKNOWN_BATCH_TYPENot a valid form type; see List Forms.
400EMPTY_CSVThe CSV has no data rows.
400INVALID_CSVThe CSV couldn't be parsed (check quoting and line endings).
401UNAUTHORIZEDToken missing, malformed, expired, or invalid. Return through the SSO gate; sign in again if session renewal fails. Do not loop on reload.
404ORG_NOT_FOUNDThe org doesn't exist or this user can't access it.
404REPORT_NOT_FOUNDNo such report; see available for report names.
409BATCH_PROJECT_MISSINGThe org isn't set up for this form yet. Contact PaperOS.
413CSV_TOO_LARGEOver 5 MB. Split into several files.
502UPSTREAM_ERRORA 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).