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
| 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 PaperOS access token to your backend in a request header.
- Your backend calls the Developer API with that token, scoped to one org.
- 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;
| 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
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:
- 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 have it reload the page, so the SSO gate refreshes the session, then try again
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: 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.
| 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. {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.
| 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.
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:
- get the template for the batch type
- build a CSV with exactly those headers
- dry run it (validates, writes nothing)
- upload 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 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).
| 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.)
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:
- 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 | batch 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 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"
}
}
]
| 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 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"
}
]
}
| 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 batch 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 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}` },
// 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.
| 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 batch type; see batch_types. |
| 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. Have the browser reload so the SSO gate refreshes it. |
| 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 batch type 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 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).

