I run a Singapore company through Osome, and they wanted me to manually upload a PDF for every single bank transaction. 144 of them. One by one. Through their web UI. I said no.
Osome is a corporate secretary and accounting service for Singapore companies. It's great — until you need to upload supporting documents for your transactions. Their dashboard flags every transaction that needs a receipt or invoice attached. You click in, upload a PDF, wait, click back, repeat.
I had 144 transactions needing documents. Salary payments, client invoices, AWS bills, Cloudflare invoices, visa receipts. Some of these I had PDFs for. Most of them I needed to generate.
The platform has no API. No bulk upload. No import. Just a web UI designed for clicking.
So I opened DevTools.
Open my.osome.com, go to any transaction, open Chrome DevTools (Cmd+Shift+I), switch to the Network tab, and start clicking around. Upload a document manually once. Watch every request.
That's it. That's the whole reverse engineering process.
Here's what I found:
Base URL: https://my.osome.com/api/v2
Auth: Session cookies (no API keys, no OAuth)
Format: JSON (mostly JSON:API style)
| Endpoint | Method | What it does |
|---|---|---|
/roberto/companies/{id}/transactions |
GET | List transactions (with filters) |
/companies/{id}/files/url_for_upload |
POST | Get a presigned S3 upload URL |
/companies/{id}/documents |
POST | Create a document record + link it to a transaction |
/documents/{id} |
PATCH | Link/relink a document to a transaction |
/conversations/{id}/messages |
POST | Send a message in the transaction thread |
Osome uses session cookies. No API tokens. The approach:
- Log into
my.osome.comin your browser - Open DevTools → Network tab
- Right-click any request to Osome → Copy as cURL
- Extract the
Cookieheader value - Use that cookie string for all your API calls
# .env
OSOME_COOKIES="access-token=xxx; is-logged-in=1; logged-in-name=YourName; ..."The cookies last as long as your browser session. Not ideal, but good enough for a batch job.
I noticed Osome sends an x-initiator header that looks like a package name with a version:
x-initiator: websome-transactions@0.229.0
Their frontend is called "websome" internally. I didn't bother sending this header — the API works fine without it.
This was the most interesting part. Uploading a document to Osome is a 3-step dance with S3:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Step 1 │────▶│ Step 2 │────▶│ Step 3 │
│ Get URL │ │ Upload S3 │ │ Link Doc │
│ (Osome) │ │ (AWS) │ │ (Osome) │
└──────────┘ └──────────┘ └──────────┘
const res = await fetch(`${BASE_URL}/companies/${COMPANY_ID}/files/url_for_upload`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: COOKIES },
body: JSON.stringify({
filename: "invoice.pdf",
size: 75596, // file size in bytes
contentType: "application/pdf",
checksum: "c568d595...", // MD5 hash of the file
}),
});Osome responds with a presigned S3 URL plus form fields:
{
"uploadUrlInfo": {
"signedUrl": "https://osome-uploads.s3.ap-southeast-1.amazonaws.com/...",
"formData": { "key": "...", "policy": "...", "x-amz-credential": "...", ... }
},
"file": {
"url": "https://osome-uploads.s3.../invoice.pdf",
"signedUrl": "https://osome-uploads.s3.../invoice.pdf?X-Amz-Algorithm=..."
}
}const form = new FormData();
// All presigned form fields go first (S3 requires this order)
for (const [key, value] of Object.entries(formData)) {
form.append(key, value);
}
// File goes LAST — S3 rejects the upload otherwise
form.append("file", new Blob([buffer], { type: "application/pdf" }), filename);
await fetch(signedUrl, { method: "POST", body: form });Gotcha: The file must be the last field in the FormData. S3 presigned POST uploads enforce this ordering. I learned this the hard way.
await fetch(`${BASE_URL}/companies/${COMPANY_ID}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: COOKIES },
body: JSON.stringify({
document: {
attributes: { acTransactionIds: [TRANSACTION_ID] },
uploadingMethod: "transactionItemPage",
name: "invoice.pdf",
file: {
url: publicUrl, // from step 1
signedUrl: fileSignedUrl, // from step 1
name: "invoice.pdf",
fileSize: 75596,
checksum: "c568d595...",
},
},
}),
});That's it. The transaction now shows the document attached.
I built a simple CLI with Bun that wraps all of this:
#!/usr/bin/env bun
// scripts/osome.ts
const COOKIES = process.env.OSOME_COOKIES || "";
if (!COOKIES) {
console.error("Missing OSOME_COOKIES env var");
console.error("Get cookies from browser DevTools → Network → Copy as cURL → extract Cookie header");
process.exit(1);
}
const headers = {
Accept: "application/json",
"Content-Type": "application/json",
Cookie: COOKIES,
};bun scripts/osome.ts list # List transactions needing docs
bun scripts/osome.ts upload <txId> <file> [-m "msg"] # Upload + link + message
bun scripts/osome.ts message <txId> <message> # Send message to thread
bun scripts/osome.ts tx <txId> # Get transaction details
$ bun scripts/osome.ts list
85 transactions need documents:
[12345678] 2025-08-01 | -X,XXX USD
Sent money to Jane Doe
[12345679] 2025-07-01 | -X,XXX USD
Sent money to Jane Doe
[12345680] 2024-09-10 | +X,XXX.XX USD
Received money from ACME CORP$ bun scripts/osome.ts upload 12345678 salary-pdfs/SALARY-12345678-Jane-2025-08-01.pdf
Uploading SALARY-12345678-Jane-2025-08-01.pdf (28344 bytes)...
→ Getting upload URL...
→ Uploading to S3...
→ Creating document and linking to transaction...
✓ Document linked to transaction 12345678
→ Sending message to conversation...
✓ Message sent (id: 48291)Half my transactions didn't have PDFs. Salary payments to contractors, client invoices — these were just bank transfers with no paper trail. My accountant still needs a document for each one.
So I wrote Puppeteer scripts that generate professional-looking PDFs from HTML templates.
For every salary payment, I generate a slip like this:
// Define all payments as data
const payments = [
{
txId: 12345678,
date: "2025-08-01",
amount: 3000,
name: "Jane Doe",
role: "Software Developer",
transactionRef: "TRANSFER-0000000000",
},
// ... 26 more payments
];
// Generate HTML → render with Puppeteer → save as PDF
for (const payment of payments) {
const html = generateHTML(payment); // returns a full HTML page with inline CSS
await page.setContent(html, { waitUntil: "domcontentloaded" });
await page.pdf({
path: `salary-pdfs/SALARY-${payment.txId}-${payment.name.split(" ")[0]}-${payment.date}.pdf`,
format: "A4",
printBackground: true,
});
}
// Save a mapping file: txId → filename (for bulk upload later)
await Bun.write("salary-pdfs/mapping.json", JSON.stringify(generated));The HTML template is self-contained — all CSS is inline, so Puppeteer renders it perfectly:
<div class="header">
<div class="title">Salary Payment Slip</div>
<div class="meta">
<span class="meta-label">Slip number</span>
<span class="meta-value">SALARY-2025-08-JD</span>
</div>
</div>
<div class="parties">
<div>
<div class="party-label">Paid to</div>
<div>Jane Doe<br>Software Developer</div>
</div>
<div>
<div class="party-label">Paid by</div>
<div>YOUR COMPANY PTE LTD<br>68 Some Road, Singapore</div>
</div>
</div>
<!-- amount table, status badge, etc -->I ended up writing 7 different generators:
| Script | What it generates | Count |
|---|---|---|
generate-salary-pdfs.ts |
Salary payment slips | 27 |
generate-client-invoices.ts |
Client invoices (incoming payments) | 12 |
generate-subsidiary-invoices.ts |
Subsidiary invoices | 1 |
generate-interco-invoice.ts |
Inter-company invoices | 1 |
generate-design-invoice.ts |
Design services invoices | 1 |
generate-visa-receipt.ts |
Bali visa receipts | 1 |
generate-pdf.ts |
Generic HTML→PDF | varies |
Each script outputs a mapping.json that maps transaction IDs to file paths. This makes bulk uploading trivial.
Here's how I processed 144 transactions:
# 1. Set up auth
export OSOME_COOKIES="access-token=xxx; ..."
# 2. Generate all the PDFs I was missing
bun scripts/generate-salary-pdfs.ts # → 27 salary slips
bun scripts/generate-client-invoices.ts # → 12 client invoices
# 3. See what's left
bun scripts/osome.ts list
# 4. Upload generated docs (using mapping.json)
for entry in $(cat salary-pdfs/mapping.json | jq -c '.[]'); do
txId=$(echo $entry | jq -r '.txId')
file=$(echo $entry | jq -r '.filename')
bun scripts/osome.ts upload $txId $file -m "Salary payment slip attached"
done
# 5. Upload vendor invoices I downloaded manually
bun scripts/osome.ts upload 12345681 downloaded-originals/aws-july-2025.pdfRuntime: Bun
Language: TypeScript
PDF Gen: Puppeteer
File Hash: Pure JS MD5 (RFC 1321)
Storage: Osome's S3 bucket (ap-southeast-1)
Auth: Browser session cookies
git clone <this-repo>
cd osome
bun install
# Copy your cookies
cp .env.example .env
# Edit .env with your OSOME_COOKIES value
# Run the CLI
bun scripts/osome.ts list1. Most SaaS platforms have an internal API. If their web app does it, there's an API call behind it. DevTools Network tab is your friend.
2. Presigned S3 uploads are a common pattern. The platform gives you a temporary URL, you upload directly to S3, then you tell the platform it's done. This saves them bandwidth.
3. Session cookies are often the only auth. No API keys, no OAuth. Just steal your own cookies from the browser. It's janky but it works for automation.
4. Puppeteer for PDF generation is underrated. Instead of wrestling with PDF libraries, just write HTML and print it. The results look professional and the workflow is simple: data → HTML template → Puppeteer → PDF.
5. The mapping.json pattern is powerful. Every generator script outputs { txId, filename } pairs. This makes the "generate" step completely separate from the "upload" step. Generate once, upload many times if needed.
osome/
├── lib/
│ ├── api/
│ │ ├── osome.ts # Core API (list, upload URL, link)
│ │ ├── upload.ts # S3 upload helper
│ │ └── types.ts # TypeScript interfaces
│ └── utils/
│ └── md5.ts # MD5 checksum (pure JS)
├── scripts/
│ ├── osome.ts # CLI tool
│ ├── generate-salary-pdfs.ts
│ ├── generate-client-invoices.ts
│ └── generate-*.ts # 7 generators total
├── salary-pdfs/ # Generated PDFs + mapping.json
├── client-invoices/ # Generated PDFs + mapping.json
├── downloaded-originals/ # Manually downloaded vendor invoices
├── requests.sh # Captured cURL requests (my notes)
├── package.json
└── wxt.config.ts
curl 'https://my.osome.com/api/v2/roberto/companies/{COMPANY_ID}/transactions?sort=dateDesc&perPage=200&page=1' \
-H 'Accept: application/json' \
-H 'Cookie: YOUR_COOKIES'Filter for transactionStatus === "documentRequired" in the response.
curl -X POST 'https://my.osome.com/api/v2/companies/{COMPANY_ID}/files/url_for_upload' \
-H 'Content-Type: application/json' \
-H 'Cookie: YOUR_COOKIES' \
-d '{"filename":"invoice.pdf","size":75596,"contentType":"application/pdf","checksum":"MD5_HEX_STRING"}'curl -X POST '{SIGNED_URL_FROM_STEP_1}' \
-F 'key=...' \
-F 'policy=...' \
-F '...(all formData fields)...' \
-F 'file=@invoice.pdf'curl -X POST 'https://my.osome.com/api/v2/companies/{COMPANY_ID}/documents' \
-H 'Content-Type: application/json' \
-H 'Cookie: YOUR_COOKIES' \
-d '{
"document": {
"attributes": { "acTransactionIds": [TRANSACTION_ID] },
"uploadingMethod": "transactionItemPage",
"name": "invoice.pdf",
"file": {
"url": "PUBLIC_URL_FROM_STEP_1",
"signedUrl": "SIGNED_URL_FROM_STEP_1",
"name": "invoice.pdf",
"fileSize": 75596,
"checksum": "MD5_HEX_STRING"
}
}
}'curl -X POST 'https://my.osome.com/api/v2/conversations/{CONVERSATION_ID}/messages' \
-H 'Content-Type: application/json' \
-H 'Cookie: YOUR_COOKIES' \
-d '{"message":{"text":"Invoice attached"}}'Built with Bun, TypeScript, Puppeteer, and spite. The whole thing took an evening. Uploading 144 documents manually would have taken a week.
I packaged this as an Agent Skill you can install directly:
npx skills add Necmttn/osome-skillOr grab the source: github.com/Necmttn/osome-skill