Published on

How I Reverse-Engineered Osome and Automated 144 Invoice Uploads

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.

The Problem

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.


Step 1: Reverse Engineering the API

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:

1
Base URL: https://my.osome.com/api/v2
2
Auth: Session cookies (no API keys, no OAuth)
3
Format: JSON (mostly JSON:API style)

Key Endpoints

EndpointMethodWhat it does
/roberto/companies/{id}/transactionsGETList transactions (with filters)
/companies/{id}/files/url_for_uploadPOSTGet a presigned S3 upload URL
/companies/{id}/documentsPOSTCreate a document record + link it to a transaction
/documents/{id}PATCHLink/relink a document to a transaction
/conversations/{id}/messagesPOSTSend a message in the transaction thread

How Auth Works

Osome uses session cookies. No API tokens. The approach:

  1. Log into my.osome.com in your browser
  2. Open DevTools → Network tab
  3. Right-click any request to Osome → Copy as cURL
  4. Extract the Cookie header value
  5. Use that cookie string for all your API calls
1
# .env
2
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.

The Interesting Header

I noticed Osome sends an x-initiator header that looks like a package name with a version:

1
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.


Step 2: Understanding the Upload Flow

This was the most interesting part. Uploading a document to Osome is a 3-step dance with S3:

1
┌──────────┐ ┌──────────┐ ┌──────────┐
2
│ Step 1 │────▶│ Step 2 │────▶│ Step 3 │
3
│ Get URL │ │ Upload S3 │ │ Link Doc │
4
│ (Osome) │ │ (AWS) │ │ (Osome) │
5
└──────────┘ └──────────┘ └──────────┘

Step 1: Ask Osome for a presigned S3 URL

1
const res = await fetch(`${BASE_URL}/companies/${COMPANY_ID}/files/url_for_upload`, {
2
method: "POST",
3
headers: { "Content-Type": "application/json", Cookie: COOKIES },
4
body: JSON.stringify({
5
filename: "invoice.pdf",
6
size: 75596, // file size in bytes
7
contentType: "application/pdf",
8
checksum: "c568d595...", // MD5 hash of the file
9
}),
10
});

Osome responds with a presigned S3 URL plus form fields:

1
{
2
"uploadUrlInfo": {
3
"signedUrl": "https://osome-uploads.s3.ap-southeast-1.amazonaws.com/...",
4
"formData": { "key": "...", "policy": "...", "x-amz-credential": "..." }
5
},
6
"file": {
7
"url": "https://osome-uploads.s3.../invoice.pdf",
8
"signedUrl": "https://osome-uploads.s3.../invoice.pdf?X-Amz-Algorithm=..."
9
}
10
}

Step 2: Upload the file directly to S3

1
const form = new FormData();
2
3
// All presigned form fields go first (S3 requires this order)
4
for (const [key, value] of Object.entries(formData)) {
5
form.append(key, value);
6
}
7
8
// File goes LAST — S3 rejects the upload otherwise
9
form.append("file", new Blob([buffer], { type: "application/pdf" }), filename);
10
11
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.

1
await fetch(`${BASE_URL}/companies/${COMPANY_ID}/documents`, {
2
method: "POST",
3
headers: { "Content-Type": "application/json", Cookie: COOKIES },
4
body: JSON.stringify({
5
document: {
6
attributes: { acTransactionIds: [TRANSACTION_ID] },
7
uploadingMethod: "transactionItemPage",
8
name: "invoice.pdf",
9
file: {
10
url: publicUrl, // from step 1
11
signedUrl: fileSignedUrl, // from step 1
12
name: "invoice.pdf",
13
fileSize: 75596,
14
checksum: "c568d595...",
15
},
16
},
17
}),
18
});

That's it. The transaction now shows the document attached.


Step 3: Building the CLI

I built a simple CLI with Bun that wraps all of this:

1
#!/usr/bin/env bun
2
// scripts/osome.ts
3
4
const COOKIES = process.env.OSOME_COOKIES || "";
5
if (!COOKIES) {
6
console.error("Missing OSOME_COOKIES env var");
7
console.error("Get cookies from browser DevTools → Network → Copy as cURL → extract Cookie header");
8
process.exit(1);
9
}
10
11
const headers = {
12
Accept: "application/json",
13
"Content-Type": "application/json",
14
Cookie: COOKIES,
15
};

Commands

1
bun scripts/osome.ts list # List transactions needing docs
2
bun scripts/osome.ts upload <txId> <file> [-m "msg"] # Upload + link + message
3
bun scripts/osome.ts message <txId> <message> # Send message to thread
4
bun scripts/osome.ts tx <txId> # Get transaction details

Example: List transactions

1
$ bun scripts/osome.ts list
2
3
85 transactions need documents:
4
5
[12345678] 2025-08-01 | -X,XXX USD
6
Sent money to Jane Doe
7
8
[12345679] 2025-07-01 | -X,XXX USD
9
Sent money to Jane Doe
10
11
[12345680] 2024-09-10 | +X,XXX.XX USD
12
Received money from ACME CORP
1
$ bun scripts/osome.ts upload 12345678 salary-pdfs/SALARY-12345678-Jane-2025-08-01.pdf
2
3
Uploading SALARY-12345678-Jane-2025-08-01.pdf (28344 bytes)...
4
→ Getting upload URL...
5
→ Uploading to S3...
6
→ Creating document and linking to transaction...
7
✓ Document linked to transaction 12345678
8
→ Sending message to conversation...
9
✓ Message sent (id: 48291)

Step 4: Generating Missing Invoices

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.

Salary PDF Generator

For every salary payment, I generate a slip like this:

1
// Define all payments as data
2
const payments = [
3
{
4
txId: 12345678,
5
date: "2025-08-01",
6
amount: 3000,
7
name: "Jane Doe",
8
role: "Software Developer",
9
transactionRef: "TRANSFER-0000000000",
10
},
11
// ... 26 more payments
12
];
13
14
// Generate HTML → render with Puppeteer → save as PDF
15
for (const payment of payments) {
16
const html = generateHTML(payment); // returns a full HTML page with inline CSS
17
await page.setContent(html, { waitUntil: "domcontentloaded" });
18
await page.pdf({
19
path: `salary-pdfs/SALARY-${payment.txId}-${payment.name.split(" ")[0]}-${payment.date}.pdf`,
20
format: "A4",
21
printBackground: true,
22
});
23
}
24
25
// Save a mapping file: txId → filename (for bulk upload later)
26
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:

1
<div class="header">
2
<div class="title">Salary Payment Slip</div>
3
<div class="meta">
4
<span class="meta-label">Slip number</span>
5
<span class="meta-value">SALARY-2025-08-JD</span>
6
</div>
7
</div>
8
<div class="parties">
9
<div>
10
<div class="party-label">Paid to</div>
11
<div>Jane Doe<br>Software Developer</div>
12
</div>
13
<div>
14
<div class="party-label">Paid by</div>
15
<div>YOUR COMPANY PTE LTD<br>68 Some Road, Singapore</div>
16
</div>
17
</div>
18
<!-- amount table, status badge, etc -->

Document Types Generated

I ended up writing 7 different generators:

ScriptWhat it generatesCount
generate-salary-pdfs.tsSalary payment slips27
generate-client-invoices.tsClient invoices (incoming payments)12
generate-subsidiary-invoices.tsSubsidiary invoices1
generate-interco-invoice.tsInter-company invoices1
generate-design-invoice.tsDesign services invoices1
generate-visa-receipt.tsVisa receipts1
generate-pdf.tsGeneric HTML→PDFvaries

Each script outputs a mapping.json that maps transaction IDs to file paths. This makes bulk uploading trivial.


The Full Workflow

Here's how I processed 144 transactions:

1
# 1. Set up auth
2
export OSOME_COOKIES="access-token=xxx; ..."
3
4
# 2. Generate all the PDFs I was missing
5
bun scripts/generate-salary-pdfs.ts # → 27 salary slips
6
bun scripts/generate-client-invoices.ts # → 12 client invoices
7
8
# 3. See what's left
9
bun scripts/osome.ts list
10
11
# 4. Upload generated docs (using mapping.json)
12
for entry in $(cat salary-pdfs/mapping.json | jq -c '.[]'); do
13
txId=$(echo $entry | jq -r '.txId')
14
file=$(echo $entry | jq -r '.filename')
15
bun scripts/osome.ts upload $txId $file -m "Salary payment slip attached"
16
done
17
18
# 5. Upload vendor invoices I downloaded manually
19
bun scripts/osome.ts upload 12345681 downloaded-originals/aws-july-2025.pdf

Tech Stack

1
Runtime: Bun
2
Language: TypeScript
3
PDF Gen: Puppeteer
4
File Hash: Pure JS MD5 (RFC 1321)
5
Storage: Osome's S3 bucket (ap-southeast-1)
6
Auth: Browser session cookies

What I Learned

1. 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.


Project Structure

1
osome/
2
├── lib/
3
│ ├── api/
4
│ │ ├── osome.ts # Core API (list, upload URL, link)
5
│ │ ├── upload.ts # S3 upload helper
6
│ │ └── types.ts # TypeScript interfaces
7
│ └── utils/
8
│ └── md5.ts # MD5 checksum (pure JS)
9
├── scripts/
10
│ ├── osome.ts # CLI tool
11
│ ├── generate-salary-pdfs.ts
12
│ ├── generate-client-invoices.ts
13
│ └── generate-*.ts # 7 generators total
14
├── salary-pdfs/ # Generated PDFs + mapping.json
15
├── client-invoices/ # Generated PDFs + mapping.json
16
├── downloaded-originals/ # Manually downloaded vendor invoices
17
├── requests.sh # Captured cURL requests (my notes)
18
└── package.json

API Reference (for fellow Osome users)

List transactions needing documents

1
curl 'https://my.osome.com/api/v2/roberto/companies/{COMPANY_ID}/transactions?sort=dateDesc&perPage=200&page=1' \
2
-H 'Accept: application/json' \
3
-H 'Cookie: YOUR_COOKIES'

Filter for transactionStatus === "documentRequired" in the response.

Get presigned upload URL

1
curl -X POST 'https://my.osome.com/api/v2/companies/{COMPANY_ID}/files/url_for_upload' \
2
-H 'Content-Type: application/json' \
3
-H 'Cookie: YOUR_COOKIES' \
4
-d '{"filename":"invoice.pdf","size":75596,"contentType":"application/pdf","checksum":"MD5_HEX_STRING"}'

Upload to S3

1
curl -X POST '{SIGNED_URL_FROM_STEP_1}' \
2
-F 'key=...' \
3
-F 'policy=...' \
4
-F '...(all formData fields)...' \
5
-F 'file=@invoice.pdf'
1
curl -X POST 'https://my.osome.com/api/v2/companies/{COMPANY_ID}/documents' \
2
-H 'Content-Type: application/json' \
3
-H 'Cookie: YOUR_COOKIES' \
4
-d '{
5
"document": {
6
"attributes": { "acTransactionIds": [TRANSACTION_ID] },
7
"uploadingMethod": "transactionItemPage",
8
"name": "invoice.pdf",
9
"file": {
10
"url": "PUBLIC_URL_FROM_STEP_1",
11
"signedUrl": "SIGNED_URL_FROM_STEP_1",
12
"name": "invoice.pdf",
13
"fileSize": 75596,
14
"checksum": "MD5_HEX_STRING"
15
}
16
}
17
}'

Send message to transaction thread

1
curl -X POST 'https://my.osome.com/api/v2/conversations/{CONVERSATION_ID}/messages' \
2
-H 'Content-Type: application/json' \
3
-H 'Cookie: YOUR_COOKIES' \
4
-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.

Use It Yourself

I packaged this as an Agent Skill you can install directly:

1
npx skills add Necmttn/osome-skill

Or grab the source and customize it for your own Osome account: