This is the shortest path from "I have a folder of PDFs and an email address" to "the recipient has a tracked, watermarked, gated link." Three implementations, in increasing order of language richness: Bash + curl + jq, the Papermark CLI, TypeScript with the SDK, and Python with the SDK. Pick the one that matches your environment. Copy-paste-modify.
The use cases that come up most often for this script:
- One-off investor share: a founder sending the deck to a specific VC mid-pitch.
- Customer-specific deal room: a sales team sending a tailored kit to a prospect.
- Vendor due-diligence packet: compliance/security teams responding to a SOC 2 questionnaire.
- Board pre-read distribution: corporate secretary running the quarterly cycle.
- Legal document delivery: outside counsel sending privileged materials to a client.
- CI artifact distribution: engineering teams sharing build artifacts or compliance reports with downstream consumers.
- Bulk-mode in a loop: wrap any of the above in a
forloop over a recipients CSV.
All seven boil down to the same three API calls: create dataroom, upload documents, mint link.
Bash + curl + jq
The lowest-dependency variant. Requires only curl (preinstalled on every modern OS) and jq (one brew install jq or apt install jq away).
#!/usr/bin/env bash
set -euo pipefail
# ─── Inputs ─────────────────────────────────────────────────────────
NAME="${1:?usage: $0 'dataroom name' ./folder recipient@example.com}"
DIR="${2:?usage: $0 'dataroom name' ./folder recipient@example.com}"
RECIPIENT="${3:?usage: $0 'dataroom name' ./folder recipient@example.com}"
API="https://api.papermark.com/v1"
: "${PAPERMARK_TOKEN:?
set PAPERMARK_TOKEN — get one at https://app.papermark.com/settings/tokens
}"
# ─── 1: create the dataroom ─────────────────────────────────────────
echo "→ creating dataroom \"$NAME\""
DR_ID=$(curl -sS -X POST "$API/datarooms" \
-H "Authorization: Bearer $PAPERMARK_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"$NAME\"}" | jq -r '.data.id')
echo " dataroom $DR_ID"
# ─── 2: upload every supported file in the folder ───────────────────
SUPPORTED=("pdf" "docx" "pptx" "xlsx" "csv" "txt" "md")
UPLOADED=0
for f in "$DIR"/*; do
[ -f "$f" ] || continue
ext="${f##*.}"
ext_lower=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
if [[ ! " ${SUPPORTED[*]} " =~ " $ext_lower " ]]; then
echo " skipping unsupported: $f"
continue
fi
echo "→ uploading $(basename "$f")"
curl -sS -X POST "$API/documents" \
-H "Authorization: Bearer $PAPERMARK_TOKEN" \
-F "file=@$f" \
-F "dataroom_id=$DR_ID" > /dev/null
UPLOADED=$((UPLOADED + 1))
done
echo " uploaded $UPLOADED documents"
# ─── 3: mint a tracked link ─────────────────────────────────────────
echo "→ minting link for $RECIPIENT"
LINK_URL=$(curl -sS -X POST "$API/links" \
-H "Authorization: Bearer $PAPERMARK_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"dataroom_id\": \"$DR_ID\",
\"require_email\": true,
\"allow_download\": false,
\"watermark\": \"$RECIPIENT · {{timestamp}}\",
\"notes\": \"Generated for $RECIPIENT on $(date -u +%Y-%m-%dT%H:%M:%SZ)\"
}" | jq -r '.data.url')
echo
echo "✓ done in $SECONDS seconds"
echo " dataroom: $DR_ID"
echo " documents: $UPLOADED"
echo " link: $LINK_URL"
Run it:
chmod +x share.sh
export PAPERMARK_TOKEN=pm_live_…
./share.sh "Acme — Series A" ./acme-pack alice@vc.com
For a typical 8-document dataroom on a broadband connection, this completes in 25-60 seconds. The bottleneck is upload time, not API latency.
Same thing with the CLI
If you have papermark installed (npm install -g papermark), the script collapses to about 12 lines:
#!/usr/bin/env bash
set -euo pipefail
NAME="$1"; DIR="$2"; RECIPIENT="$3"
DR=$(papermark datarooms create --name "$NAME" --json | jq -r '.data.id')
for f in "$DIR"/*; do
[ -f "$f" ] && papermark documents upload "$f" --dataroom "$DR" > /dev/null
done
papermark links create \
--dataroom "$DR" \
--require-email \
--watermark "$RECIPIENT · {{timestamp}}" \
--json | jq -r '.data.url'
The CLI handles auth, retries, and the supported-file-type filtering internally. The trade-off is the Node startup cost (~80-150ms per CLI invocation), which adds up across many uploads but is invisible for one-shot scripts.
TypeScript with the SDK
For more elaborate workflows. Progress bars, parallel uploads, retry on flaky networks, structured error handling. Use the SDK:
#!/usr/bin/env -S npx tsx
import { Papermark } from "@papermark/sdk";
import { readdir } from "node:fs/promises";
import { createReadStream } from "node:fs";
import path from "node:path";
const [name, dir, recipient] = process.argv.slice(2);
if (!name || !dir || !recipient) {
console.error(
"usage: tsx share.ts 'dataroom name' ./folder recipient@example.com",
);
process.exit(1);
}
const pm = new Papermark(); // reads PAPERMARK_TOKEN
const SUPPORTED = new Set([".pdf", ".docx", ".pptx", ".xlsx", ".csv", ".txt", ".md"]);
console.log(`→ creating dataroom "${name}"`);
const dataroom = await pm.datarooms.create({ name });
console.log(` ${dataroom.id}`);
const files = (await readdir(dir)).filter((f) =>
SUPPORTED.has(path.extname(f).toLowerCase()),
);
console.log(`→ uploading ${files.length} files in parallel (concurrency 4)`);
let done = 0;
await Promise.all(
files.map(async (f) => {
await pm.documents.upload({
file: createReadStream(path.join(dir, f)),
dataroomId: dataroom.id,
name: f,
});
done++;
process.stdout.write(` [${done}/${files.length}] ${f}\n`);
}),
);
console.log(`→ minting link for ${recipient}`);
const link = await pm.links.create({
dataroomId: dataroom.id,
requireEmail: true,
allowDownload: false,
watermark: `${recipient} · {{timestamp}}`,
});
console.log(`\n✓ ${link.url}`);
Run it with:
PAPERMARK_TOKEN=pm_live_… npx tsx share.ts "Acme — Series A" ./acme-pack alice@vc.com
Parallel-upload concurrency of 4-8 typically saturates a standard broadband connection without exhausting the API's per-account rate limit.
Python with the SDK
For environments where Node isn't installed but Python is (common in data-science teams and operations):
#!/usr/bin/env python3
import os, sys, glob, time
from papermark import Papermark
def main():
if len(sys.argv) < 4:
print("usage: python share.py 'dataroom name' ./folder recipient@example.com")
sys.exit(1)
name, directory, recipient = sys.argv[1], sys.argv[2], sys.argv[3]
pm = Papermark() # reads PAPERMARK_TOKEN
SUPPORTED = {".pdf", ".docx", ".pptx", ".xlsx", ".csv", ".txt", ".md"}
started = time.time()
print(f"→ creating dataroom {name!r}")
room = pm.datarooms.create(name=name)
print(f" {room.id}")
files = [
p for p in glob.glob(f"{directory}/*")
if os.path.splitext(p)[1].lower() in SUPPORTED
]
print(f"→ uploading {len(files)} files")
for i, path in enumerate(files, 1):
with open(path, "rb") as f:
pm.documents.upload(file=f, dataroom_id=room.id, name=os.path.basename(path))
print(f" [{i}/{len(files)}] {os.path.basename(path)}")
print(f"→ minting link for {recipient}")
link = pm.links.create(
dataroom_id=room.id,
require_email=True,
allow_download=False,
watermark=f"{recipient} · {{{{timestamp}}}}",
)
elapsed = time.time() - started
print(f"\n✓ done in {elapsed:.1f}s")
print(f" {link.url}")
if __name__ == "__main__":
main()
Extending the script
Six useful extensions in roughly increasing complexity:
1: add a password from your password manager
PW=$(op item get "Acme dataroom" --field password)
papermark links create --dataroom "$DR" --password "$PW" --json
Works with 1Password CLI (op), Bitwarden CLI (bw), or any other secret-manager CLI. Don't hardcode passwords; don't pipe openssl rand into the script unless you also send the password through a separate channel.
2: send the link via email
RESEND_FROM="deals@yourcompany.com"
curl -X POST https://api.resend.com/emails \
-H "Authorization: Bearer $RESEND_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"from\": \"$RESEND_FROM\",
\"to\": \"$RECIPIENT\",
\"subject\": \"Materials for $NAME\",
\"html\": \"Here are the materials: <a href='$LINK_URL'>$LINK_URL</a>\"
}"
Resend, Postmark, SES, Mailgun, or your own SMTP all work. Use a transactional sender, not a marketing platform.
3: per-bidder loop over a CSV
Wrap the link-minting step in a loop over a recipients CSV:
tail -n +2 recipients.csv | while IFS=, read -r NAME EMAIL FUND; do
URL=$(papermark links create \
--dataroom "$DR" \
--require-email \
--watermark "$NAME · $FUND · {{timestamp}}" \
--json | jq -r '.data.url')
echo "$EMAIL,$URL"
done > links.csv
For 30 recipients, this takes about 40-80 seconds end-to-end. See Per-recipient share links for the deeper pattern.
4: add organized folders
If your document set has natural categorization (Financials, Legal, IP), build the folder tree before uploading:
for folder in Financials Legal IP Operations; do
FID=$(curl -sS -X POST "$API/datarooms/$DR/folders" \
-H "Authorization: Bearer $PAPERMARK_TOKEN" \
-d "{\"name\": \"$folder\"}" | jq -r '.data.id')
# Upload files matching this folder pattern
for f in "$DIR/$folder"/*; do
[ -f "$f" ] && papermark documents upload "$f" \
--dataroom "$DR" --folder "$FID" > /dev/null
done
done
5: add expiry and download policy from environment
papermark links create \
--dataroom "$DR" \
--require-email \
--expires "${LINK_EXPIRES_AT:-2026-12-31}" \
--no-download \
--watermark "$RECIPIENT · {{timestamp}}"
Externalize defaults so the same script handles different deal contexts without code changes.
6: log the run to your CRM
curl -X POST "https://api.hubapi.com/crm/v3/objects/contacts/$CONTACT_ID/notes" \
-H "Authorization: Bearer $HUBSPOT_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"properties\": {
\"hs_note_body\": \"Dataroom $NAME provisioned. Link: $LINK_URL\"
}
}"
Now the deal team has a CRM record of every dataroom they've ever sent.
What you didn't have to build
The combined script above is 30-80 lines depending on language. The infrastructure it replaces. Purpose-built sharing UIs, ad-hoc email-attachment workflows, manual CRM logging, custom watermarking, link expiry management. Typically takes 2-6 engineer-weeks to build internally, and breaks at the edges (the email gateway didn't deliver, the watermark library doesn't handle Unicode, the link expiry job stopped firing). The API approach inherits all of that hardened infrastructure for free.
See also
More in Cookbook
Per-recipient share links: one dataroom, N watermarked URLs
Generate one share link per recipient with per-recipient watermarks, per-recipient policy, and CRM write-back: the single most useful pattern in programmable data rooms. Worked example uses the Papermark API.
Forward data room view events to Slack: real-time engagement alerts via webhook
Wire data room webhooks into a Slack channel: verify signatures, filter to high-signal events, add interactive actions, route by recipient priority. Complete Next.js handler with HMAC verification.