Release 2026.10

← API

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

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

Next: watch the source catalog — or back to the API reference.

Citation format

pubsec/data 2026.10, https://pubsecdata.org/api/cookbook/matching-tenders