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:
- Tender — a single tender from a portal (NEN, VVZ, E-ZAK, TenderArena, etc.). Identified by a numeric ID.
- Filter — a saved set of criteria (region, industry, keywords, value). When a new tender meets the criteria, a match is produced.
- Match — a new tender that fell into your filter. Represents the (tender × filter) link + timestamp + your state (starred / excluded / viewed).
- Taxonomy — controlled 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.
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.
/api/v2/leads/tenders/searchQuery parameters
| Parameter | Type | Description |
|---|---|---|
| qText* | string | Search text (full-text in title + description). |
| regions | string | NUTS leaf codes CSV (CZ010,CZ020). |
| cpvPrefixes | string | CPV prefixes CSV (45,452). |
| industryTags | string | Industry tag IDs CSV (con_buildings,it_development). |
| minValue | number | Min. estimated value (CZK). Tenders with no value pass through. |
| maxValue | number | Max. estimated value (CZK). Tenders with no value pass through. |
| deadlineFrom | string (YYYY-MM-DD) | Submission deadline >= YYYY-MM-DD. |
| deadlineTo | string (YYYY-MM-DD) | Submission deadline <= YYYY-MM-DD. |
| sort | "newest" | "deadline" | "value" | Sort: newest (default), deadline (ascending), value (descending). |
| limit | number | Result count, max 1000 (default: 50). For >1k results use nextCursor pagination or /matches/export. |
| cursor | string | Keyset cursor from previous page (pagination.nextCursor). |
Example
curl -H "X-Api-Key: mrw_live_…" \ "https://veritra.io/api/v2/leads/tenders/search?qText=rekonstrukce®ions=CZ010,CZ020&minValue=500000&limit=10"
/api/v2/leads/tenders/:idResponse: { data: { id, title, description, estimatedValue, deadlineAt, contractingAuthority, documents[], starred, excluded } }.
/api/v2/leads/tenders/:id/emailBody may contain recipientEmail to forward to a different email. Defaults to user.email.
/api/v2/leads/documents/previewQuery: ?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.
/api/v2/leads/zadavatele?q=praha/api/v2/leads/countries/api/v2/leads/regions?country=CZ/api/v2/leads/regions/catalog?country=CZ&locale=cs/api/v2/leads/taxonomy/industry/api/v2/leads/taxonomy/cpvFilter 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.
CZCzech RepublicSKSlovakiaPLPolandDEGermanyATAustriaFRFranceESSpainITItalyNLNetherlandsBEBelgiumPTPortugalSESwedenFIFinlandDKDenmarkNONorwayIEIrelandGRGreeceRORomaniaBGBulgariaHUHungaryHRCroatiaSISloveniaLTLithuaniaLVLatviaEEEstoniaLULuxembourgCYCyprusMTMaltaCHSwitzerlandISIcelandMKNorth MacedoniaTo 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 PrahaCZ020Středočeský krajCZ031Jihočeský krajCZ032Plzeňský krajCZ041Karlovarský krajCZ042Ústecký krajCZ051Liberecký krajCZ052Královéhradecký krajCZ053Pardubický krajCZ063Kraj VysočinaCZ064Jihomoravský krajCZ071Olomoucký krajCZ072Zlínský krajCZ080Moravskoslezský krajindustryTags 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.
con_buildingsBuilding construction and renovationcon_civilCivil engineering (roads, bridges, water)con_tradesConstruction trades and subworkscon_energy_efficiencyEnergy savings & renewablescon_materialsConstruction materialsdes_documentationDesign documentation and studiesdes_supervision_ohsSite supervision and OHSdes_surveyingSurveying and land consolidationit_developmentSoftware development and integrationit_licensingSoftware licences and subscriptionsit_hardwareHardware and infrastructureit_cybersecurityCybersecurityit_data_aiData, analytics, AI/MLtelecom_internetTelecom, internet, mobileprof_marketingMarketing, PR, advertisingprof_legalLegal servicesprof_accountingAccounting, grants, auditprof_hrHR, recruitmentprof_translationTranslation and interpretingprof_insuranceInsurance and financeops_cleaningCleaning servicesops_securitySecurity and receptionops_maintenanceEquipment maintenance and serviceops_wasteWaste managementops_facilityProperty managementcat_cateringCatering servicescat_accommodationAccommodation and conferencescat_foodFood and beveragestrans_transportPassenger and freight transporttrans_vehiclesVehicles, parts, leasinghealth_pharmaPharmaceuticalshealth_devicesMedical devices and supplieshealth_careHealth and social caregoods_furnitureFurniture and interiorsgoods_clothingClothing, PPE, uniformsgoods_electricalElectrical materialgoods_machineryIndustrial machineryenergy_fuelsFuelsenergy_power_heatElectricity and heatenergy_waterWater utilitiesnat_forestryForestrynat_greeneryGreenery maintenance and gardeningnat_agricultureAgriculturedefense_safetyFire, military, defencesci_labLab and measurement equipmentsci_researchResearch and developmentedu_trainingEducation and trainingculture_mediaCulture, books, mediacategories 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.
03Agriculture, forestry, fishing09Fuels and energy15Food and beverages18Clothing, footwear, PPE22Printed matter, books30Office machines, IT equipment31Electrical machines, cables, lighting32Radio, TV, telecommunications33Medical equipment, pharmaceuticals34Vehicles, transport35Security, fire and military equipment38Laboratory and measuring instruments39Furniture, interior fittings42Industrial machinery44Construction materials and structures45Construction works48Software and licenses50Repair and maintenance55Catering, accommodation60Transport (carriage)64Postal, telecom services65Public utilities (electricity, water)66Financial and insurance services70Real estate and facility management71Architectural, design, engineering services72IT services (development, integration, support)73Research and development75Public administration, defense77Agricultural, forestry, horticultural services79Business services (legal, accounting, HR, marketing)80Education and training85Health and social care90Waste, environment, cleaning92Culture, sport, recreation98Other services for the publicFull 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.
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.
/api/v2/leads/filters/api/v2/leads/filters/api/v2/leads/filters/:id/api/v2/leads/filters/:id/api/v2/leads/filters/:id/api/v2/leads/filters/:id/matches/export?format=csv|xlsx|json&view=all|starred|excludedBody parameters (POST / PUT) — shared
| Parameter | Type | Description |
|---|---|---|
| name* | string | Filter name (max 120 characters) |
| regions | string[] | NUTS codes (e.g. `CZ010`). Mix any level. Empty array = all regions. |
| industryTags | string[] | Recommended path. Tag IDs from our taxonomy (e.g. 'stav_pozemni', 'it_vyvoj'). Multi-tag — OR between items. |
| categories | string[] | CPV prefixes of any length (e.g. '45', '4523', '45316110'). Less accurate than industryTags. |
| keywords | string[] | Keywords — LIKE match in tender title and description (OR between items). |
| minValue | number | null | Min. estimated value (CZK). Tenders with no value pass through. |
| maxValue | number | null | Max. estimated value (CZK). Tenders with no value pass through. |
| emailDigest | boolean | Daily email digest (default true) |
| active | boolean | Active 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
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/filtersIT 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/filtersPublic 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/filtersDeactivate 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.
Fetching matches
/api/v2/leads/matchesQuery parameters
| Parameter | Type | Description |
|---|---|---|
| filterId | string | Filter by a specific filter |
| qText | string | Search text (full-text in title + description). |
| since | string (ISO 8601 datetime) | Only matches since this date (ISO datetime) |
| delivered | boolean | false = 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). |
| limit | number | Result count, max 1000 (default: 50). For >1k results use nextCursor pagination or /matches/export. |
| cursor | string | Keyset 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-12345synthetic 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.
/api/v2/leads/matches/:matchIdResponse: same shape as matches list item.
/api/v2/leads/matches/:matchId/star{ "starred": true }/api/v2/leads/matches/:matchId/exclude{ "excluded": true }/api/v2/leads/matches/:matchId/viewNo-op for synthetic live-:id matches (no row to mark).
/api/v2/leads/preferences{
"data": {
"starred": [12345, 67890],
"excluded": [54321]
}
}Taxonomy
Static reference catalogs for filter values. Locale-aware labels.
/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"] },
…
]
}
}/api/v2/leads/taxonomy/cpv?locale=csResponse: 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.
/api/v2/account/webhooks/api/v2/account/webhooks/api/v2/account/webhooks/:id/api/v2/account/webhooks/:id/api/v2/account/webhooks/:id/api/v2/account/webhooks/:id/rotate-secret/api/v2/account/webhooks/:id/test/api/v2/account/webhooks/:id/deliveries/api/v2/account/webhooks/:id/deliveries/:deliveryId/replayHMAC 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
| Parameter | Type | Description |
|---|---|---|
| Trial | 500 req/month | 1 day free. No card / billing profile required. Default ApiKey.requestsLimit. |
| Paid plan | unlimited | Monthly cap is lifted. Only the technical rate limit of 100/h/key applies. |
| Filters | 20 | Max number of active filters per account. |
| Webhook endpoints | 5 | Max 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.