Cookbook: a matched-tenders digest
You work a trade in one province. Once a day you want the new tenders that match, in your inbox, without checking anything by hand. This page is the whole pipeline: one script, standard library only, handed to cron.
The pattern
- Poll daily. One request to https://pubsec.pro/api/v1/tenders with q set to your trade keyword (it matches tender titles and buyer names, case-insensitive), prov set to your province, and sort=published so the digest reads newest-first.
- Follow the pages. Walk meta.next_offset until it returns null. A trade-and-province filter usually fits on one page.
- Dedupe by id. Every tender carries a stable id. Keep the ids you have already emailed in a small JSON file and send only the rest.
- Email yourself. Plain text: title, buyer, closing time, link. One line per tender, nothing to render.
Rate math: one digest run costs one to three requests against the 1,000-a-day allowance. You could poll hourly and still not come close.
The script
Save as tender_digest.py, fill in the five constants, and add 0 7 * * * python3 /path/to/tender_digest.py to your crontab.
#!/usr/bin/env python3
"""Matched-tenders digest: poll daily, dedupe by id, email what's new.
Cron entry (07:00 every day):
0 7 * * * python3 /path/to/tender_digest.py
"""
import json
import smtplib
import ssl
import urllib.request
from email.message import EmailMessage
from pathlib import Path
TRADE = "electrical" # free-text match on tender title or buyer
PROVINCE = "on" # on qc bc ab sk mb nb ns pe nl yt nt nu
LIMIT = 100 # page size; fetch_all() follows next_offset
SEEN_FILE = Path.home() / ".tender-digest-seen.json"
EMAIL_TO = "[email protected]"
SMTP_HOST = "smtp.example.com"
SMTP_USER = "[email protected]"
SMTP_PASS = "your-app-password"
def fetch_page(offset):
url = ("https://pubsec.pro/api/v1/tenders"
f"?q={TRADE}&prov={PROVINCE}&sort=published&limit={LIMIT}&offset={offset}")
with urllib.request.urlopen(url) as response:
return json.load(response)
def fetch_all():
rows, offset = [], 0
while True:
page = fetch_page(offset)
rows.extend(page["data"])
offset = page["meta"]["next_offset"]
if offset is None: # null on the last page
return rows
def main():
seen = set(json.loads(SEEN_FILE.read_text()) if SEEN_FILE.exists() else [])
fresh = [t for t in fetch_all() if t["id"] not in seen]
if not fresh:
print("no new matched tenders today")
return
body = "\n\n".join(
f"{t['title']}\n"
f" buyer: {t['buyer']} ({t['province_code']})\n"
f" closes: {t['closing_at']}\n"
f" {t['url']}"
for t in fresh)
msg = EmailMessage()
msg["Subject"] = f"{len(fresh)} new {TRADE} tender(s) in {PROVINCE.upper()}"
msg["From"] = SMTP_USER
msg["To"] = EMAIL_TO
msg.set_content(body)
with smtplib.SMTP_SSL(SMTP_HOST, 465, context=ssl.create_default_context()) as smtp:
smtp.login(SMTP_USER, SMTP_PASS)
smtp.send_message(msg)
seen.update(t["id"] for t in fresh)
SEEN_FILE.write_text(json.dumps(sorted(seen)))
print(f"emailed {len(fresh)} new tender(s)")
if __name__ == "__main__":
main()
Notes that save an evening
- The first email is big. The seen-file starts empty. Run the script once by hand to seed it, or accept one kickoff email with everything currently matching.
- Several trades? q is a single keyword. Run one digest per trade — still trivially inside the rate limit — or drop q and filter client-side on trade_category.
- Closing times move. Dedupe by id suppresses a tender you have already mailed, even if its closing_at changed. If you want re-alerts on changes, store id → closing_at in the state file instead of a plain set and compare.
Next: watch the source catalog — or back to the API reference.
Citation format
pubsec/data 2026.10, https://pubsecdata.org/api/cookbook/matching-tendersMethodology & limitationsrefreshed monthly