Skip to content

Watchlists and alerts

A watchlist is a saved set of filters. Every 30 minutes TenderScope loads new data, matches it against all active watchlists and sends alerts.

reason When
new A tender matching the filters appeared
amended A matching tender changed (new deadline, title or documents)
deadline-24h A matching open tender has its deadline within the next 24 hours

The same alert (watchlist + tender + kind) is never sent twice.

  • Free: alerts are collected and sent as one daily digest (the first cycle after 08:00 UTC). Email only.
  • Pro, Team, API: alerts go out in every 30-minute cycle, by email and/or webhook.

Failed deliveries are retried up to 5 times with growing pauses (2, 4, 8, 16 minutes). After that the alert is marked failed and not sent again.

One email per watchlist per cycle, listing every tender:

Subject: TenderScope: 2 tender alerts for "IT in Armenia"
2 tenders matched your watchlist "IT in Armenia":
1. [New] Supply of server equipment
Buyer: Example Ministry (AM)
Budget: not stated
Deadline: 2026-11-05T08:00:00.000Z
Link: https://www.armeps.am/epps/cft/prepareViewCfTWS.do?resourceId=0000000
...
Manage watchlists: https://tenderscope.app/app

The HTML version has the same content with links. The text above is illustrative.

Set channels.webhook to an https URL (Pro and above). Plain http, localhost and private, loopback or link-local addresses are rejected. TenderScope sends one POST per watchlist per cycle with Content-Type: application/json:

{
"watchlistId": "9c1e7b0a-0000-4000-8000-000000000000",
"watchlistName": "IT in Armenia",
"sentAt": "2026-11-04T08:30:00.000Z",
"alerts": [
{
"id": "42",
"reason": "deadline-24h",
"createdAt": "2026-11-04T08:30:00.000Z",
"tender": {
"id": "1234",
"source": "gnumner",
"title": "Supply of server equipment",
"buyerName": "Example Ministry",
"buyerCountry": "AM",
"budgetAmount": null,
"budgetCurrency": "AMD",
"deadlineAt": "2026-11-05T08:00:00.000Z",
"url": "https://www.armeps.am/epps/cft/prepareViewCfTWS.do?resourceId=0000000"
}
}
]
}

Answer with any 2xx status to confirm. Anything else counts as a failure and is retried.

Every request carries the header

X-TenderScope-Signature: t=1793780000,v1=3f1a…

t is the Unix time in seconds, v1 is the hex HMAC-SHA256 of the string t + "." + rawBody with your signing secret. Always verify against the raw body (before JSON parsing), compare in constant time and reject old timestamps.

import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyTenderScopeSignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay protection
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1, 'hex');
const wanted = Buffer.from(expected, 'hex');
return given.length === wanted.length && timingSafeEqual(given, wanted);
}
// Express: app.post('/tenderscope', express.raw({ type: 'application/json' }), (req, res) => {
// const ok = verifyTenderScopeSignature(req.body.toString('utf8'), req.get('X-TenderScope-Signature') ?? '', process.env.TENDERSCOPE_SECRET);
// if (!ok) return res.sendStatus(401);
// ...
// res.sendStatus(204);
// });

Every account has its own signing secret. Open the dashboard, find Webhook signing secret and click Show signing secret. Store it on your server (for example as TENDERSCOPE_SECRET) and pass it to verifyTenderScopeSignature above. Webhooks of all your watchlists are signed with this secret; other accounts have different secrets.