NAV

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, batch-upload 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 PaperOS access token to your backend in a request header.
  3. Your backend calls the Developer API with that token, scoped to one org.
  4. Your backend stores what it needs in your own Postgres 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

There is one rule: send Authorization: Bearer <token>, where the token is any PaperOS user token.

When your app runs behind PaperOS SSO on the deploy platform, the SSO gate forwards the signed-in user's PaperOS 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.

Org scoping is automatic

For every /api/v1/orgs/{org_id}/... route, PaperOS checks that the user can access that org and scopes the call to it. There is no separate “workspace token” step.

{org_id} accepts the org's public id (org_xxx) or its numeric id. Get the list of orgs the user can access from GET /api/v1/orgs.

Token expiry

Tokens expire after about 1 hour. The SSO gate refreshes the browser session roughly every 55 minutes, 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: org-bound 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": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJ...",
   "token_type": "Bearer",
   "expires_in": 3600,
   "org_id": "org_01ewdxxpvgg2y19pbtbyddtvv8",
   "account_id": 97
}

You don't need this for normal use; automatic org scoping covers it. It is there for clients that want a token bound to a single org. Treat the returned token exactly like the user token: backend only, never stored.

Orgs

An org (organization, “workspace”) is a company, fund, or other entity in PaperOS. Everything else (reports, batches, records, documents) lives inside an org, so start here to find the org_id you'll use in every other call.

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. {org_id} may be the public id (org_xxx) or the numeric id. Returns 404 ORG_NOT_FOUND if the user can't access it.

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.

Batch Uploads

Batch uploads let you send many rows at once as a CSV: capital statements built from your accounting data, bank transactions from a bank statement export, and so on. 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 batch type
  2. build a CSV with exactly those headers
  3. dry run it (validates, writes nothing)
  4. upload 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 Batch Types

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 types for this org.

Get a 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 batch type. 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.)

Upload a Batch

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 batch = 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": []
}

Send the CSV either way:

ParameterDefaultDescription
typebatch 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 batch in PaperOS.

Uploads 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 upload took about 7 seconds; larger batches 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

Records are the individual things in an org: investors (individuals and entities), investments, the org itself, and so on. Reports are views over records, so each report row is a record.

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 Batch Uploads.

Record Types and Fields

These are introspection endpoints: use them to discover type and field names while you build, but don't depend on the exact response shape at runtime, as it may change.

GET /api/v1/schema

curl -s "${PAPEROS_BASE_URL}/api/v1/schema" |
    jq -r '.record_types[].type'
var resp = await fetch(`${paperBase}/api/v1/schema`);
var { record_types } = await resp.json();

GET /api/v1/schema/{type}

curl -s "${PAPEROS_BASE_URL}/api/v1/schema/individual" |
    jq -r '.field_types[].type'
var resp = await fetch(`${paperBase}/api/v1/schema/individual`);
var { field_types } = await resp.json();

Lists record types, and the fields each type has. Use these to find the type and fields keys for creating and updating records.

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 batch 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 batch 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 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}` },
      // Batch uploads generate PDFs before returning (~7s for one row, longer
      // for bigger batches), 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) {
      // The frontend should reload the page so the SSO gate refreshes the token.
      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, and reload the page when one returns 401. 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 batch type; see batch_types.
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. Have the browser reload so the SSO gate refreshes it.
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 batch type 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 batch 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).