New

Tender Monitor

Automatic delivery of new tenders matching your filters — region, industry tags, keywords, value range. Pull API + email digest + webhook.

Concepts

Four terms you'll see across all endpoints:

  • Tendera single tender from a portal (NEN, VVZ, E-ZAK, TenderArena, etc.). Identified by a numeric ID.
  • Filtera saved set of criteria (region, industry, keywords, value). When a new tender meets the criteria, a match is produced.
  • Matcha new tender that fell into your filter. Represents the (tender × filter) link + timestamp + your state (starred / excluded / viewed).
  • Taxonomycontrolled vocabularies for regions (NUTS), industries (industry tags) and CPV codes used to classify tenders.

Quick start

Filters can be set up in two ways — the result is identical and a filter can be edited via either path at any time.

curl -H "X-Api-Key: mrw_live_…" \
  "https://veritra.io/api/v2/leads/tenders/search?qText=rekonstrukce%20mostu&limit=5"

The above is an ad-hoc search. For continuous monitoring (new tenders matching your criteria), use filters + webhook/email digest — see below.

Recommendation: Always prefer industryTags over categories (CPV) for filtering. Our industryTags are a union of LLM tender classification and CPV mapping — robust against incorrect CPV codes. Czech contracting authorities frequently assign overly generic or unrelated CPV codes (typically the generic '45000000-0' for construction instead of a specific prefix), so a CPV-only filter will miss a large share of relevant tenders.

Tender search (ad-hoc)

For one-off searches across all active tenders. Doesn't use a saved filter — parameters go directly in the query string.

GET/api/v2/leads/tenders/search

Query parameters

ParameterTypeDescription
qText*stringSearch text (full-text in title + description).
regionsstringNUTS leaf codes CSV (CZ010,CZ020).
cpvPrefixesstringCPV prefixes CSV (45,452).
industryTagsstringIndustry tag IDs CSV (con_buildings,it_development).
minValuenumberMin. estimated value (CZK). Tenders with no value pass through.
maxValuenumberMax. estimated value (CZK). Tenders with no value pass through.
deadlineFromstring (YYYY-MM-DD)Submission deadline >= YYYY-MM-DD.
deadlineTostring (YYYY-MM-DD)Submission deadline <= YYYY-MM-DD.
sort"newest" | "deadline" | "value"Sort: newest (default), deadline (ascending), value (descending).
limitnumberResult count, max 1000 (default: 50). For >1k results use nextCursor pagination or /matches/export.
cursorstringKeyset cursor from previous page (pagination.nextCursor).

Example

curl -H "X-Api-Key: mrw_live_…" \
  "https://veritra.io/api/v2/leads/tenders/search?qText=rekonstrukce&regions=CZ010,CZ020&minValue=500000&limit=10"
GET/api/v2/leads/tenders/:id

Response: { data: { id, title, description, estimatedValue, deadlineAt, contractingAuthority, documents[], starred, excluded } }.

POST/api/v2/leads/tenders/:id/email

Body may contain recipientEmail to forward to a different email. Defaults to user.email.

GET/api/v2/leads/documents/preview

Query: ?url=:original&kind=docx|xlsx. Allowlisted hosts: NEN, E-ZAK, Tender Arena, Gemin, ProfilZadavatele.

GET /api/v2/leads/documents/preview?url=https://nen.nipez.cz/…/Vyzva.docx&kind=docx
→ 302 Location: https://rwx-storage…/Tendero/doc-cache-v8/<hash>.html (signed, TTL 10 min)

Catalog & autocomplete (public, no key)

For UI dropdowns and filter creation. Server-side cache 1h. Public, no auth required.

GET/api/v2/leads/zadavatele?q=praha
GET/api/v2/leads/countries
GET/api/v2/leads/regions?country=CZ
GET/api/v2/leads/regions/catalog?country=CZ&locale=cs
GET/api/v2/leads/taxonomy/industry
GET/api/v2/leads/taxonomy/cpv
Search vs. Filter: Search is a one-shot query, returning the current snapshot. A Filter (below) is saved criteria — the server continuously notifies you about new tenders via webhook or email digest.

Filter fields and allowed values

A filter consists of the fields below. All are optional except `name`. An empty field means no restriction on that dimension.

regions string[]

NUTS regions for the given country (?country=CZ) with locale-aware labels.

Filter is pan-EU: The `regions` values are NUTS codes from any EU country listed below. The filter only returns regions for which you have an active LEADS subscription (subscription scope). If you only have CZ + SK subscribed, German NUTS codes will be silently ignored.
Supported countries (NUTS-0)
CZCzech Republic
SKSlovakia
PLPoland
DEGermany
ATAustria
FRFrance
ESSpain
ITItaly
NLNetherlands
BEBelgium
PTPortugal
SESweden
FIFinland
DKDenmark
NONorway
IEIreland
GRGreece
RORomania
BGBulgaria
HUHungary
HRCroatia
SISlovenia
LTLithuania
LVLatvia
EEEstonia
LULuxembourg
CYCyprus
MTMalta
CHSwitzerland
ISIceland
MKNorth Macedonia

To get the full NUTS tree for a specific country use:

GET /api/v2/leads/regions/catalog?country=CZ&locale=cs
GET /api/v2/leads/regions/catalog?country=DE&locale=de
GET /api/v2/leads/regions/catalog?country=FR&locale=en
Example: NUTS-3 codes for CZ (regions) — expand ▸
CZ010Hlavní město Praha
CZ020Středočeský kraj
CZ031Jihočeský kraj
CZ032Plzeňský kraj
CZ041Karlovarský kraj
CZ042Ústecký kraj
CZ051Liberecký kraj
CZ052Královéhradecký kraj
CZ053Pardubický kraj
CZ063Kraj Vysočina
CZ064Jihomoravský kraj
CZ071Olomoucký kraj
CZ072Zlínský kraj
CZ080Moravskoslezský kraj

industryTags string[] Recommended

Multi-tag industry classification. A tender can have multiple tags (e.g. 'stav_pozemni' + 'stav_remesla' for a renovation). The filter matches a tender if at least one of the requested tags is set (JSON_OVERLAPS).

Why prefer industryTags over CPV: Our industryTags combine LLM classification of the tender title/description with CPV mapping and regex hints — they catch relevant tenders even when the authority assigned CPV carelessly. A pure CPV filter is exact but depends on contracting authority discipline, which is weak in CZ.

48 tags across 13 areas
🏗️Construction
con_buildingsBuilding construction and renovation
con_civilCivil engineering (roads, bridges, water)
con_tradesConstruction trades and subworks
con_energy_efficiencyEnergy savings & renewables
con_materialsConstruction materials
📐Design & supervision
des_documentationDesign documentation and studies
des_supervision_ohsSite supervision and OHS
des_surveyingSurveying and land consolidation
💻IT & software
it_developmentSoftware development and integration
it_licensingSoftware licences and subscriptions
it_hardwareHardware and infrastructure
it_cybersecurityCybersecurity
it_data_aiData, analytics, AI/ML
📡Telecommunications
telecom_internetTelecom, internet, mobile
🧑‍💼Professional services
prof_marketingMarketing, PR, advertising
prof_legalLegal services
prof_accountingAccounting, grants, audit
prof_hrHR, recruitment
prof_translationTranslation and interpreting
prof_insuranceInsurance and finance
🛡️Operations & maintenance
ops_cleaningCleaning services
ops_securitySecurity and reception
ops_maintenanceEquipment maintenance and service
ops_wasteWaste management
ops_facilityProperty management
🍽️Catering & accommodation
cat_cateringCatering services
cat_accommodationAccommodation and conferences
cat_foodFood and beverages
🚚Transport & vehicles
trans_transportPassenger and freight transport
trans_vehiclesVehicles, parts, leasing
🏥Healthcare
health_pharmaPharmaceuticals
health_devicesMedical devices and supplies
health_careHealth and social care
📦Goods & equipment
goods_furnitureFurniture and interiors
goods_clothingClothing, PPE, uniforms
goods_electricalElectrical material
goods_machineryIndustrial machinery
Energy & water
energy_fuelsFuels
energy_power_heatElectricity and heat
energy_waterWater utilities
🌳Nature, forestry, safety
nat_forestryForestry
nat_greeneryGreenery maintenance and gardening
nat_agricultureAgriculture
defense_safetyFire, military, defence
🔬Science & education
sci_labLab and measurement equipment
sci_researchResearch and development
edu_trainingEducation and training
culture_mediaCulture, books, media
Tag missing? If you don't find a tag for your industry, write to michal@veritra.io — we'll add it.

categories string[] Less accurate

CPV (Common Procurement Vocabulary) prefixes of any length — matched via `LIKE 'prefix%'`. So '45' catches all construction divisions, '4523' only civil engineering, '45316110' only street lighting.

35 most common CPV divisions (2-digit)
03Agriculture, forestry, fishing
09Fuels and energy
15Food and beverages
18Clothing, footwear, PPE
22Printed matter, books
30Office machines, IT equipment
31Electrical machines, cables, lighting
32Radio, TV, telecommunications
33Medical equipment, pharmaceuticals
34Vehicles, transport
35Security, fire and military equipment
38Laboratory and measuring instruments
39Furniture, interior fittings
42Industrial machinery
44Construction materials and structures
45Construction works
48Software and licenses
50Repair and maintenance
55Catering, accommodation
60Transport (carriage)
64Postal, telecom services
65Public utilities (electricity, water)
66Financial and insurance services
70Real estate and facility management
71Architectural, design, engineering services
72IT services (development, integration, support)
73Research and development
75Public administration, defense
77Agricultural, forestry, horticultural services
79Business services (legal, accounting, HR, marketing)
80Education and training
85Health and social care
90Waste, environment, cleaning
92Culture, sport, recreation
98Other services for the public

Full catalog (9,454 codes) is available at /docs/leads/cpv — searchable and grouped by division.

keywords string[]

LIKE match in tender title and description. Case-insensitive. OR between items. Useful as a safety net if industryTags/CPV don't catch everything (e.g. a specific tender type that only shows up in text).

"keywords": ["reconstruction", "lighting", "kindergarten"]

minValue / maxValue number | null

Estimated tender value range in CZK. You can set only minValue, only maxValue, or both.

Important: Tenders without an estimated value set (estimatedValue is null or 0) pass through the filter in both directions. Many contracting authorities don't disclose value — cutting them off would lose you tenders.

emailDigest boolean

If true, you get a single daily email (5:00 UTC) summarising new matches for this filter. Default true. Disable by editing the filter. Webhooks are configured separately, at the account level (see below).

name string · active boolean

`name` — max 120 chars, required. `active` — if false, the filter is excluded from the cron (no new matches, no digest, no webhook). Use to pause without deleting.

Filter logic

Fields combine as follows:

MATCH = (authority.NUTS3 ∈ expand(regions))
     AND (minValue ≤ estimatedValue ≤ maxValue  OR  estimatedValue is null or 0)
     AND (industry_or_cpv  OR  keyword_match)

# expand(regions): NUTS codes are expanded to NUTS 3 leaves
# (CZ → 14 regions, CZ01 → CZ010, CZ010 → CZ010).

industry_or_cpv:
   if    industryTags set  →  JSON_OVERLAPS(industryTags, tender.industryTags)
   elif  categories set    →  tender.cpvCode LIKE ANY (categories + "%")
   else                    →  false   (no industry filter applied)

keyword_match:
   if    keywords set      →  tender.title or description LIKE ANY (%kw%)
   else                    →  false

# If you set no industryTags / categories / keywords,
# all tenders matching regions and value range are returned.

industryTags takes precedence over categories — if you set both, only industryTags is used (categories is ignored). keywords work independently (they're OR'd with industry_or_cpv).

Filter management endpoints

Use the management key (mrw_live_…) to manage filters. It has separate rate limits and does not consume LEADS credits, so filter management does not affect your daily lead retrieval.

GET/api/v2/leads/filters
POST/api/v2/leads/filters
GET/api/v2/leads/filters/:id
PATCH/api/v2/leads/filters/:id
DELETE/api/v2/leads/filters/:id
GET/api/v2/leads/filters/:id/matches/export?format=csv|xlsx|json&view=all|starred|excluded

Body parameters (POST / PUT) — shared

ParameterTypeDescription
name*stringFilter name (max 120 characters)
regionsstring[]NUTS codes (e.g. `CZ010`). Mix any level. Empty array = all regions.
industryTagsstring[]Recommended path. Tag IDs from our taxonomy (e.g. 'stav_pozemni', 'it_vyvoj'). Multi-tag — OR between items.
categoriesstring[]CPV prefixes of any length (e.g. '45', '4523', '45316110'). Less accurate than industryTags.
keywordsstring[]Keywords — LIKE match in tender title and description (OR between items).
minValuenumber | nullMin. estimated value (CZK). Tenders with no value pass through.
maxValuenumber | nullMax. estimated value (CZK). Tenders with no value pass through.
emailDigestbooleanDaily email digest (default true)
activebooleanActive filter (default true)

Webhooks are no longer configured per filter — see the Webhook section below (account-level endpoints). The webhookUrl field is rejected with HTTP 410 for security reasons.

Examples

Examples below are shown in English for readability. In production, keywords are matched (LIKE %kw%) against the tender title and description in the publisher's language — currently always Czech, so submit your filter with Czech terms (e.g. "osvětlení", "veřejné osvětlení").

Construction in Prague over 500k CZK

curl -X POST -H "X-Api-Key: mrw_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Prague construction 500k+",
    "regions": ["CZ010"],
    "industryTags": ["con_buildings", "con_trades"],
    "minValue": 500000
  }' \
  https://veritra.io/api/v2/leads/filters

IT development and SW licences (all regions)

curl -X POST -H "X-Api-Key: mrw_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "IT development + SW licences",
    "industryTags": ["it_development", "it_licensing", "it_data_ai"],
    "keywords": ["information system", "module"]
  }' \
  https://veritra.io/api/v2/leads/filters

Public lighting (CZ) — tags + keywords combo

curl -X POST -H "X-Api-Key: mrw_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Public lighting (CZ)",
    "industryTags": ["goods_electrical", "con_civil"],
    "keywords": ["lighting", "street light", "public lighting", "lamps"],
    "minValue": 200000
  }' \
  https://veritra.io/api/v2/leads/filters

Deactivate a filter

curl -X PATCH -H "X-Api-Key: mrw_live_…" \
  -H "Content-Type: application/json" \
  -d '{"active": false}' \
  https://veritra.io/api/v2/leads/filters/<id>

Delivery — webhook vs polling

Two ways to get new tenders into your ERP. Most integrators combine both.

Webhook (push)

Tendero POSTs to your endpoint within ~2 seconds of match. Event leads.match.created with full tender payload.

Pros: real-time, no polling overhead, server-side gating.

Cons: requires publicly-accessible endpoint (HTTPS), HMAC verification, idempotency handling.

Polling (pull)

Your ERP calls GET /api/v2/leads/matches?since=… periodically (e.g. every 5 min). Returns matches since the given timestamp.

Pros: no public endpoint, simple implementation, restart-friendly.

Cons: latency 5-10 min, rate-limit constraint (60 req/min mgmt API), wasted empty responses.

Recommended: primary webhook + daily polling (since=yesterday) as safety net in case webhook delivery exhausted retry budget.

Fetching matches

GET/api/v2/leads/matches

Query parameters

ParameterTypeDescription
filterIdstringFilter by a specific filter
qTextstringSearch text (full-text in title + description).
sincestring (ISO 8601 datetime)Only matches since this date (ISO datetime)
deliveredbooleanfalse = only undelivered, true = only delivered
view"starred" | "excluded"Special view: starred (favorites) | excluded (hidden).
sort"newest" | "deadline" | "value"Sort: newest (default), deadline (ascending), value (descending).
limitnumberResult count, max 1000 (default: 50). For >1k results use nextCursor pagination or /matches/export.
cursorstringKeyset cursor from previous page (pagination.nextCursor).

Returned matches are automatically marked delivered=true. Each request consumes 1 credit.

matchId formats

matchId has two formats based on origin:

  • cm… (cuid prefix) — returned from pre-computed LeadMatch table (daily cron). Stable across calls, usable for mark-as-viewed.
  • live-12345 synthetic prefix for live-detected matches via search / browse mode (qText param, view=starred/excluded). Not in LeadMatch table → mark-as-viewed is no-op. tenderId is the dash suffix.

Example

curl -H "X-Api-Key: mrw_leads_…" \
  "https://veritra.io/api/v2/leads/matches?delivered=false&limit=10"

Response

{
  "data": [
    {
      "matchId": "cm…",
      "filterId": "cm…",
      "filterName": "Prague construction",
      "matchedAt": "2026-04-03T06:00:00.000Z",
      "viewedAt": null,
      "delivered": true,
      "tender": {
        "id": 12345,
        "title": "Bridge reconstruction reg.no. 123",
        "estimatedValue": 12500000,
        "deadlineAt": "2026-05-15T22:00:00.000Z",
        "publishedAt": "2026-04-01T08:00:00.000Z",
        "firstSeenAt": "2026-04-01T08:30:00.000Z",
        "url": "https://nen.nipez.cz/...",
        "portalType": "NEN",
        "cpvCode": "45000000",
        "tenderType": "OFFERS",
        "contractingAuthority": {
          "ico": "12345678",
          "name": "Město Praha",
          "region": "Praha",
          "district": "Praha 1"
        },
        "documents": [
          { "name": "Výzva.pdf", "url": "https://nen.nipez.cz/…", "fileType": "pdf", "fileSizeBytes": 320000 }
        ],
        "starred": false,
        "excluded": false
      }
    }
  ],
  "pagination": { "nextCursor": "eyJmaXJzdFNlZW5BdC…", "totalCount": 42 }
}

For next page, pass pagination.nextCursor as ?cursor= parameter.

Match actions

Star (favorite), exclude (hide), and view (mark as read) are per-tender preferences stored in UserTenderPreference.

GET/api/v2/leads/matches/:matchId

Response: same shape as matches list item.

POST/api/v2/leads/matches/:matchId/star
{ "starred": true }
POST/api/v2/leads/matches/:matchId/exclude
{ "excluded": true }
POST/api/v2/leads/matches/:matchId/view

No-op for synthetic live-:id matches (no row to mark).

GET/api/v2/leads/preferences
{
  "data": {
    "starred": [12345, 67890],
    "excluded": [54321]
  }
}

Taxonomy

Static reference catalogs for filter values. Locale-aware labels.

GET/api/v2/leads/taxonomy/industry?locale=cs
{
  "data": {
    "locale": "cs",
    "areas": [{ "id": "construction", "icon": "🏗️", "label": "Stavebnictví" }, …],
    "tags": [
      { "id": "con_buildings", "area": "construction", "label": "Pozemní stavby", "cpvPrefixes": ["452"] },
      …
    ]
  }
}
GET/api/v2/leads/taxonomy/cpv?locale=cs

Response: tree structure divisions → groups → classes → categories → subcategories.

Webhook

Webhook endpoints (CRUD, secret rotation) are account-level — documented once in Account API → Webhooks. This section covers only the leads-specific event payload (leads.match.created) and HMAC verification.

Event delivery

On a new match we send an event of type leads.match.created with an HMAC-SHA256 signature in the X-Signature-256 header, idempotency key in X-Idempotency-Key, and the type in X-Event-Type. On failure we retry with exponential backoff for up to ~33 hours.

Retry policy

If your endpoint returns non-2xx (or doesn't respond within 10s), Tendero retries with exponential backoff: 1min, 5min, 30min, 2h, 12h, 24h. After 6 failures the webhook is marked as failed and flagged in the dashboard. Idempotency-key stays the same for each retry — your endpoint MUST deduplicate (otherwise the same match is processed 6×).

Manage endpoints via API

You can manage webhook endpoints without entering the dashboard. Limit 5 endpoints per account.

GET/api/v2/account/webhooks
POST/api/v2/account/webhooks
Webhook secret is shown only once! When POST /webhooks succeeds, the response includes a 'secret' field — save it immediately into your environment (e.g. MRICKWOOD_WEBHOOK_SECRET). It cannot be retrieved later. If lost, use /rotate-secret to generate a new one (the old one becomes invalid immediately).
GET/api/v2/account/webhooks/:id
PATCH/api/v2/account/webhooks/:id
DELETE/api/v2/account/webhooks/:id
POST/api/v2/account/webhooks/:id/rotate-secret
POST/api/v2/account/webhooks/:id/test
GET/api/v2/account/webhooks/:id/deliveries
POST/api/v2/account/webhooks/:id/deliveries/:deliveryId/replay

HMAC signature verification

Server signs payload as HMAC-SHA256(secret, raw_body) and sends in X-Signature-256 header in sha256=hex format. Verify in constant-time, otherwise the integration is vulnerable to timing attacks.

// Node.js (Express)
import crypto from "node:crypto";

const WEBHOOK_SECRET = process.env.MRICKWOOD_WEBHOOK_SECRET!;

function verify(rawBody: string, sig: string | undefined): boolean {
  if (!sig) return false;
  const expected = "sha256=" + crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(rawBody)
    .digest("hex");
  // Constant-time comparison (timing-safe)
  const a = Buffer.from(sig);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhooks/veritra",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const raw = req.body.toString("utf8");
    if (!verify(raw, req.header("X-Signature-256"))) {
      return res.status(401).send("Invalid signature");
    }
    const idempKey = req.header("X-Idempotency-Key")!;
    const evt = JSON.parse(raw);
    // Idempotency: store idempKey, skip if already processed
    if (await alreadyProcessed(idempKey)) return res.status(200).send("ok");
    await processEvent(evt);
    await markProcessed(idempKey);
    res.status(200).send("ok"); // Must be 2xx within 10s
  },
);
# Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort

WEBHOOK_SECRET = os.environ["MRICKWOOD_WEBHOOK_SECRET"]

def verify(raw: bytes, sig: str | None) -> bool:
    if not sig: return False
    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET.encode(), raw, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(sig, expected)

@app.post("/webhooks/mrickwood")
def webhook():
    raw = request.get_data()
    if not verify(raw, request.headers.get("X-Signature-256")):
        abort(401)
    # ... idempotency check + process
    return "ok", 200
{
  "id": "evt_…",
  "type": "leads.match.created",
  "createdAt": "2026-04-03T06:00:00.000Z",
  "data": {
    "filterId": "cm…",
    "filterName": "Prague construction",
    "matchId": "cm…",
    "tender": { "id": "12345", "title": "…", "estimatedValue": 12500000 }
  }
}

Email digest

With emailDigest: true you receive a daily email summarising new tenders. The digest is sent in the morning (5:00 UTC) to your account email. You can disable it by editing the filter.

Limits

ParameterTypeDescription
Trial500 req/month1 day free. No card / billing profile required. Default ApiKey.requestsLimit.
Paid planunlimitedMonthly cap is lifted. Only the technical rate limit of 100/h/key applies.
Filters20Max number of active filters per account.
Webhook endpoints5Max number of active webhook URLs per account.

On monthly cap overage the API returns 429 with a Retry-After header. The cap resets on the 1st of the month.