Short answer: there is an official Etimad Developer Portal, but it has no tenders API. On 10 November 2026 apiportal.etimad.sa listed six products (contract and salary inquiry services for account holders) and none of them searches or lists tenders. The public tenders site, tenders.etimad.sa, loads its list from a JSON endpoint that needs no login or key: that afternoon it reported 291,464 tenders in total, 279 of them published in the last 2 days.
This post documents that endpoint, how to page it safely, where the award results (bidders, prices, winner) are, and the gotchas that cost me time. Every number and output below comes from a request I ran on 10 November 2026.
What Etimad officially offers: the Developer Portal
Etimad is the Saudi government platform on which agencies publish their tenders; the footer of its Developer Portal reads "All rights reserved to the Ministry of Finance - Etimad © 2026 - National Center for Governmental Resources - KSA". The portal's front page says "Link your system with the Etimad data" and listed these products on 10 November 2026:
| Product | Category shown | What it is, in the portal's words |
|---|---|---|
| Bank Contracts Inquiry | Financial Products | "obtain existing and historical contract data related to Government Tenders and Procurements", by contract number and commercial registration |
| Contracts Plus | Financial Products | contract data inquiry; per the portal's FAQ it "provides all contracts under specific Vendor" |
| Salary Certificate | Financial Products | salary certificate of a government employee |
| Detailed Salary Certificate | Financial Products | salary details of a government employee |
| Average Salary Inquiry Service | Financial Products | three or six-month average salary |
| Government Deduction Inquiry Service | Government and semi-government | detailed government deductions |
The Bank Contracts Inquiry page describes its audience as "Financial and Banking Sector, FinTech" and prices an inquiry at SAR 20 to 45 per contract depending on monthly volume. The portal's FAQ says: "To access the developers portal, you must log in with your account on Etimad platform, according to the entity you belong to", and production use must "comply with the requirements based on your user account (Government, Business)".
So the official integration services are for registered entities, and they answer "tell me about this contract" or "this employee's salary". None answers "which tenders were published today". For that there is only the public website. The tenders site links a usage and disclaimer policy in its footer; read it before you rely on the data commercially.
The public Etimad tenders JSON endpoint
The visitor page calls:
GET https://tenders.etimad.sa/Tender/AllSupplierTendersForVisitorAsync ?PageSize=24&PageNumber=1&PublishDateId=2&TenderCategory=0 import requests BASE = "https://tenders.etimad.sa" LISTING = f"{BASE}/Tender/AllSupplierTendersForVisitorAsync" s = requests.Session() s.headers.update({ # the default python-requests User-Agent gets HTTP 403; any browser string works "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*", }) def listing(page=1, window=2, category=0, **filters): """window: 1 any time, 2 = 2 days, 3 = 7 days, 4 = 30 days, 5 = 90 days. category: 0 all, 2 open for offers, 6 award announced.""" params = {"PageSize": 24, "PageNumber": page, "PublishDateId": window, "TenderCategory": category, **filters} return s.get(LISTING, params=params, timeout=120) d = listing().json() print(d["totalCount"], "tenders published in the last 2 days,", len(d["data"]), "on page 1") for t in d["data"][:3]: print(t["referenceNumber"], "|", t["tenderTypeId"], t["tenderTypeName"], "|", t["agencyName"], "| deadline", (t["lastOfferPresentationDate"] or "")[:10], "| fee SAR", t["condetionalBookletPrice"]) 279 tenders published in the last 2 days, 24 on page 1 261039008261 | 2 شراء مباشر | الهيئة السعودية للمياه | deadline 2026-11-13 | fee SAR 0.0 261039008267 | 1 منافسة عامة | الهيئة السعودية للمياه | deadline 2026-11-25 | fee SAR 0.0 261139003764 | 1 منافسة عامة | مستشفى القوات المسلحة بالرياض | deadline 2026-11-25 | fee SAR 500.0 The answer looks like {"data": [...], "totalCount": n, "pageSize": 24, "currentPage": 1}, newest publication first. Parameters I verified:
| Parameter | Values | Checked on 10 Nov 2026 |
|---|---|---|
PublishDateId | 1 any time, 2 last 2 days, 3 last week, 4 last month, 5 last 3 months | 291,464 / 270 / 684 / 2,880 / 7,140 tenders |
TenderCategory | 0 all, 2 open for offers, 6 award announced | open: 1,364; award announced: 242,907 (2,033 in the 3-month window) |
MultipleSearch | keyword, Arabic or English | صيانة (maintenance), last week: 123 |
TenderTypeId | 1 public tender, 2 direct purchase, 11 framework agreement, 15 large companies | public, last month: 1,132 |
TenderAreasIdString | region id 1-13 | 1 (Riyadh), last week: 244 |
TenderActivityId | main activity id 1-19 | 9 (telecommunications and IT), last month: 311 |
PageSize | up to 24 | 50 and 100 still return 24 rows; 10 returns 10 |
The id lists come from two small JSON lookups on the same host: /Tender/GetAreasAsync (13 regions) and /Tender/GetMainActivitiesAsync (19 named activity groups). /Tender/GetAllAgenciesAsync returns 1,887 agencies with their 12-digit codes (302 KB); only 39 of them carry an English name.
The row fields, with the portal's own spelling:
| Field | Meaning |
|---|---|
tenderId, referenceNumber, tenderNumber | identifiers |
tenderIdString | the encoded id used in page and fragment URLs |
tenderName, agencyName, branchName | name, agency and branch, as published |
tenderTypeId, tenderTypeName | type code and Arabic label |
tenderActivityId, tenderActivityName | activity code and Arabic label |
tenderStatusId | status code (the label is usually empty in the listing) |
submitionDate | publication time |
lastEnqueriesDate, lastOfferPresentationDate, offersOpeningDate | enquiry deadline, submission deadline, opening date; each has a ...Hijri twin |
condetionalBookletPrice, invitationCost, financialFees, buyingCost | document fee and other fees, SAR |
remainingDays | days left to submit |
isUGRP | unified government procurement row (see gotcha 3) |
How many tenders does Etimad publish per day?
The 270 tenders in the 2-day window at 14:50 Riyadh time on 10 November were published on 8 November (100), 9 November (89) and 10 November (81 so far). 160 were direct purchases, 103 public tenders, 6 framework agreements and 1 a large-companies tender, from 88 agencies. 179 had free documents; the highest document fee was SAR 5,000. 238 names contained Arabic text and 32 were Latin-only.
How to page through Etimad tenders: 24 rows a page, in order
This is the part that is easy to get wrong. The pages have to be requested one after another, starting at page 1:
import time def walk(window=2, category=0, max_restarts=3, **filters): """Every tender of a window, read in page order, one request at a time.""" seen, page, restarts, total = {}, 1, 0, None while True: r = listing(page, window, category, **filters) if r.status_code == 400: if total is not None and len(seen) >= total: break # an error after the last page is just the end if restarts == max_restarts: r.raise_for_status() restarts += 1 # the portal lost its place: wait, start again from page 1 time.sleep(5) page = 1 continue r.raise_for_status() answer = r.json() total = answer["totalCount"] for t in answer["data"]: seen[t["tenderId"]] = t # unified-procurement rows repeat on later pages if len(answer["data"]) < 24: break page += 1 time.sleep(1) return list(seen.values()), page, restarts t0 = time.time() tenders, pages, restarts = walk(window=2) print(len(tenders), "tenders from", pages, "pages in", round(time.time() - t0), "s,", restarts, "restarts") by_type = {} for t in tenders: by_type[t["tenderTypeName"]] = by_type.get(t["tenderTypeName"], 0) + 1 print(by_type) 279 tenders from 13 pages in 63 s, 0 restarts {'شراء مباشر': 165, 'منافسة عامة': 107, 'منافسات الشركات الكبرى': 1, 'منافسة إتفاقية إطارية': 6} What I measured:
- In order, it works. Six in-order walks (74 page requests) had no error. A page took 1.5 to 4.4 seconds in most requests and 10 to 11 seconds in a few.
- Skipping ahead does not. Of 35 requests that jumped more than one page ahead, 30 answered HTTP 400 with an "unexpected error, try again" message in Arabic. That includes page 50 of the all-time listing, page 20 of the 7-day window and page 5 requested straight after page 3.
- The other 5 answered HTTP 200, and that is worse. After reading pages 1 to 5 of a query I asked for page 8 and received 24 rows that were page 7's (at least 20 of the 24 ids matched), under a
totalCountof 170 where the in-order pages said 172 to 174. Two more jumps returned 24 rows for pages that hold 6 and 16 rows when read in order.
My requests carried no cookies in most of these tests, so whatever remembers the position is not a cookie. I do not know the mechanism; the practical rule is enough: start at page 1, go one page at a time, never in parallel, and when a 400 appears, go back to page 1 instead of retrying the same page. Stop on a page with fewer than 24 rows. Do not wait for an error to mark the end: read in order, the pages after the last one answered HTTP 200 with a few repeated rows; after a jump they answered 400.
How to get Etimad award results: winner, bidders and prices
There is no separate awards listing (/Tender/AwardingStepTenderListAsync answers 404). Awards are two steps: list tenders with TenderCategory=6, then fetch one small HTML fragment per tender.
import html import re from urllib.parse import quote def tables(fragment): """The <tbody> blocks of an Etimad HTML fragment as lists of rows of cell texts.""" out = [] for body in re.findall(r"<tbody.*?</tbody>", fragment, re.S): out.append([[html.unescape(re.sub(r"<[^>]+>", "", td)).strip() for td in re.findall(r"<td.*?</td>", tr, re.S)] for tr in re.findall(r"<tr.*?</tr>", body, re.S)]) return out awarded = listing(window=4, category=6).json() # award announced, published in the last 30 days print(awarded["totalCount"], "tenders with announced awards in the 30-day window") t = next(x for x in awarded["data"] if not x["isUGRP"]) # UGRP tenders have no visitor detail page tid = quote(t["tenderIdString"], safe="") # a space must become %20, never "+" frag = s.get(f"{BASE}/Tender/GetAwardingResultsForVisitorViewComponenet?tenderIdStr={tid}", timeout=60).text found = tables(frag) bidders = found[0] if found else [] winners = found[1] if len(found) > 1 else [] print(t["referenceNumber"], "|", t["agencyName"], "|", t["tenderName"][:60]) print(len(bidders), "bidders, offers SAR:", sorted(float(b[1]) for b in bidders)) for supplier, offer, award in winners: print("awarded:", supplier, "| offer", offer, "| award value", award) print("page:", f"{BASE}/Tender/DetailsForVisitor?STenderId={tid}") 309 tenders with announced awards in the 30-day window 261139001457 | تجمع الرياض الصحي الأول | إعادة تأهيل أماكن انتظار المرضى بالطوارئ والعيادات لزوم مستش 3 bidders, offers SAR: [32999.99, 88996.2, 96600.0] awarded: مؤسسة نهضة العاصمة التجارية | offer 32999.99 | award value 32999.99 page: https://tenders.etimad.sa/Tender/DetailsForVisitor?STenderId=VVZu%2A%40%40%2A%2A9nw3CPf2qcHfbz70w%3D%3D The fragment (3.5 KB for this tender) has two tables: every bidder with the financial offer and the technical result, and the awarded suppliers with offer and award value; the heading of the second carries the award type (here "ترسية كاملة", a full award). Three more sources per tender:
| URL | Size | Content |
|---|---|---|
/Tender/DetailsForVisitor?STenderId= | 300 KB HTML | purpose, document price, status, contract duration, insurance, type, agency, submission method, initial guarantee, award number |
/Tender/GetTenderDatesViewComponenet?tenderIdStr= | 5.7 KB | enquiry dates, offer examination, expected award date, work start date |
/Tender/GetRelationsDetailsViewComponenet?tenderIdStr= | 6.1 KB | contractor classification, execution region and city, activities |
Two things about awards. The publication window applies to the date the tender was published, not the date it was awarded, so an awards feed needs a wide window (3 months: 2,033 rows) and a memory of what you have already seen. And the award listing contains rows that are not awards; see gotcha 3.
Arabic text and the fields with stable codes
Tender names, agencies, branches, activities and supplier names are published in Arabic (about 1 name in 8 in my sample was Latin-only) and are not translated anywhere on the site. For filtering and grouping, use the coded fields instead of the labels:
| Code | Stable values seen on 10 Nov 2026 |
|---|---|
tenderTypeId | 1 منافسة عامة (public tender), 2 شراء مباشر (direct purchase), 11 منافسة إتفاقية إطارية (framework agreement), 15 منافسات الشركات الكبرى (large companies) |
tenderStatusId | 4 on 269 of 270 newly published tenders (approved, open), 15 on tenders with an approved award |
tenderActivityId | 43 distinct activity ids in the 2-day window; the 19 main groups come from GetMainActivitiesAsync |
| region | ids 1-13 from GetAreasAsync (1 منطقة الرياض, 2 منطقة مكة المكرمة, ...); the region is a filter and a detail-fragment field, not a listing field |
referenceNumber | the number people quote, e.g. 261139001457 |
In the listing, agencyCode was filled on 2 of 270 rows and tenderStatusName on 2 of 270, so match agencies by agencyName and statuses by tenderStatusId.
The gotchas
1. 24 rows is the maximum page size. Larger values are accepted and ignored.
2. Pages must be read in order. Jumps return HTTP 400 or, sometimes, the wrong rows with HTTP 200. Details above.
3. Unified-procurement (UGRP) rows are mixed into every listing. They have isUGRP: true and ids from 2,000,000,000. They behave differently from everything else: they repeat at the top of later pages (pages 9 to 14 of one walk each started with the same two), they ignore the category and keyword filters (page 1 of "award announced + public tender, last month" held 22 UGRP rows out of 24, all with status 4, none of them awarded), and their detail URL redirects to the login page. totalCount also changes from page to page inside one walk (174, 173, 172, then 174 again in one of mine). Deduplicate by tenderId and drop isUGRP rows when you need awards or detail pages.
4. The all-time listing is unpredictable. At 09:40 UTC, in a cloud run of mine, page 2 of the unfiltered all-time listing timed out at 90 seconds on each of three attempts; at 11:47 UTC the same request took 3 seconds. And because pages must be read in order, the old end of the list is out of reach in practice: 291,464 tenders are 12,145 pages. Use the windows: the listing is newest first, so the first rows of the 7-day window are the first rows of the all-time listing.
5. Send tenderIdString back exactly as given. 78 of 270 ids contained a space and 82 contained *@@**. Percent-encode them as they are. If you "repair" the space into a plus sign (%2B), the detail URL redirects to the login page.
6. Send a browser User-Agent. The default python-requests one gets HTTP 403.
7. Dates. submitionDate and the deadlines are local Riyadh time without an offset (2026-11-10T14:45:32.049606); createdAt is always 0001-01-01T00:00:00; offersOpeningDate was empty on 161 of 270 rows.
8. No public supplier data without an account. Supplier names appear only inside award fragments. The contract APIs on the Developer Portal need a subscription.
9. Be a polite client. One request at a time is not only courtesy here, it is required. Add a pause, cache the detail pages (they are 300 KB each), and prefer the 2-day window on a schedule over re-reading months.
When not to maintain this yourself
A script like the one above is fine for a look. A daily feed needs the in-order paging with restarts, de-duplication, a memory of delivered tenders, the four detail requests per tender, the Arabic label parsing and English translations of the coded fields, and attention when the portal changes.
I packaged that as the Saudi Etimad Tenders API on Apify (disclosure: I built it). It reads the same public pages and returns one row per tender with English field names, English translations for type, status and region, Gregorian and Hijri deadlines, and with includeDetails the purpose, contract duration, execution city and the award tables. An onlyNew mode delivers each tender once and again when its status or deadline changes.
import os from decimal import Decimal from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("data_daemon/saudi-etimad-tenders").call( run_input={ "publishedWithin": "7d", "tenderTypes": ["public"], "regions": ["Riyadh"], "includeDetails": True, # purpose, contract duration, city, award results "maxItems": 3, }, max_total_charge_usd=Decimal("0.05"), logger=None, ) print(run["status"], "|", run["statusMessage"]) for t in client.dataset(run["defaultDatasetId"]).iterate_items(): print(t["referenceNumber"], "|", t["tenderTypeEn"], "|", t["statusEn"], "|", t.get("regionEn"), "/", t.get("city")) print(" ", t["agency"], "| deadline", t["submissionDeadlineDate"], "| contract", t.get("contractDurationDays"), "days | fee SAR", t["documentFeeSar"]) print(" ", t["name"][:70]) SUCCEEDED | Saved 3 tenders (published in the last week; types public; regions Riyadh), newest first. 3 with detail page fields. Capped by maxItems (3); set maxItems to 0 for everything that matches. 261139003764 | Public tender | Approved (open for offers) | Riyadh / الرياض مستشفى القوات المسلحة بالرياض | deadline 2026-11-25 | contract 1095 days | fee SAR 500 إحلال وأستبدال أجهزه معمل الأسنان 261139003061 | Public tender | Approved (open for offers) | Riyadh / الرياض صندوق التنمية الزراعية | deadline 2026-11-25 | contract 540 days | fee SAR 1500 تحسين نظام الخدمات الداخلية 261139003449 | Public tender | Approved (open for offers) | Riyadh / الرياض المركز الوطني لتنمية الغطاء النباتي ومكافحة التصحر | deadline 2026-12-10 | contract 1080 days | fee SAR 500 الخدمات المدارة للمنصة الوطنية للبرنامج الوطني للتشجير That run took 7 seconds. The price is $0.003 per tender, plus $0.007 per tender when includeDetails reads its detail page, so $0.01 per tender with details. No start fee, no subscription. With the counts measured above:
| Job | Tenders on 10 Nov 2026 | Listing only | With details |
|---|---|---|---|
| Default run: the 50 newest tenders | 50 | $0.15 | $0.50 |
| Everything published in the last 2 days | 279 | $0.84 | $2.79 |
| Last week | 684 | $2.05 | $6.84 |
| Last month | 2,880 | $8.64 | $28.80 |
| Last 3 months | 7,140 | $21.42 | $71.40 |
| Award-announced rows, 3-month window | 2,033 | $6.10 | $20.33 |
The "with details" column is an upper bound: unified-procurement rows have no public detail page and are not charged the detail fee. A daily feed of everything is about 90 to 100 tenders a day at the rate of 8 and 9 November, so roughly $0.27 to $0.30 a day without details. The Actor keeps a unified-procurement row only when it fits the run (stage all or open, your keyword in its name or agency) and flags it isUnifiedProcurement, so an awards pull returns awarded tenders only. A ready-made input: new Saudi government tenders of the last 2 days as JSON.
FAQ
Is there an official Etimad API for tenders?
No. Etimad's Developer Portal (apiportal.etimad.sa) listed six API products on 10 November 2026: two contract inquiry services, three salary services and a government deduction inquiry. None lists or searches tenders, and all require an Etimad account and a subscription for production use.
Do I need an Etimad account to read tenders?
Not for the public listing, the tender detail pages or the published award results; they answer without login. Unified-procurement (UGRP) tenders are the exception: their detail link redirects to the login page.
How do I get Saudi government tenders as JSON?
GET https://tenders.etimad.sa/Tender/AllSupplierTendersForVisitorAsync?PageSize=24&PageNumber=1&PublishDateId=2&TenderCategory=0 with a browser User-Agent returns the newest tenders of the last 2 days as JSON, 24 per page. Request the pages in order.
How do I find who won an Etimad tender and at what price?
List tenders with TenderCategory=6, then fetch /Tender/GetAwardingResultsForVisitorViewComponenet?tenderIdStr=<encoded id>. It is an HTML fragment with a bidders table (supplier, offer, technical result) and an awarded table (supplier, offer, award value).
Is Etimad tender data available in English?
Mostly not. Names, agencies and suppliers are published in Arabic. The type, status, activity and region are codes, so they can be mapped to English once and filtered reliably.
How many tenders does Etimad list?
291,464 on 10 November 2026, of which 242,907 had announced awards and 1,364 were open for offers; 2,880 were published in the last month.

