- 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:
1Base URL: https://my.osome.com/api/v22Auth: Session cookies (no API keys, no OAuth)3Format: JSON (mostly JSON:API style)
Key Endpoints
| 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 |
How Auth Works
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
1# .env2OSOME_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:
1x-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
1const res = await fetch(`${BASE_URL}/companies/${COMPANY_ID}/files/url_for_upload`, {2method: "POST",3headers: { "Content-Type": "application/json", Cookie: COOKIES },4body: JSON.stringify({5filename: "invoice.pdf",6size: 75596, // file size in bytes7contentType: "application/pdf",8checksum: "c568d595...", // MD5 hash of the file9}),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
1const form = new FormData();23// All presigned form fields go first (S3 requires this order)4for (const [key, value] of Object.entries(formData)) {5form.append(key, value);6}78// File goes LAST — S3 rejects the upload otherwise9form.append("file", new Blob([buffer], { type: "application/pdf" }), filename);1011await 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.
Step 3: Tell Osome to link the uploaded file to a transaction
1await fetch(`${BASE_URL}/companies/${COMPANY_ID}/documents`, {2method: "POST",3headers: { "Content-Type": "application/json", Cookie: COOKIES },4body: JSON.stringify({5document: {6attributes: { acTransactionIds: [TRANSACTION_ID] },7uploadingMethod: "transactionItemPage",8name: "invoice.pdf",9file: {10url: publicUrl, // from step 111signedUrl: fileSignedUrl, // from step 112name: "invoice.pdf",13fileSize: 75596,14checksum: "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 bun2// scripts/osome.ts34const COOKIES = process.env.OSOME_COOKIES || "";5if (!COOKIES) {6console.error("Missing OSOME_COOKIES env var");7console.error("Get cookies from browser DevTools → Network → Copy as cURL → extract Cookie header");8process.exit(1);9}1011const headers = {12Accept: "application/json",13"Content-Type": "application/json",14Cookie: COOKIES,15};
Commands
1bun scripts/osome.ts list # List transactions needing docs2bun scripts/osome.ts upload <txId> <file> [-m "msg"] # Upload + link + message3bun scripts/osome.ts message <txId> <message> # Send message to thread4bun scripts/osome.ts tx <txId> # Get transaction details
Example: List transactions
1$ bun scripts/osome.ts list2385 transactions need documents:45[12345678] 2025-08-01 | -X,XXX USD6Sent money to Jane Doe78[12345679] 2025-07-01 | -X,XXX USD9Sent money to Jane Doe1011[12345680] 2024-09-10 | +X,XXX.XX USD12Received money from ACME CORP
Example: Upload and link
1$ bun scripts/osome.ts upload 12345678 salary-pdfs/SALARY-12345678-Jane-2025-08-01.pdf23Uploading 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 123456788→ 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 data2const payments = [3{4txId: 12345678,5date: "2025-08-01",6amount: 3000,7name: "Jane Doe",8role: "Software Developer",9transactionRef: "TRANSFER-0000000000",10},11// ... 26 more payments12];1314// Generate HTML → render with Puppeteer → save as PDF15for (const payment of payments) {16const html = generateHTML(payment); // returns a full HTML page with inline CSS17await page.setContent(html, { waitUntil: "domcontentloaded" });18await page.pdf({19path: `salary-pdfs/SALARY-${payment.txId}-${payment.name.split(" ")[0]}-${payment.date}.pdf`,20format: "A4",21printBackground: true,22});23}2425// Save a mapping file: txId → filename (for bulk upload later)26await 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:
| 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 | 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.
The Full Workflow
Here's how I processed 144 transactions:
1# 1. Set up auth2export OSOME_COOKIES="access-token=xxx; ..."34# 2. Generate all the PDFs I was missing5bun scripts/generate-salary-pdfs.ts # → 27 salary slips6bun scripts/generate-client-invoices.ts # → 12 client invoices78# 3. See what's left9bun scripts/osome.ts list1011# 4. Upload generated docs (using mapping.json)12for entry in $(cat salary-pdfs/mapping.json | jq -c '.[]'); do13txId=$(echo $entry | jq -r '.txId')14file=$(echo $entry | jq -r '.filename')15bun scripts/osome.ts upload $txId $file -m "Salary payment slip attached"16done1718# 5. Upload vendor invoices I downloaded manually19bun scripts/osome.ts upload 12345681 downloaded-originals/aws-july-2025.pdf
Tech Stack
1Runtime: Bun2Language: TypeScript3PDF Gen: Puppeteer4File Hash: Pure JS MD5 (RFC 1321)5Storage: Osome's S3 bucket (ap-southeast-1)6Auth: 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
1osome/2├── lib/3│ ├── api/4│ │ ├── osome.ts # Core API (list, upload URL, link)5│ │ ├── upload.ts # S3 upload helper6│ │ └── types.ts # TypeScript interfaces7│ └── utils/8│ └── md5.ts # MD5 checksum (pure JS)9├── scripts/10│ ├── osome.ts # CLI tool11│ ├── generate-salary-pdfs.ts12│ ├── generate-client-invoices.ts13│ └── generate-*.ts # 7 generators total14├── salary-pdfs/ # Generated PDFs + mapping.json15├── client-invoices/ # Generated PDFs + mapping.json16├── downloaded-originals/ # Manually downloaded vendor invoices17├── requests.sh # Captured cURL requests (my notes)18└── package.json
API Reference (for fellow Osome users)
List transactions needing documents
1curl '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
1curl -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
1curl -X POST '{SIGNED_URL_FROM_STEP_1}' \2-F 'key=...' \3-F 'policy=...' \4-F '...(all formData fields)...' \5-F 'file=@invoice.pdf'
Create document and link to transaction
1curl -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
1curl -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:
1npx skills add Necmttn/osome-skill
Or grab the source and customize it for your own Osome account:
- GitHub repo — sanitized, ready to fork
- Full source gist — all files in one place