Skip to content

API v1

Offer API for quotes and sales orders

Turn a customer’s request — free text, an RFQ spreadsheet, a PDF enquiry — into a priced offer with matched product codes. Post the request, get an id back immediately, then poll or receive a webhook when the offer is ready.

On this page

Overview

All endpoints live under one base URL. Every request and response is JSON, except file uploads, which also accept multipart.

Base URL
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 header
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.

1. Create an offer
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-…"
}
2. Poll until it finishes
curl https://erp-agent.com/api/v1/offers/9f1c0e2a-7b44-4a1e-9a1f-2f0d5c8e1b33 \
  -H "Authorization: Bearer $ERP_API_KEY"
Python
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

POST/v1/offers

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

FieldTypeDescription
textstringThe customer's request in their own words — paste the enquiry email verbatim. Required unless you attach files. Up to 100,000 characters.
filesfile[]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_idsstring[]Ids returned by POST /v1/files, for files uploaded beforehand.
customer_numberstringOptional ERP customer number. Given one, the agent looks the customer up directly; without one it identifies the customer from the request itself.
customer_namestringOptional hint used when no customer number is supplied.
request_typestring"offer" (default) or "order".
fast_modebooleanfalse (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_urlstringHTTPS endpoint to receive a signed webhook when the run finishes. Removes the need to poll.
referencestringYour own identifier, echoed back on every read.
metadataobjectArbitrary key/value data, stored and echoed back.

Send an Idempotency-Key header to make retries safe — see Rate limits & idempotency.

JSON request with pre-uploaded files
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.

JSON
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}'
Multipart
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

GET/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.

200 OK — completed
{
  "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

GET/v1/offers

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

FieldTypeDescription
limitinteger1–100, default 25.
statusstringReturn only offers in this status.
created_afterstringISO-8601 timestamp; only offers created after it.
cursorstringnext_cursor from the previous page.
200 OK
{
  "object": "list",
  "data": [ { "id": "…", "status": "completed", … } ],
  "has_more": true,
  "next_cursor": "MjAyNi0wOC0xMFQwOToxMjowNFo="
}

Files

POST/v1/files

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

Direct upload
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 }
Presigned upload (large files)
# 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.

FieldDescription
processingThe agent is working. Keep polling, or wait for the webhook.
completedThe offer is ready and result is populated.
awaiting_approvalVery large request paused for human confirmation in the dashboard. It resumes once someone approves it.
sendingSomeone is pushing the offer into the ERP right now.
sentPushed to the ERP; erp_offer_number is populated.
send_failedThe offer exists but the ERP rejected the push. See error.
rejectedA reviewer declined the request.
deletedThe offer was deleted.
failedThe 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.

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

Verifying in Python
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 response
{
  "error": {
    "type": "invalid_request_error",
    "code": "missing_input",
    "message": "Provide 'text' describing the request, one or more files, or both.",
    "request_id": "req_4f2a9c1e7b3d8a5f6c0e2d14"
  }
}
FieldDescription
400The request was malformed. See error.code.
401Missing, invalid or revoked API key.
403The key lacks the required scope.
404No such offer for this organization.
409idempotency_in_progress — an identical request with this Idempotency-Key is still being accepted. Retry in a moment.
413A file exceeded 25 MB, or the whole request exceeded the body cap.
429Rate limit exceeded, or this key already has 5 runs in progress (too_many_concurrent_runs). Back off and retry.
5xxOur 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.