VCE Ingestion API
Send your purchase history and BOM-mapping data straight from your own system (SAP, any ERP, or a script) into Workwise — no Excel files, no manual work. This guide walks you through it, step by step.
To estimate what a part should cost, Workwise needs two kinds of data from you. Instead of uploading Excel sheets, your system can send them here as JSON. We accept the data, save it, and reply right away with a summary of what happened.
How it works
.json file and POST it to the right web address.The two kinds of data
Each one has its own web address. They all start with:
https://api.letsworkwise.com/api/v1/vcost-estimation| What you're sending | Where to send it | If you send it again |
|---|---|---|
| Purchase history — rates you've paid vendors | POST /ingest/mrr | Adds new rows; exact duplicates are skipped |
| Model → BOM list — which parts make up a product | POST /ingest/model-bom-map | Replaces that model + BOM's parts |
Every request needs a key so we know it's really you. Create one on the Integrations → API Keys page (any signed-in Workwise user can do this). You'll see the key only once, so copy it somewhere safe — if you lose it, just make a new one.
Add the key to every request, like this:
Authorization: Bearer wvce_live_xxxxxxxx_………
# or
X-API-Key: wvce_live_xxxxxxxx_………What a key is allowed to send
When you create a key, you tick which kinds of data it may send. If a key tries to send something it isn't allowed to, the request is refused (403). The permissions are:
ingest:mrringest:model-bom-map
Keys never expire — to rotate, make a new one and revoke the old one. A key already knows which company it belongs to, so you never send a company id yourself.
A few simple things before you start:
- You always send a
.jsonfile. Save your data as a file and upload it as multipart form-data in a field namedfile. (Sending JSON straight in the request body is not accepted — it must be a file.) We keep a copy of every file you send, so you can look it up later. - The file holds one JSON object. That object has a single list inside it —
recordsfor purchase history ormappingsfor the model → BOM list. See the exact shape in each section below. - Dates are best written as
"2025-10-27"(year-month-day). We also accept the other shapes an ERP export tends to produce —27-10-2025,27/10/2025,27-Oct-2025,20251027, or a raw Excel date serial — all read the same way the Excel upload reads them. A day-first date like03/07/2025is 3 July, not 7 March. - The part number matters most. We read the part's type and its matching keys straight from
cpartno, so it has to be written in the standard Bliss format (for example3BA24800X3M-001R1T7B). If it's written wrong, the part can't be matched to a price. - You don't send everything. We fill in the behind-the-scenes fields for you — your company, the record id, the matching keys, and so on. Just send the fields shown in the examples below.
There are three limits. Each one has its own reply, so you always know which you hit. If you have more data than this, split it across several files and send them one after another.
Every endpoint below also lists its own limits next to it, so you don't have to come back here while you build.
| Limit | How much | If you go over |
|---|---|---|
| File size | Up to 50 MB per .json file | 413 — split into smaller files |
| Rows in one file | Up to 5,000. For MRR that's the number of records. For the mapping it's the total components across the whole file — not the number of models. | 400 — split into smaller files |
| Requests per minute | Up to 120 per key | 429 — wait and retry (we tell you how long in the Retry-After header) |
Why size and rows are both capped
They protect different things, so one doesn't replace the other. The row cap keeps a request quick to process. The size cap is separate because you're welcome to send extra fields of your own — which means 5,000 rows tells us nothing about how big the file actually is. Both are checked, and you'll hear about whichever you cross first.
What going over looks like
A file bigger than 50 MB:
HTTP 413 Payload Too Large
{
"status": 0,
"message": "JSON file is too large. The limit is 50 MB per file — split
the payload across smaller files and send them one after
another."
}More than 5,000 rows in one file:
HTTP 400 Bad Request
// An MRR file holding 6,400 records:
{
"status": 2,
"errors": {
"records": "Too many records in one file. The limit is 5,000 rows per
file — split the payload across smaller files and send them
one after another."
}
}
// A mapping file whose components add up to 6,400:
{
"status": 0,
"message": "Too many rows in one file (6,400). The limit is 5,000 rows per
file — split the payload across smaller files and send them one
after another."
}Nothing is saved when you go over a limit — we check before writing anything, so you'll never end up with half a file loaded. Fix the file and send it again.
These are the rates you've actually paid vendors in the past — the numbers Workwise uses to price parts. Send them as a simple list under records. Four fields are required on each record: irate, supplier, dated, and type — the figures the pricing needs. Everything else is optional, including cpartno: send what you have.
A record with no cpartno is kept but will never be matched to a part, which is the same thing that happens when you upload one in a spreadsheet.
Your .json file should contain a records list, like this:
{
"source": "SAP-ECC",
"records": [
{
"cpartno": "3BA24800X3M-001R1T7B", // optional -> cpartno (drives match keys)
"irate": 231.00, // required -> irate (the vendor rate)
"supplier": "INTRICAST PVT. LTD.", // required -> supplier
"dated": "2025-10-27", // required -> dated (drives recency; see Good to know)
"type": "02", // required -> type (02/07 = complete buy)
"mrrNo": "001864", // -> mrr_no
"itemName": "BODY", // -> item_name
"unit": "NOS", // -> unit
"poNo": "PO-2025-1180", // -> p_o_no
"icode": 70060257, // -> icode
"billChl": "BILL", // -> bill_chl
"advised": 10, // -> advised
"rcvd": 10, // -> rcvd
"accept": 10, // -> accept
"reject": 0, // -> reject
"lc": 0, // -> lc
"tcNo": "TC-1180", // -> tc_no
"excDoc": "", // -> exc_doc
"testCert": "TC/2025/114", // -> test_cert
"finvno": "INV-8842", // -> finvno
"wono": "WO-551", // -> wono
"gateEntry": "GE-9931", // -> gate_entry
"gateDate": "2025-10-25", // -> gate_date
"entBy": "RAVI", // -> ent_by
"inspBy": "SATISH", // -> insp_by
"rwCond": "OK", // -> rw_cond
"qcDate": "2025-10-26", // -> qcdate
"store": "MAIN", // -> store
"mtime": 0, // -> mtime
"invDate": "2025-10-24", // -> invdate
"cdrgno": "24800X3", // -> cdrgno
"revNo": "R1", // -> rev_no
"exchRate": 1, // -> exch_rate
"awb": "", // -> awb
"modeTpt": "ROAD", // -> mode_tpt
"poDate": "2025-10-01", // -> podate
"boeNo": "" // -> boe_no
}
]
}- Scope
- Your key needs
ingest:mrr. Without it you get403. - Rows
- Up to 5,000 items in
records— one record is one row. Over that gets400. - File
- One
.jsonfile, up to 50 MB. Bigger gets413. An inline JSON body is never accepted —400. - Rate
- 120 requests per minute per key. Over that gets
429with aRetry-Afterheader. - Repeat sends
- Safe. Nothing is overwritten — identical records come back counted as
duplicates, and you can send the same file twice without harm.
Before storing a record we check whether we already hold an identical one for your company. We compare all 37 fields below — every one of them, not just the required ones:
datedmrrNosupplierbillChlitemNameunitadvisedrcvdacceptrejectiratelctcNoexcDoctestCertfinvnotypewonopoNogateEntrygateDateentByinspByrwCondqcDateicodestoremtimeinvDatecpartnocdrgnorevNoexchRateawbmodeTptpoDateboeNoAll 37 match an existing record → we keep the one we have and skip yours. Not an error: the reply counts it under duplicates.
Any single field differs → it is a different record and is stored as a new one, counted under inserted.
Comparison ignores upper/lower case, leading and trailing spaces, and number formatting (231 and 231.00 are the same value). Dates are compared as calendar days, so 2026-07-30 and 30-07-2026 are the same date. This is the identical check the Excel upload runs, so a record loaded from a spreadsheet is recognised when you push it here, and the other way round.
Purchase history is a log, so records only ever accumulate — we never delete or overwrite an earlier one. A corrected rate for a part you already sent is stored as an additional record, and the most recent dated is what pricing uses.
Every field, explained
| Field you send | Saved as | Needed? |
|---|---|---|
cpartno | cpartno | optional |
irate | irate | required |
supplier | supplier | required |
dated | dated | required |
type | type | required |
mrrNo | mrr_no | optional |
itemName | item_name | optional |
unit | unit | optional |
poNo | p_o_no | optional |
icode | icode | optional |
billChl | bill_chl | optional |
advised | advised | optional |
rcvd | rcvd | optional |
accept | accept | optional |
reject | reject | optional |
lc | lc | optional |
tcNo | tc_no | optional |
excDoc | exc_doc | optional |
testCert | test_cert | optional |
finvno | finvno | optional |
wono | wono | optional |
gateEntry | gate_entry | optional |
gateDate | gate_date | optional |
entBy | ent_by | optional |
inspBy | insp_by | optional |
rwCond | rw_cond | optional |
qcDate | qcdate | optional |
store | store | optional |
mtime | mtime | optional |
invDate | invdate | optional |
cdrgno | cdrgno | optional |
revNo | rev_no | optional |
exchRate | exch_rate | optional |
awb | awb | optional |
modeTpt | mode_tpt | optional |
poDate | podate | optional |
boeNo | boe_no | optional |
This says which child parts make up each model. Each entry has a header (the model + BOM) and a list of components (the parts). If you send the same model and BOM again, we replace that list with the new one — so you never get duplicates. Required: modelNo and bomNo in the header, plus cpartno and childQty on each component. Everything else is optional — including the BOM date, model/child drawing numbers, revision, material (MOC), and parent part & units — so this single feed now carries the full BOM.
Your .json file should contain a mappings list, like this:
{
"source": "SAP-ECC",
"externalRef": "MBM-90010001",
"mappings": [
{
"modelNo": "26DA10-120", // required -> model_no (upsert key with bomNo)
"bomNo": "90010001", // required -> bom_no (upsert key with modelNo)
"bomDate": "2020-06-15", // -> bom_date
"modelDrgNo": "26DA10-120-001N3L", // -> model_drg_no
"productName": "GLOBE VALVE 2\" 300#", // -> product_name
"components": [
{
"cpartno": "3BA24800X3M-001R1T7B", // required -> cpartno (drives match keys)
"childQty": 1, // required -> child_qty
"childName": "BODY", // -> child_name
"childCode": "70060257", // -> child_code
"parentCode": "26DA10-120", // -> parent_code
"parentPartno":"926DA10-120", // -> parent_partno
"parentUnit": "NOS", // -> par_unit
"childUnit": "NOS", // -> chl_unit
"childMoc": "SA 216 Gr. WCB", // -> child_moc
"cdrgno": "BA24800X3M", // -> cdrgno (child drawing no)
"childDrgRev": "A" // -> child_drg_rev
}
]
}
]
}- Scope
- Your key needs
ingest:model-bom-map. Without it you get403. - Rows
- Up to 5,000 — counted as the total components in the file, not the number of mappings. 100 models with 60 components each is 6,000 and gets
400. - File
- One
.jsonfile, up to 50 MB. Bigger gets413. An inline JSON body is never accepted —400. - Rate
- 120 requests per minute per key. Over that gets
429with aRetry-Afterheader. - Repeat sends
- Replaces, not adds. Every
(modelNo, bomNo)you send has its existing components deleted and rewritten from your file.
Before storing a record we check whether we already hold an identical one for your company. We compare all 16 fields below — every one of them, not just the required ones:
modelNobomNoparentCodechildCodeproductNamechildNamecpartnochildQtychildUnitbomDatemodelDrgNoparentPartnoparentUnitchildMoccdrgnochildDrgRevAll 16 match an existing record → we keep the one we have and skip yours. Not an error: the reply counts it under duplicates.
Any single field differs → it is a different record and is stored as a new one, counted under inserted.
Comparison ignores upper/lower case, leading and trailing spaces, and number formatting (231 and 231.00 are the same value). Dates are compared as calendar days, so 2026-07-30 and 30-07-2026 are the same date. This is the identical check the Excel upload runs, so a record loaded from a spreadsheet is recognised when you push it here, and the other way round.
Before that check runs, one thing happens first for this endpoint. A BOM is the current parts list for a model, not a history, so a re-send is a correction rather than an addition. For every modelNo + bomNo in your file we delete the rows we hold for that pair, then store yours. The reply's replaced count tells you how many were removed.
So in practice the comparison above only matters within a single file — two identical component rows in the same payload collapse into one. Across sends, the replace has already cleared the old rows, so nothing is left to match against.
Note productName is compared as the row's parent name, which we work out from your parentCode links — not the header productName you send.
Every field, explained
| Field you send | Where | Saved as | Needed? |
|---|---|---|---|
modelNo | header | model_no | required |
bomNo | header | bom_no | required |
bomDate | header | bom_date | optional |
modelDrgNo | header | model_drg_no | optional |
productName | header | product_name | optional |
cpartno | component | cpartno | required |
childQty | component | child_qty | required |
childName | component | child_name | optional |
childCode | component | child_code | optional |
parentCode | component | parent_code | optional |
parentPartno | component | parent_partno | optional |
parentUnit | component | par_unit | optional |
childUnit | component | chl_unit | optional |
childMoc | component | child_moc | optional |
cdrgno | component | cdrgno | optional |
childDrgRev | component | child_drg_rev | optional |
Want to see what you sent earlier? These two let you look it up. Both only ever show your own company's data.
Your recent sends, newest first. You can filter with dataset, page, and limit.
// GET /ingest/history
{
"status": 1,
"data": [
{
"id": 5012,
"dataset_type": "model_bom_map",
"status": "completed", // completed | partial | failed
"total_rows": 120,
"inserted_rows": 116,
"duplicate_rows": 1,
"error_rows": 3,
"source": "api",
"started_at": "2026-07-16T09:51:57.125Z",
"finished_at": "2026-07-16T09:51:57.125Z",
"created_at": "2026-07-16T09:51:56.944Z"
}
// …most recent first
],
"message": "Success"
}- Scope
- Any active key works — no dataset scope needed.
- Paging
pagestarts at 1 (default 1).limitis 1–100 (default 50). Outside that gets400.- Filter
datasetis optional and must be one ofmrrormodel_bom_map.- Shows
- Only sends made through this API, for your own company. Excel uploads made in the dashboard don't appear here.
- Rate
- 120 requests per minute per key. Over that gets
429with aRetry-Afterheader.
The details of one send — how many rows went in and which ones had problems. Use the ingestId that came back when you sent it.
// GET /ingest/5012
{
"status": 1,
"data": {
"id": 5012,
"dataset_type": "model_bom_map",
"status": "partial",
"total_rows": 120,
"inserted_rows": 116,
"duplicate_rows": 1,
"error_rows": 3,
"last_error": "3 row(s) rejected during ingest.",
"error_samples": [
{ "index": 44, "ref": "26DA10-120/90010001#2", "error": "…" }
],
"source": "api",
"created_at": "2026-07-16T09:51:56.944Z"
},
"message": "Success"
}- Scope
- Any active key works — no dataset scope needed.
- ingestId
- A positive whole number. Anything else gets
400. - Shows
- Only sends made through this API, for your own company. An id belonging to an Excel upload returns
404, even though it's your own. - Error list
errorsholds at most 25 examples, even when more rows were rejected —rejectedhas the true count.- Rate
- 120 requests per minute per key. Over that gets
429with aRetry-Afterheader.
If your request is well-formed, you always get a 200 OK with a summary. Here's the nice part: if a few rows have problems, we still save the good ones and simply list the bad ones — we don't throw the whole batch away. The outcome tells you how it went:
- completed — every row went in.
- partial — most rows went in; a few are listed under
errors. - failed — nothing went in (check
errors).
{
"status": 1,
"data": {
"ingestId": 5012,
"correlationId": "b3f1a9e2-…",
"dataset": "model_bom_map",
"outcome": "partial", // completed | partial | failed
"received": 120,
"inserted": 116,
"replaced": 3, // mapping only (rows overwritten)
"duplicates": 1,
"rejected": 3,
"errors": [ // capped at 25 samples
{ "index": 44, "ref": "26DA10-120/90010001#2", "error": "…" }
]
},
"message": "116 inserted, 3 replaced, 3 rejected"
}When something is wrong with the request itself
These are about the request, not individual rows:
| What happened | You get |
|---|---|
| The JSON is malformed or a field is the wrong type | 400 |
No .json file attached (an inline JSON body isn't accepted) | 400 |
| Key is missing, wrong, or revoked | 401 |
| Key isn't allowed to send this kind of data | 403 |
| More than 5,000 rows in one file | 400 |
| File is bigger than 50 MB (see limits) | 413 |
| Too many requests too fast | 429 |
| Something broke on our side | 500 |
Every reply includes an X-Request-Id — share it with us if you ever need help tracing a request.
Make a key with the ingest:mrr permission, save your data as mrr.json, then upload the file:
curl -X POST "https://api.letsworkwise.com/api/v1/vcost-estimation/ingest/mrr" \
-H "Authorization: Bearer wvce_live_xxxxxxxx_………" \
-F "file=@mrr.json;type=application/json"Where mrr.json is a file containing:
{
"source": "SAP-ECC",
"records": [
{
"cpartno": "3BA24800X3M-001R1T7B", // optional -> cpartno (drives match keys)
"irate": 231.00, // required -> irate (the vendor rate)
"supplier": "INTRICAST PVT. LTD.", // required -> supplier
"dated": "2025-10-27", // required -> dated (drives recency; see Good to know)
"type": "02", // required -> type (02/07 = complete buy)
"mrrNo": "001864", // -> mrr_no
"itemName": "BODY", // -> item_name
"unit": "NOS", // -> unit
"poNo": "PO-2025-1180", // -> p_o_no
"icode": 70060257, // -> icode
"billChl": "BILL", // -> bill_chl
"advised": 10, // -> advised
"rcvd": 10, // -> rcvd
"accept": 10, // -> accept
"reject": 0, // -> reject
"lc": 0, // -> lc
"tcNo": "TC-1180", // -> tc_no
"excDoc": "", // -> exc_doc
"testCert": "TC/2025/114", // -> test_cert
"finvno": "INV-8842", // -> finvno
"wono": "WO-551", // -> wono
"gateEntry": "GE-9931", // -> gate_entry
"gateDate": "2025-10-25", // -> gate_date
"entBy": "RAVI", // -> ent_by
"inspBy": "SATISH", // -> insp_by
"rwCond": "OK", // -> rw_cond
"qcDate": "2025-10-26", // -> qcdate
"store": "MAIN", // -> store
"mtime": 0, // -> mtime
"invDate": "2025-10-24", // -> invdate
"cdrgno": "24800X3", // -> cdrgno
"revNo": "R1", // -> rev_no
"exchRate": 1, // -> exch_rate
"awb": "", // -> awb
"modeTpt": "ROAD", // -> mode_tpt
"poDate": "2025-10-01", // -> podate
"boeNo": "" // -> boe_no
}
]
}