Plank help · updated 2026-09-09
Working with the Tizilim portal (subsoil-user procurement)
How to read and automate tizilim.gov.kz — Kazakhstan's state registry of goods, works and services for subsoil-use operations. There is no documented API, but a public read API needs no credentials at all, and the portal itself runs on a REST API you can drive once you can sign with ЭЦП.
Agents: fetch the raw markdown of this page at /en/help/tizilim-portal.md
Working with the Tizilim portal (subsoil-user procurement)
tizilim.gov.kz is Kazakhstan's state information system «Реестр товаров, работ и услуг, используемых при проведении операций по недропользованию, и их производителей». Since 1 July 2025 every procurement announcement by a subsoil user (недропользователь) is published there and nowhere else — it replaced reestr.nadloc.kz outright.
Two kinds of customer care about it:
- Поставщики — they want to know, without checking a website daily, which lots match what they sell.
- Недропользователи (заказчики) — they publish annual plans, run purchases, and file reports on Kazakhstani content. Their work on the portal is repetitive, deadline-driven and done by hand.
There is no documented API and no developer programme. The site's FAQ, «Инструкции» and «Полезные ссылки» pages contain zero mentions of API or integration. Everything below was read off the portal's own client bundle and verified against the live system on 2026-09-09. It is undocumented, which means it can change without notice — write code that fails loudly, not code that silently returns nothing.
There are two separate surfaces. Start with the first one; most requests never need the second.
1. The public read API — no credentials, works today
The public portal (public.tizilim.gov.kz) is a client-side app calling a plain JSON API. That API is reachable directly, needs no authentication, no ЭЦП, and no browser.
Base: https://api.tizilim.gov.kz (also served at https://public.tizilim.gov.kz — same data; the pagination links in a response point at the api. host).
| Endpoint | Returns |
|---|---|
GET /api/public/tenders | Purchases (закупки). Paginated. |
GET /api/public/lots | Individual lots. Paginated. |
GET /api/public/stats | Portal totals — tenders, completed tenders, plan points. |
GET /api/public/news | News items. |
GET /api/public/pages/{slug} | Static pages: documents (инструкции), laws, faq, about, contact, useful-links. |
GET /api/public/refs/auction-types | Purchase methods. |
GET /api/public/refs/auction-statuses | Tender statuses. |
GET /api/public/refs/lot-statuses | Lot statuses. |
GET /api/public/refs/tru-types | ТРУ types. |
GET /api/public/tenders/{number}/protocol | Protocol PDF for a finished purchase — the path param is the tender number. See §1.4. |
1.1 Filters
Both /tenders and /lots take the same query parameters (verified on both):
| Parameter | Meaning |
|---|---|
page, per_page | Laravel pagination. |
search | Case-insensitive substring, matched against the name and the number. |
status[] | Repeatable. Codes from refs/auction-statuses (tenders) or refs/lot-statuses (lots). |
auction_type[] | Repeatable. Codes from refs/auction-types. |
enstru_type[] | Repeatable. 0 = товары, 1 = работы, 2 = услуги. |
start_date, end_date | YYYY-MM-DD. A single day, not a range — read §1.3. |
company_id | The customer's 12-digit БИН. Applied whatever you send — a short or wrong value returns 0 rows, not everything, so a typo reads as «this customer has no purchases». The portal's own UI only sends it at exactly 12 characters; the server has no such guard. |
Reference values, as of 2026-09-09:
auction-types 101 Открытый конкурс · 103 Из одного источника
104 на товарных биржах · 105 Открытый конкурс на понижение
112 без применения способов
auction-statuses PUBLISHED · BIDDING · SUPPLIER_OFFERS · TRADES_PUBLISHED
CANCELED · REJECTED · REFUSE · WAITING_SIGN
WAITING_RESULT_SIGN · COMPLETED
tru-types 0 товары · 1 работы · 2 услуги
Read them from refs/ at runtime rather than hardcoding this table — it is a snapshot, and the endpoint is free.
1.2 It is slow. Design for that.
Measured from a server in Europe:
per_page=50→ 6–13 s for one page, varying run to run.per_page=200→ works, ~21 s. Throughput per row is the same as at 50, so a bigger page buys nothing; an earlier note here called this a timeout, which was our own 25 s client deadline rather than a server cutoff./refs/*→ well under a second./stats→ sub-second warm, ~1.7 s cold.
So: use per_page=50 and a 60 s client timeout — not because larger pages fail, but because they gain nothing and fail slower. Never sweep the whole corpus (707 pages ≈ 1.5–2.5 hours) when a filter would do. There is no published rate limit and no robots.txt; be conservative anyway — one sequential request at a time, no parallel fan-out.
1.3 The two traps that will otherwise give you wrong answers
start_date is an exact-day equality, not a lower bound. start_date=2026-09-01 returns the 88 tenders published on that day — not everything since. Combining start_date=2026-01-01&end_date=2026-01-31 returns 0, because it means "started on 1 Jan and ended on 31 Jan". To cover a period, loop over days.
The default ordering is not newest-first, and is not stable enough to diff. Page 1 opens with the highest numbers but already mixes in an older one; the last page (707 today, and it drifts) is not sorted at all. So do not implement "new since last run" as "read page 1 and diff". Poll by day instead:
import datetime, requests
BASE = "https://api.tizilim.gov.kz/api/public"
def tenders_for_day(day: datetime.date, **filters) -> list[dict]:
"""Every tender published on one day. Deterministic; safe to re-run."""
out, page = [], 1
while True:
r = requests.get(f"{BASE}/tenders", timeout=60, params={
"start_date": day.isoformat(), "page": page, "per_page": 50, **filters,
})
r.raise_for_status()
body = r.json()
out.extend(body["data"])
if page >= body["meta"]["last_page"]:
return out
page += 1
# Yesterday's works-category purchases:
rows = tenders_for_day(datetime.date.today() - datetime.timedelta(days=1),
**{"enstru_type[]": "1"})
A daily watch is then: for each day since the last run, pull that day, filter locally on whatever the customer actually cares about (keywords, ЕНС ТРУ code prefix, БИН, amount), and report. Store what you already reported by number so a re-run is idempotent.
1.4 What the public API does not give you
- No lot→tender link, no documents, no bidder list, no per-lot detail beyond the row.
- Lot rows carry no dates.
/lotsaccepts and honoursstart_date, but the returned row has nostart_date/end_datefield, so you cannot see the value you filtered on. - No customer БИН in the row — only
customer.name_ru. You can filter bycompany_idif the customer already told you their БИН, but you cannot read a БИН back out. - Inconsistent shapes between the two endpoints. A tender's
statusis an object ({name_ru, name_en, name_kz}); a lot'sstatusis a plain string.name_enis frequentlynullon both. Never assume a field is present.
Row shapes, verified:
tender number name_ru name_en name_kz customer{name_*} lots_count
offers_count amount type{name_*} status{name_*} start_date end_date
lot number name_* code description_* quantity amount type{name_*} status(str)
code on a lot is the ЕНС ТРУ classifier (e.g. 422121.100.000000) — that is the right key for "everything in my category", far better than keyword search. A lot's number is an integer, a tender's is a string like 2026.ОК-38085; don't assume one type for both.
1.5 The protocol PDF — reachable, by number
GET /api/public/tenders/{number}/protocol takes the tender number from the list row, and returns the real PDF:
2026.ОИ-38204 → 200 application/pdf, ~50 KB
2026.ОК-38085 → 404 {"message":"Протокол не найден"} the purchase exists, it has no protocol yet
2026.ОК-99999 → 404 {"message":"Закупка не найдена"} no such number
38085 → 404 {"message":"Закупка не найдена"} an integer id is NOT the key
Tell the two 404s apart before concluding anything. «Протокол не найден» means you asked too early — the purchase is still running. «Закупка не найдена» means the key is wrong, and the usual cause is passing an integer id instead of the number. An earlier version of this page made exactly that mistake and concluded the endpoint was unusable.
2. The authenticated API — the whole portal, once you can sign
tizilim.gov.kz is a Nuxt single-page app over a Laravel REST backend at https://tizilim.gov.kz/api/. Every action a user performs in the UI is one call to that API. There is nothing the browser can do that a script cannot.
Auth: a Bearer token, valid 24 h, stored client-side in the auth.token cookie. A CSRF token is fetched from GET /api/csrf-token before the SSO handshake.
Two ways in:
- ЭЦП — the portal's own path. Three calls (§2.1).
- SSO via zakup.gov.kz —
https://zakup.gov.kz/api/sso/connect/authorize?client_id=tizilim&scope=api offline_access&response_type=code&redirect_uri=…, an OpenID Connect authorization-code flow whose callback is posted toPOST /api/auth/sso-callback. It moves the ЭЦП problem to another portal rather than removing it; prefer path 1.
There is no password-only login. /auth/login-esp takes a login and password, but only alongside a signed XML.
2.1 The login chain
POST /api/auth/esign-auth-xml → the XML to sign (no auth needed)
POST /api/auth/login-check-esp {xml: <signed>} → available roles/types
POST /api/auth/login-esp {xml, login, password, type} → {access_token}
The XML the server hands you is trivial — a nonce, nothing more. Despite the field name, the server fills uuid with the caller's own IP address, so yours will differ:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<key_settings_digi_sign>
<uuid>203.0.113.10</uuid>
<date>2026-09-09 11:29:45</date>
</key_settings_digi_sign>
Sign it as enveloped XMLDSig with the user's ЭЦП key and post it back. Read Signing with a Kazakhstan ЭЦП key for how — it is the same mechanism the ЭСФ integration already uses, and it does not require NCALayer.
Signature rejected? The endpoint answers 422 {"success":false,"error":"Ошибка при обработке сертификата"}. That single message covers a malformed signature, a wrong key type and an untrusted chain alike, so debug by changing one thing at a time.
2.2 The endpoint families
Roughly 276 endpoints, all under /api/, grouped as the UI groups them:
| Family | What lives there |
|---|---|
/plan/plan-point/* | The annual plan — including import, export, export-example, sign, sign-empty, revert, delete-all. |
/tender/open-tender/*, /tender/open-tender-reduction/*, /tender/single-source/*, /tender/offtake/* | Creating, editing and publishing a purchase by method: store, {id}/update, {id}/publish, {id}/cancel, {id}/refuse. |
/tender/{id}/protocol/* | Bid opening, bid evaluation, results — each with a -sign twin. |
/tender/{id}/offers/* | Supplier offers: evaluate, compare, reject, archive, winners. |
/my/application*, /my/applications | The supplier side — submitting, clarifying and withdrawing a bid. |
/agreement/* | Contracts: send-to-sign, sign, terminate, not-conclude. |
/reports/*, /report/* | Kazakhstani-content reporting (tpi / uvs), with import, export and sign. |
/company/* | Profile, employees, users, committee, contracts, and keys (not the ЭЦП key — see §3). |
/reestr/*, /refs/*, /notification/* | Registry reads, dictionaries, notifications. |
The highest-value automation is the boring end of that list: /plan/plan-point/import, /plan/plan-point/export, and /reports/submitting/tpi/{id}/import are already file-based. A customer hand-keying an annual plan or a quarterly КС report is doing work that these three endpoints do in one call.
2.3 Every write is a separate signature
The portal's write pattern is uniform, and it is the same shape as login:
POST <action>-xml → unsigned XML the server built from the pending action
sign it locally
POST <action> {xml: <signed>, …} → committed
Examples of the first half: /plan/plan-point/sign-xml, /plan/plan-point/sign-empty-xml, /auth/esign-auth-xml, /auth/password/reset-xml, and per-object paths like /tender/{id}/protocol/result-sign.
Consequences worth stating to the customer before you build anything:
- Signing is per action, not per session. Publishing 40 plan points is 40 signatures. That is fine for a script and impossible by hand, which is exactly why the automation is worth building.
- The ЭЦП key must be reachable for the whole run, not just at login.
- Never sign in a loop without a confirmed plan. Assemble everything, show the user what will be published, get an explicit yes, then sign and post. A published purchase is visible to every supplier in the country.
3. Two different keys. Do not confuse them.
| ЭЦП key | The /company/keys pair | |
|---|---|---|
| What | НУЦ РК .p12 + password | RSA-OAEP-4096, generated in the browser |
| Used for | Login and every signed action | Encrypting bid prices in sealed purchases |
| Where it comes from | The user already has it | POST /api/company/keys returns it once; the private key PEM downloads to the user's disk and the portal never sees it again |
| If it is missing | You cannot log in | You cannot open bids — the purchase is stuck at bid opening |
The second one is a genuine trap: it is created per company via /company/keys/allow-create, deactivated via /company/keys/{id}/deactivate, and the private half exists only as a file the user downloaded, possibly months ago, possibly on a machine that no longer exists. If the automation covers bid opening, ask for that PEM up front — discovering it is gone at the opening deadline is not recoverable.
4. Do not drive the browser
The temptation is to automate the UI because it "requires NCALayer". Both halves of that are wrong:
- The portal is a pure client-side SPA over the same REST API — the HTML shell is 4 KB and contains no data. Driving Chrome buys you nothing the API doesn't give you, and costs you an unstable DOM.
- NCALayer is a desktop Java app on the user's own machine. A browser in a sandbox cannot reach
wss://127.0.0.1:13579, so browser automation does not solve the signing problem — it inherits it.
Call the API. Sign in-process. See Signing with a Kazakhstan ЭЦП key.
5. Before you build on this
- Nothing here is contractual. No API docs, no terms of use, no versioning. An undocumented endpoint can change shape overnight; your code should raise, not shrug.
- Ask the portal. For anything beyond a courteous read cadence, mail
tizilim@qazindustry.gov.kz(the site also runs a Telegram support bot,@iDos_prime_bot). They already advertise integration with the ЕНС ТРУ directory, so an official channel may exist that isn't published — and the answer is worth having in writing before a customer depends on it. - Credentials are the real blocker for writes, not the technology. Write automation needs the customer's ЭЦП key held where the script can reach it. Treat that exactly as Connecting ЭСФ does: workspace credentials,
.gitignorefor*.p12, never in chat or logs, an awareness of who else can see a file in a shared workspace (Scripts and integrations), and an explicit offer to keep the key on the user's own machine instead.
6. What has actually been verified
Honest state as of 2026-09-09, so nobody re-derives it:
- ✅ Verified live: every public endpoint in §1, every reference value, the timings, the ordering and
start_datetraps, and all three protocol responses in §1.5. - ✅ Verified live, with one gap: the filters.
search,status[],auction_type[],enstru_type[],start_dateandend_datewere each exercised against real results.company_idwas only shown to return 0 for values that match no company — no correct БИН was ever used as a positive control, so "it filters by БИН" is read from the portal's own client code, not measured. - ⚠️ Read from the client bundle, not exercised: §3's
/company/keysdescription (RSA-OAEP-4096, generated in the browser, one-time PEM download). - ✅ Verified live:
POST /api/auth/esign-auth-xmlreturns the nonce XML with no authentication, andPOST /api/auth/login-check-espwith a bad payload returns422«Ошибка при обработке сертификата». - ⚠️ Read from the portal's client bundle, not exercised: the §2.2 endpoint list, the request bodies, and the
Bearertoken lifetime. - ❌ Not verified — nobody has done this yet: the signed round trip. No one has produced a signature this portal accepts. That is the one spike to run before promising write automation, and it is cheap: one
esign-auth-xml→ sign →login-check-espthat returns a role list instead of a 422 proves the entire chain.