On this page
Overview
All endpoints live under one base URL. Every request and response is JSON, except file uploads, which also accept multipart.
https://erp-agent.com/api/v1
Offer generation is asynchronous. A typical request takes three to eight minutes, and a large one — several hundred line items — can take half an hour; fast mode cuts a typical request to one to three minutes. POST /v1/offers therefore returns 202 Accepted with an offer id the moment the run starts; the offer itself does not exist yet.
Request bodies are capped at roughly 4 MB on this host. That is ample for text and a typical RFQ, but attachments above it must go through POST /v1/files, which hands back a pre-authorized upload that bypasses the API entirely — see Files.
Authentication
Authenticate with the API key issued to your organization, as a bearer token. Keys are scoped to exactly one organization and cannot read another tenant’s data.
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
Treat the key as a password: it is shown once at creation and stored only as a hash, so it cannot be recovered — a lost key has to be revoked and replaced. Never put it in browser code.
Check a key with GET /v1/me, which returns the organization it belongs to, its scopes and its rate limit.
Quickstart
Send a request in the customer’s own words, attach the RFQ if you have one, and poll until the status is terminal.
curl -X POST https://erp-agent.com/api/v1/offers \
-H "Authorization: Bearer $ERP_API_KEY" \
-F "text=Tarvitsemme 20 kpl 16mm2 kuparikaapelia ja 50 kpl KAA 6x1.5" \
-F "files=@rfq.pdf"
{
"id": "9f1c0e2a-7b44-4a1e-9a1f-2f0d5c8e1b33",
"object": "offer",
"status": "processing",
"created_at": "2026-08-10T09:12:04Z",
"dashboard_url": "https://erp-agent.com/agents/offers/9f1c0e2a-…"
}curl https://erp-agent.com/api/v1/offers/9f1c0e2a-7b44-4a1e-9a1f-2f0d5c8e1b33 \ -H "Authorization: Bearer $ERP_API_KEY"
import time, requests
BASE = "https://erp-agent.com/api/v1"
headers = {"Authorization": f"Bearer {API_KEY}"}
created = requests.post(
f"{BASE}/offers",
headers=headers,
data={"text": customer_email_body, "customer_number": "K-10042"},
files=[("files", ("rfq.xlsx", rfq_bytes))],
).json()
offer_id = created["id"]
PENDING = {"processing", "awaiting_approval", "sending"}
while True:
offer = requests.get(f"{BASE}/offers/{offer_id}", headers=headers).json()
if offer["status"] not in PENDING:
break
time.sleep(15)
if offer["status"] == "failed":
raise RuntimeError(offer["error"]["code"])
for line in offer["result"]["line_items"]:
print(line["product_code"], line["quantity"], line["unit_price"])Create an offer
/v1/offersAccepts multipart/form-data (text and files in one request) or application/json (text plus file ids you uploaded earlier). Returns 202 with the offer id.
| Field | Type | Description |
|---|---|---|
| text | string | The customer's request in their own words — paste the enquiry email verbatim. Required unless you attach files. Up to 100,000 characters. |
| files | file[] | Attachments, multipart only. Up to 20 files. PDF, XLSX, XLS, CSV, DOC, DOCX, PPTX, TXT, JSON, XML, EML, MSG and images. 25 MB each, but the whole request is capped at ~4 MB — use POST /v1/files for anything larger. |
| file_ids | string[] | Ids returned by POST /v1/files, for files uploaded beforehand. |
| customer_number | string | Optional ERP customer number. Given one, the agent looks the customer up directly; without one it identifies the customer from the request itself. |
| customer_name | string | Optional hint used when no customer number is supplied. |
| request_type | string | "offer" (default) or "order". |
| fast_mode | boolean | false (default) searches thoroughly and can take up to 30 minutes on a long request; true returns a draft in a few minutes with a shorter search. Recommended for multi-line RFQs. See Fast mode. |
| callback_url | string | HTTPS endpoint to receive a signed webhook when the run finishes. Removes the need to poll. |
| reference | string | Your own identifier, echoed back on every read. |
| metadata | object | Arbitrary key/value data, stored and echoed back. |
Send an Idempotency-Key header to make retries safe — see Rate limits & idempotency.
curl -X POST https://erp-agent.com/api/v1/offers \
-H "Authorization: Bearer $ERP_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-88213" \
-d '{
"text": "Please quote the attached bill of materials.",
"file_ids": ["8b2f…", "1c7a…"],
"customer_number": "K-10042",
"callback_url": "https://example.com/hooks/erp-agent",
"reference": "RFQ-88213"
}'Fast mode
By default the agent searches the catalogue line by line until it is satisfied with each match. Fast mode trades part of that search for speed: every line is matched against the catalogue in one pass, the agent takes a short, bounded look at the lines that need it, and a single final step decides all lines together. It is the same switch as the Fast option on the dashboard’s new-quote page.
A request of up to about 50 lines as text, a spreadsheet or a Word document typically finishes in one to three minutes. Reading a scanned or image-heavy PDF takes as long as it does in normal mode, so those requests gain less.
The cost is coverage, not format. The response is identical in both modes, but a line the agent cannot settle within the shorter search comes back with matched: false instead of being searched further. Use it when a person reviews the draft anyway or when turnaround matters most; leave it off for large or unusual requests where a complete first pass saves more time than it costs.
curl -X POST https://erp-agent.com/api/v1/offers \
-H "Authorization: Bearer $ERP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "2 kpl KSO-100 poistoilmaventtiili", "fast_mode": true}'curl -X POST https://erp-agent.com/api/v1/offers \ -H "Authorization: Bearer $ERP_API_KEY" \ -F "fast_mode=true" \ -F "files=@rfq.xlsx"
fast_mode takes a JSON boolean; in multipart, send "true" or "false". Any other value is rejected with 400 invalid_fast_mode rather than silently running the slow path. Leaving it out means normal mode. The choice is echoed back as request.fast_mode on GET /v1/offers/{id}.
Retrieve an offer
/v1/offers/{id}One call returns both the status and, once the run has finished, the complete result: the customer that was resolved, every line item with its matched product code and price, and the ERP offer number if the offer has been sent.
{
"id": "9f1c0e2a-7b44-4a1e-9a1f-2f0d5c8e1b33",
"object": "offer",
"status": "completed",
"error": null,
"created_at": "2026-08-10T09:12:04Z",
"completed_at": "2026-08-10T09:17:41Z",
"request": {
"text": "Tarvitsemme 20 kpl 16mm2 kuparikaapelia…",
"request_type": "offer",
"fast_mode": false,
"customer_number": "K-10042",
"file_ids": ["8b2f…"],
"reference": "RFQ-88213",
"metadata": {}
},
"customer": {
"number": "K-10042",
"name": "Sähkötukku Oy",
"email": "hankinta@sahkotukku.fi",
"resolved": true
},
"result": {
"line_items": [
{
"position": 1,
"product_code": "MCM-CU16-100",
"product_name": "MCMK 16mm² kuparikaapeli 100m",
"requested_term": "16mm2 kuparikaapelia",
"customer_product_code": null,
"quantity": 20,
"unit": "kpl",
"unit_price": 12.5,
"discount_percent": 0,
"line_total": 250.0,
"vat_rate": 25.5,
"matched": true,
"confidence": 95,
"match_reasoning": "Exact match on cross-section and length."
}
],
"line_count": 2,
"matched_count": 2,
"unmatched_count": 0,
"average_confidence": 93,
"total_amount": 812.5,
"currency": "EUR",
"offer_number": null,
"erp_offer_number": null,
"warnings": [],
"quality": {
"degraded": false,
"reasons": [],
"all_lines_unmatched": false,
"undecided_lines": 0,
"finalizer_chunks_timed_out": 0
}
},
"dashboard_url": "https://erp-agent.com/agents/offers/9f1c0e2a-…"
}result is null until an offer exists — that is, for processing, awaiting_approval and failed. It is populated for every other status. updated_at tracks the last change to the offer and moves after you receive it — a reviewer editing lines in the dashboard bumps it. Lines the agent could not match come back with matched: false and a null product code, keeping the original requested_term so you can route them to a human. confidence is 0–100, not a probability.
List offers
/v1/offersEvery offer your organization created through the API, newest first. This is the recovery path: if a poll is lost to a deploy or a crash, you can always find your runs again here.
| Field | Type | Description |
|---|---|---|
| limit | integer | 1–100, default 25. |
| status | string | Return only offers in this status. |
| created_after | string | ISO-8601 timestamp; only offers created after it. |
| cursor | string | next_cursor from the previous page. |
{
"object": "list",
"data": [ { "id": "…", "status": "completed", … } ],
"has_more": true,
"next_cursor": "MjAyNi0wOC0xMFQwOToxMjowNFo="
}Files
/v1/filesTwo ways to attach a document. Send the bytes directly as multipart, or ask for a presigned URL and upload straight to storage — use the second for anything above about 4 MB, since the direct path is capped by the edge in front of the API.
curl -X POST https://erp-agent.com/api/v1/files \
-H "Authorization: Bearer $ERP_API_KEY" \
-F "file=@rfq.pdf"
{ "id": "8b2f…", "object": "file", "filename": "rfq.pdf", "size": 214114 }# 1. reserve
curl -X POST https://erp-agent.com/api/v1/files \
-H "Authorization: Bearer $ERP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename":"bom.xlsx","content_type":"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet","size":18300000}'
{
"id": "1c7a…",
"upload_url": "https://…",
"upload_fields": { "key": "…", "policy": "…", "x-amz-signature": "…" },
"upload_expires_in": 900
}
# 2. POST the bytes as a form: every upload_fields entry first, file last
curl -X POST "$UPLOAD_URL" \
-F key=... -F policy=... -F x-amz-signature=... \
-F file=@bom.xlsx
# 3. reference the id
# POST /v1/offers { "file_ids": ["1c7a…"], … }A note that saves debugging time: photographs are not read. PDFs are OCR’d, spreadsheets and documents are parsed, but a JPG or PNG of a paper enquiry yields nothing usable. Send the source document.
Statuses
Three statuses are non-terminal — processing, awaiting_approval and sending. Keep polling on any of them; the rest are final.
| Field | Description |
|---|---|
| processing | The agent is working. Keep polling, or wait for the webhook. |
| completed | The offer is ready and result is populated. |
| awaiting_approval | Very large request paused for human confirmation in the dashboard. It resumes once someone approves it. |
| sending | Someone is pushing the offer into the ERP right now. |
| sent | Pushed to the ERP; erp_offer_number is populated. |
| send_failed | The offer exists but the ERP rejected the push. See error. |
| rejected | A reviewer declined the request. |
| deleted | The offer was deleted. |
| failed | The run did not produce an offer. See error.code. |
Webhooks
Set callback_url when creating an offer and we POST the finished offer to it — the same body GET /v1/offers/{id} returns, wrapped in an event. Delivery is retried twice on failure, after 5 and 30 seconds.
POST https://example.com/hooks/erp-agent
X-ERP-Event: offer.completed
X-ERP-Signature: t=1786633061,v1=6a3f…
{ "event": "offer.completed", "data": { "id": "9f1c…", "status": "completed", … } }Verify the signature before trusting the payload. It is an HMAC-SHA256 of "{timestamp}.{raw body}" using the webhook secret issued with your key. Including the timestamp is what makes a captured delivery non-replayable, so reject anything older than a few minutes.
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp, signature = int(parts["t"]), parts["v1"]
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)Webhooks complement polling rather than replacing it. If your endpoint is down for all three attempts the delivery is dropped — reconcile with GET /v1/offers.
Errors
Errors use conventional HTTP status codes and always carry the same envelope. The request_id is also returned as an X-Request-Id header — quote it in support requests.
{
"error": {
"type": "invalid_request_error",
"code": "missing_input",
"message": "Provide 'text' describing the request, one or more files, or both.",
"request_id": "req_4f2a9c1e7b3d8a5f6c0e2d14"
}
}| Field | Description |
|---|---|
| 400 | The request was malformed. See error.code. |
| 401 | Missing, invalid or revoked API key. |
| 403 | The key lacks the required scope. |
| 404 | No such offer for this organization. |
| 409 | idempotency_in_progress — an identical request with this Idempotency-Key is still being accepted. Retry in a moment. |
| 413 | A file exceeded 25 MB, or the whole request exceeded the body cap. |
| 429 | Rate limit exceeded, or this key already has 5 runs in progress (too_many_concurrent_runs). Back off and retry. |
| 5xx | Our fault. Retry with backoff. |
A failed offer is not an HTTP error — the request succeeded, the run did not. Read error.code on the offer: run_failed (the agent errored), run_lost (it was interrupted before producing anything — resubmit), no_offer_produced (it finished without extracting any products), cancelled, start_failed, or — on the send_failed status — erp_send_failed.
Error messages are stable, generic strings; the underlying detail stays in our logs. Branch on error.code, never on the message text.
Rate limits & idempotency
Each key has a per-minute request limit, returned on successful responses as RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (error responses carry only X-Request-Id). Exceeding it returns 429. The default is 60 requests per minute and can be raised per key.
Separately, one key may have 5 offer runs in progress at a time. Starting a sixth returns 429 too_many_concurrent_runs — queue your work rather than firing a batch at once.
Because creating an offer starts real work, retries need to be safe. Send an Idempotency-Key header — any unique string, typically your own order or enquiry id — and a repeat of the same request within 24 hours returns the original offer with 200 and an Idempotent-Replay: true header instead of starting a second run.
Need a higher limit or a sandbox?
Email lauri@erp-agent.com and we’ll set it up.