Developer docs

TenderBuilder API

Specialist tools for Irish public-sector tenders, callable by any AI agent over REST or MCP. This is the full reference; the machine-readable spec is at the OpenAPI link below.

Specialist tools for Irish public-sector tenders, callable by any AI agent.

You keep your existing AI subscription (ChatGPT, Claude, an internal agent, LangGraph, CrewAI) and connect these tools to it. Your model does the reasoning and orchestration; these tools do the accurate Irish-procurement work: matching live eTenders notices to a company, extracting requirements and evaluation criteria from a buyer's pack, drafting responses that map to the scoring, and checking compliance before submission.

  • Base URL: https://tenderbuilder.ie/api/v1
  • Machine-readable spec: GET /openapi.json (no auth needed)
  • MCP endpoint: POST /mcp

Throughout this guide, every path is written relative to the base URL. When you see POST /match, the full URL is https://tenderbuilder.ie/api/v1/match. Paths are never site-relative: there is no /v1/... route at the domain root, only under /api/v1.

Quickstart: confirm your key first

Before any paid call, run GET /me. It is free, needs no scope, and touches no credits, it just tells you who the key acts as and what budget it has. If this works, your key and base URL are correct.

curl https://tenderbuilder.ie/api/v1/me \
  -H "Authorization: Bearer tb_live_..."

Success looks like this (the envelope is described under Response shape):

{
  "data": {
    "tenant_id": "…",
    "tier": "free",
    "has_active_subscription": false,
    "credits": {
      "monthly_remaining": 0,
      "purchased": 0,
      "total": 0,
      "period_end": null,
    },
    "api_units": {
      "allowance": 25,
      "used": 0,
      "remaining": 25,
      "resets_at": "…",
    },
    "key": { "name": "My first key", "scopes": ["match", "analyse"] },
    "auth": "api_key",
  },
  "meta": { "units_remaining": 25, "generated_at": "…" },
}

GET /me is the authoritative source for your live budget. Any figure in this document is a default that can change; /me is what actually applies right now.

Authentication

Create a key at Profile → API keys. Only workspace admins can create keys: if you are on a member seat, ask an admin to mint one for you. A key is shown once and stored only as a hash, so it cannot be recovered; lost keys are rotated, not recovered.

Authorization: Bearer tb_live_<prefix>_<secret>

Keys act for a whole workspace. Keep them server-side. There is deliberately no CORS on the API: a key in a browser is a key in the hands of anyone who opens devtools.

Each key carries scopes (match, analyse, draft, pack) and can only do what you tick when creating it. Grant the least a caller needs. Every endpoint that needs a scope is listed with it in the table below; an endpoint refuses with scope_missing (403) when the key lacks its scope.

Response shape

Every JSON response uses the same envelope, so a model parses one contract:

{
  "data": {
    /* the answer */
  },
  "meta": { "units_charged": 3, "units_remaining": 97, "generated_at": "…" },
  "warnings": [{ "code": "stale_listings", "message": "…" }],
}

meta.units_charged and meta.units_remaining appear on calls that meter units; generated_at is always present. warnings is present only when there is something to warn about.

**Read the warnings array.** It is where we say what we did _not_ do: results truncated, a corpus row not refreshed recently, an empty result that means "not run yet" rather than "nothing found". Ignoring warnings is how an agent confidently reports a wrong answer.

Errors:

{
  "error": { "code": "quota_exhausted", "message": "…", "retryable": false },
  "meta": { "generated_at": "…" },
}

code is stable and safe to branch on. retryable tells you whether the same request could succeed later without changing anything.

CodeHTTPRetryableMeaning
no_credentials401noNo API key supplied.
invalid_key401noKey is malformed or unknown.
revoked_key401noKey was revoked.
scope_missing403noKey lacks the scope this endpoint needs.
forbidden403noAccess withdrawn (e.g. after a refund). Buying credits does not lift it.
not_found404noNo such resource in this workspace.
invalid_request400noBad parameters, or a required earlier step has not happened.
quota_exhausted402noMonthly API units spent. Resets at the start of the next UTC month.
no_credits402noNo tender credit (or free assessment) left for a run. See What things cost.
rate_limited429yesToo fast. Honour Retry-After.
api_paused503yesTemporary maintenance pause.
internal503yesOur fault. Retry with backoff.

What things cost

Two separate currencies, deliberately:

  • Tender credits: what the existing product already uses. One is consumed when a drafting run finalises. Unchanged by the API; the API cannot undercut a plan because it adds no new spend path.
  • API units: a derived monthly allowance for the cheap AI endpoints, counted per workspace over the UTC calendar month. GET /me reports both.
EndpointUnitsScopeNotes
GET /me0_(none)_Your identity and budget. Free, scope-free.
GET /tenders/search0_(none)_Public procurement data we index. Free, rate-limited.
POST /profile/derive1matchOne AI call. Reuse the result.
POST /match3matchFlat, whether or not you pass a pre-derived profile.
POST /tenders0analyseCreate a project.
GET /tenders0analyseList your projects.
POST /tenders/{id}/documents0analyseUpload the buyer's pack (multipart, SMALL files).
POST /tenders/{id}/documents/upload-url0analyseStep 1 of the signed-URL flow for large files.
POST /tenders/{id}/documents/confirm0analyseStep 2: register the uploaded files.
POST /tenders/{id}/documents/fetch0analyseAdd a document by https URL (SSRF-guarded).
GET /tenders/{id}/documents0analyseList uploaded documents.
POST /tenders/{id}/analyse0analyseStart analysis. May 402 (see below).
GET /tenders/{id}/requirements0analyseExtracted criteria, questions, gaps.
GET /tenders/{id}/brief0analyseBid/no-bid recommendation.
GET /runs/{id}0analysePoll a run: analyse OR draft (see note).
POST /tenders/{id}/decision0draftRecord the bid decision. Unlocks drafting.
POST /tenders/{id}/draft0\*draftConsumes one tender credit, not units.
GET /tenders/{id}/answers0analyseDrafted answers.
GET /tenders/{id}/compliance0analyseCompliance checks.
GET /tenders/{id}/pack0packThe assembled response zip.
POST /tenders/{id}/review3analyseGrade up to 5 of your own answers. Batch them.

\* POST /tenders/{id}/draft costs 0 API units but consumes one tender credit. GET /runs/{id} requires the analyse scope even when it is polling a _draft_ run: one poll endpoint serves both, and it carries the read scope.

Free assessments, and how analyse can 402

POST /tenders/{id}/analyse is not simply "free". It tries the paid path first (reserving a tender credit and enqueuing a full run) and only if you have no credit does it fall back to a capped free assessment. Free assessments are limited to 3 per workspace per UTC calendar month. So analyse returns 402 no_credits when:

  • you have no tender credit and have already used your 3 free assessments this month; or
  • a bid decision is already recorded on the tender, so the next run would be a paid _drafting_ run (never free) and no credit is available.

If the paid reservation succeeds, the run is a full analysis. If it falls back, you get a free assessment (a lighter brief). Either way the response tells you which path ran; do not assume analyse is always free.

A no_credits (402) message names the fix and links it: it points at https://tenderbuilder.ie/billing to add credits. POST /tenders/{id}/draft returns the same no_credits (402) with that billing link when no tender credit is available. Buying credit does not lift a forbidden (403) or a quota_exhausted (402): those are separate.

Monthly API-unit allowance by tier

These are the env defaults. They are commercial figures and are subject to change: **the authoritative figure is GET /me; treat this table as a guide.**

TierAPI units / month
Free25
Starter100
Business300
Founding300
Agency1000

Units are counted per workspace, not per key: creating extra keys does not buy more allowance. The allowance resets at the start of each UTC calendar month.

The workflow

The order matters. Skipping a step returns empty results that look like answers.

match ─→ create tender ─→ upload documents ─→ analyse ─┐
                                                        │ (poll)
        requirements / brief ←──────────────────────────┘
                    │
          record decision (a human decides)
                    │
                 draft ─┐
                        │ (poll)
     answers / compliance / pack ←─┘

1. Find tenders worth bidding on

curl -X POST https://tenderbuilder.ie/api/v1/match \
  -H "Authorization: Bearer $TB_KEY" -H "Content-Type: application/json" \
  -d '{"description":"We are a Dublin-based managed IT services provider serving schools and healthcare clients across Leinster: network support, Microsoft 365, cybersecurity and helpdesk."}'

Returns matches with verdict (strong or possible) and a one-line reason. This is a shortlist for a human, not a decision.

2. Start a project and upload the buyer's pack

curl -X POST https://tenderbuilder.ie/api/v1/tenders \
  -H "Authorization: Bearer $TB_KEY" -H "Content-Type: application/json" \
  -d '{"listing_id":"<id from match>"}'

curl -X POST https://tenderbuilder.ie/api/v1/tenders/$TENDER/documents \
  -H "Authorization: Bearer $TB_KEY" \
  -F "files=@ITT.pdf" -F "files=@response-template.docx"

Upload is multipart. See Uploading documents below for the size ceiling and the signed-URL flow for large files.

3. Analyse, and poll

curl -X POST https://tenderbuilder.ie/api/v1/tenders/$TENDER/analyse \
  -H "Authorization: Bearer $TB_KEY"
# → 202 { "run": { "run_id": "…", "status": "queued", "poll_after_ms": 20000 } }

curl https://tenderbuilder.ie/api/v1/runs/$RUN -H "Authorization: Bearer $TB_KEY"

Analysis and drafting are asynchronous, permanently. A big tender pack takes minutes and the harness checkpoints internally, pausing and resuming across several segments. That is why a run has its own id rather than a job id: one logical run spans several internal jobs, and reporting the first one's status would say succeeded while drafting was still going.

Wait at least poll_after_ms between polls. Continuations wait on a scheduler tick, so polling faster learns nothing. When poll_after_ms is null, the run is finished: status is then succeeded or failed.

status is derived from every job in the run chain, not a single job row, so a run that checkpointed mid-way still reports running rather than a premature succeeded. On failed, the run's error field says why, and a run_failed warning carries the same message. One failed case is worth knowing: if you start a newer run for the same tender before an earlier one finishes, the earlier run is superseded and reports failed with a message telling you to start a fresh run and poll the id it returns, never a false succeeded.

4. Read what was found

GET /tenders/{id}/requirements: award criteria and weightings, the scored questions with word limits, mandatory documents, pass/fail thresholds, gaps.

GET /tenders/{id}/brief: the bid/no-bid recommendation and its reasoning.

If assessed is false or brief is null, the analysis has not finished. That is not "this tender has no requirements".

5. Decide, then draft

The decision is its own call, and that is deliberate:

curl -X POST https://tenderbuilder.ie/api/v1/tenders/$TENDER/decision \
  -H "Authorization: Bearer $TB_KEY" -H "Content-Type: application/json" \
  -d '{"decision":"bid"}'

This is where a business commits to bidding, and it is what unlocks paid drafting. An agent should not call it without an explicit instruction from the person it works for.

Then POST /tenders/{id}/draft (consumes a tender credit, 5-30 minutes, poll as before), and read GET /tenders/{id}/answers, GET /tenders/{id}/compliance, and GET /tenders/{id}/pack for the assembled zip.

6. Or: write it yourself and have us grade it

If your model writes the answers, POST /tenders/{id}/review is the branch off the workflow above. You need steps 1-4 (the tender has to have been analysed: with no extracted questions there is nothing to check against, and you get a 400 naming analyse rather than an empty pass), but not the decision, not the drafting run and not a tender credit.

curl -X POST https://tenderbuilder.ie/api/v1/tenders/$TENDER/review \
  -H "Authorization: Bearer $TB_KEY" -H "Content-Type: application/json" \
  -d '{"answers":[{"question_id":"<id from /requirements>","draft":"Our survey methodology follows HSA guidance…"}]}'

Three checks per answer:

  • Word limit: deterministic, and a hard fail. Counted by the same formula we draft to, so this and GET /tenders/{id}/compliance cannot disagree about a count. A page limit is not checked: pages depend on the font, margins and template of the document you finally submit.
  • Coverage: every distinct thing the buyer's question and its award criterion ask for, marked covered or missed against your text.
  • Grounding: every concrete claim your text makes, checked against your company profile. This is the one your own model cannot do: it does not have your profile, and we never send it back.

Each answer comes back with a rating, the specific problems found and a fix for each. **Read assessed and checks, not the length of problems.** assessed: false means a check did not run: the problems listed are what we managed to find, not a clean bill of health, and rating is null for exactly that reason. A fail still stands after a skipped check, because more looking can only find more problems. A question_id that is not on this tender comes back in not_on_this_tender rather than being dropped.

3 units flat for up to 5 answers, so batch them. Nothing you send is stored. It is our judgement of your text, not the buyer's mark.

Uploading documents

There are three ways to get a buyer's document onto a tender, all requiring the analyse scope and all costing 0 units. Per tender the caps are the same across all three: at most 40 files, 75 MB per file, and 300 MB in total. They are enforced against what is already on the tender before a new file is accepted.

Small files: multipart

POST /tenders/{id}/documents takes multipart/form-data with one or more files parts.

curl -X POST https://tenderbuilder.ie/api/v1/tenders/$TENDER/documents \
  -H "Authorization: Bearer $TB_KEY" \
  -F "files=@ITT.pdf" -F "files=@response-template.docx"

Multipart is capped at 4 MB per request. The serverless platform hard-caps the whole request body at ~4.5 MB before it ever reaches our code, so this path enforces a 4 MB per-request budget and rejects anything bigger with a clean invalid_request (400) that points at the signed-URL flow. A realistic ITT pack (5-50 MB PDFs) will not fit here, so use the signed-URL flow for those. On a 201, read the partial_upload warning: if some files failed, re-upload them before analysing or the requirements matrix will be confidently incomplete.

Large files: the signed-URL flow

Three steps, so the bytes go straight to storage and never through the 4 MB request-body limit. Handles files up to 75 MB each.

Step 1, ask for upload URLs. Send a files array; each item declares its filename and size_bytes. size_bytes is required, so the per-tender caps can be checked before any URL is minted. The content type is derived server-side from the filename, not taken from you.

curl -X POST https://tenderbuilder.ie/api/v1/tenders/$TENDER/documents/upload-url \
  -H "Authorization: Bearer $TB_KEY" -H "Content-Type: application/json" \
  -d '{"files":[{"filename":"ITT.pdf","size_bytes":18874368}]}'

The response carries an uploads array, one entry per declared file, each with a path, a signed_url, the fixed method (PUT), and the headers to send:

{
  "data": {
    "uploads": [
      {
        "path": "…/…/…-ITT.pdf",
        "filename": "ITT.pdf",
        "signed_url": "https://…",
        "method": "PUT",
        "headers": { "content-type": "application/pdf" },
      },
    ],
    "instructions": "…",
    "next_step": "POST /v1/tenders/{id}/documents/confirm",
  },
}

**Step 2, PUT the bytes to each signed_url** with the returned headers. The upload token is already baked into the URL, so those headers are all you send. This uploads straight to storage, with no size cap on the request.

Step 3, confirm. POST the paths back. Each object's existence and its real size are verified in storage (the size_bytes you declared in step 1 was advisory), then it is registered exactly as a multipart upload would be.

curl -X POST https://tenderbuilder.ie/api/v1/tenders/$TENDER/documents/confirm \
  -H "Authorization: Bearer $TB_KEY" -H "Content-Type: application/json" \
  -d '{"files":[{"path":"…/…/…-ITT.pdf"}]}'

Pass only path values that came from step 1: a path outside this tender's storage prefix is refused. A filename is optional (it defaults to the trailing path segment). As with multipart, a 201 can carry a partial_confirm warning naming files that could not be registered (missing from storage, empty, or over 75 MB); re-upload those before analysing. There is no idempotency key: confirming the same path twice registers it twice, exactly as re-uploading the same file would.

Fetch-by-URL

POST /tenders/{id}/documents/fetch takes a single {"url":"…"} and downloads it server-side into the tender. This is the path an MCP agent uses, since MCP cannot POST file bytes.

curl -X POST https://tenderbuilder.ie/api/v1/tenders/$TENDER/documents/fetch \
  -H "Authorization: Bearer $TB_KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://buyer.example/itt.pdf"}'

Because it fetches a caller-supplied URL from inside our network, it is tightly constrained against SSRF, and every constraint is a clean invalid_request (400):

  • **https:// only**, no http, file, credentials in the URL, or a non-443 port.
  • The URL must be a direct download whose path ends in a document extension (pdf, docx, doc, xlsx, xls, pptx, txt), and the response's content-type must be a matching document type, not a web page.
  • Redirects are refused, not followed. A 30x comes back telling you to supply the final direct-download URL: following one would re-open a URL we never validated.
  • The host is resolved here and every resolved address must be public: private, loopback, link-local, CGNAT and cloud-metadata ranges are all refused (IPv4 and IPv6, including IPv4-mapped IPv6).
  • The download is streamed with a hard 75 MB cap and a hard timeout.

It cannot reach a URL that sits behind a login (many eTenders downloads do), in which case it buys you nothing over uploading the file yourself. There is no host allowlist: any public https document URL that passes the checks above works.

Using it from an agent

The API speaks two dialects of the same tools: MCP for MCP-capable clients, and OpenAPI for function-calling / Actions. Both authenticate with the same tb_live_... key.

MCP

Point any MCP client at POST /mcp (https://tenderbuilder.ie/api/v1/mcp) with your key as a bearer token. Tools are named tenderbuilder_* and carry descriptions written for a model: what each is for, what must happen first, how long it takes, and what the result does _not_ mean.

Claude Code, one command:

claude mcp add --transport http tenderbuilder \
  https://tenderbuilder.ie/api/v1/mcp \
  --header "Authorization: Bearer tb_live_..."

Claude Desktop / other JSON-config clients: clients that read an mcpServers block but cannot attach a header to a remote server reach it through the mcp-remote bridge, which forwards the header:

{
  "mcpServers": {
    "tenderbuilder": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://tenderbuilder.ie/api/v1/mcp",
        "--header",
        "Authorization: Bearer tb_live_..."
      ]
    }
  }
}

Clients that cannot attach a static bearer header: not supported yet. The claude.ai remote-connector directory and ChatGPT connectors expect an OAuth handshake, not a fixed Authorization header. There is no OAuth on this API today, by design: authentication is the tb_live_... bearer key and nothing else. Those hosted connector surfaces therefore cannot attach to it yet. Use a client that lets you set the header (Claude Code, or a JSON-config client via mcp-remote as above).

Function calling / Actions

Generate the tool definitions from GET /openapi.json (https://tenderbuilder.ie/api/v1/openapi.json, no auth needed).

Limits

Two layers. A per-IP shield sheds unauthenticated traffic before it touches the database; a durable per-key limit is the one that actually bounds a caller: 120 requests/minute by default, and a stricter 20/minute on the heavy endpoints. The 20/minute bucket covers the AI-backed calls (match, profile/derive, review, analyse, draft) and the expensive I/O ones (all document ingestion: multipart, upload-url, confirm and fetch; plus pack download). Both answer 429 with Retry-After.

Things we deliberately do not do

  • There is no submission endpoint. Nothing here files a bid. Drafts and packs are for a human to review, edit and submit. Every draft response carries requires_human_review: true.
  • We do not scrape eTenders on your request. Search and match serve a cached index refreshed by a single polite daily crawl. Every listing carries a freshness object and a link to the authoritative record; when stale is true, confirm the deadline at the source before relying on it.
  • We do not guess at figures. Where the buyer's documents do not state a value, a deadline or a threshold, you get (not stated) rather than an estimate. A wrong number in a procurement document is worse than a missing one.

Versioning and support

v1 is a stable contract. Within v1 we hold the paths, the response envelope (data / meta / warnings and error), and the error code vocabulary stable. Changes within v1 are additive: new endpoints, new optional fields, new warning codes. Anything that would break an existing integration ships under a new version prefix, not silently under /api/v1.

Commercial figures (unit costs, tier allowances, free-assessment cap) are not part of the stability contract: they can change, which is why GET /me is the authoritative source for your live budget.

Breaking changes and deprecations are announced through the same channel you receive product updates, and ahead of taking effect.

Support: email lewis@tenderbuilder.ie. Every API call is recorded against the key that made it, so if something is not working, quote the key prefix (tb_live_<prefix>…, safe to share, it is not the secret) and roughly when.