Programmatic access to Moroccan public procurement — tenders, award history, AI analyses and Go/No-Go verdicts.
This repository is the developer surface of Soumit: the API reference, the machine-readable contract, official clients for Python and TypeScript, and runnable examples.
The API is live. You can see its shape right now, without a key:
curl https://soumit.ma/api/v1{
"object": "service_index",
"version": "1.0.0",
"openapi_url": "/api/v1/openapi.json",
"resources": ["/api/v1/tenders", "/api/v1/awards", "…"],
"scopes": ["tenders:read", "awards:read", "…"],
"rate_limits": { "read_per_hour": 1000, "write_per_hour": 300 }
}Moroccan public buyers publish thousands of consultations a year on
marchespublics.gov.ma. Each one arrives as a dossier de consultation des
entreprises (DCE): a ZIP of scanned PDFs, a CPS running to a hundred pages, an
administrative règlement, price schedules, sometimes a plan set.
For a large firm with a bid desk, that is a staffing line. For the SME that makes up most of the supplier base, it is the reason they do not bid. The decision that matters — is this one worth the three days it will cost to answer? — has to be made from documents that take a day to read.
Soumit ingests the notice and its package, extracts the facts that decide the question, and answers it with a Go / No-Go verdict whose every factor is traceable to a source. This API exposes that as data you can build on.
| Resource | What it is | Shared or private |
|---|---|---|
| Tenders | The published corpus: buyer, object, budget, procedure, region, deadline | Shared — same rows for everyone |
| Awards | Attribution records (procès-verbaux): who won, for how much, against what estimate | Shared |
| Analyses | Structured extraction of a consultation, with a quality tier and per-field provenance | Private to your organisation |
| Verdicts | Go / Watch / No-Go, factor by factor, each with status, weight and provenance | Private |
| Catalogue | What your organisation can supply — references, cost basis, registrations, lead times | Private, read + write |
| Pursuits | Your bid pipeline: stage, decision, submission, outcome | Private, read + write |
| Events & webhooks | Signed, retried delivery of the four event types, plus a pollable log | Private |
Corpus as of 2026-08-14 — 3,693 tenders spanning 2025-11-24 to 2026-08-13, 1,838 award records, 1,998 analyses. Ingestion runs daily.
pip install ./clients/python # or: npm install ./clients/typescriptfrom soumit import Soumit
soumit = Soumit(api_key="sk_live_…")
# Everything closing in the next 30 days, paginated automatically.
for tender in soumit.tenders.auto_paginate(
deadline_before="2026-09-13", status="open"
):
print(tender["deadline"], tender["buyer"]["name"], tender["title"])import { Soumit } from "@soumit/api";
const soumit = new Soumit({ apiKey: process.env.SOUMIT_API_KEY! });
for await (const award of soumit.awards.autoPaginate({ awardedAfter: "2026-01-01" })) {
console.log(award.awardee.name, award.awarded_amount, award.currency);
}Or with no dependencies at all:
curl -H "Authorization: Bearer $SOUMIT_API_KEY" \
"https://soumit.ma/api/v1/tenders?limit=5&status=open"More in examples/ — market-share analysis from award history, a
deadline watcher, and a signature-verifying webhook receiver.
Keys are issued on request while the API is in its pilot phase. Email abdel@envoisms.ma saying what you want to build and which scopes you need, and you will get one.
A key belongs to exactly one organisation, carries only the scopes you asked for, and is shown once. Read and write are separate scopes for every resource that has both, so a reporting integration that must never mutate anything is expressible without trust.
| Authentication & scopes | Bearer keys, the scope vocabulary, wildcards, revocation |
| Pagination | Opaque cursors, and why not offsets |
| Errors | The complete code table and what to retry |
| Rate limits | Budgets, headers, backoff |
| Webhooks | Signature verification, replay defence, retry schedule |
| Data model | Every resource field by field, and the honesty contract |
| Architecture | How the platform is built, and the decisions behind it |
openapi.json |
The full OpenAPI 3.1 contract |
Most of this API is conventional on purpose — Bearer tokens, cursor pagination,
Idempotency-Key, HMAC-signed webhooks. Convention is a feature: nothing here
should need explaining to someone who has integrated a payments API. Three
things are deliberate departures.
The product exists so somebody can decide whether to commit three days of engineering time to a bid. A fabricated budget is worse than a blank one, because a blank one gets checked.
So nothing is ever filled with a plausible substitute. A tender whose notice
published no budget has budget.amount: null — never a sector average. A
Go/No-Go factor with no input has status: "unknown" and value: null, and
its weight leaves the denominator, so it neither rewards nor punishes the
score. When the engine has no blocker and no defensible score, it publishes
total_score: null with a score_withheld_reason rather than a precise-looking
number built on nothing.
Every response carries an unavailable map giving the stable reason code for
each missing field, so a client can distinguish not published, published but
unparseable, and not yet extracted — three situations that mean different
things to a bidder.
Every analysis carries a quality.tier:
decision_ready— passed every readiness gatereview_required— did not; a human should lookdegraded— produced from impaired source material (a scan that would not OCR)
...plus per-field provenance: exact (read verbatim from the consultation),
derived (computed from something that was), inferred, or missing (looked
for, not found). Verdict factors record whether each input was grounded — from
a record — or asserted by a model.
An LLM in the pipeline will occasionally be confidently wrong. Rather than pretend otherwise, the API reports how much of each answer rests on something checkable, and lets the consumer set its own bar.
GET /api/v1/openapi.json is built per request from the same operation registry
the handlers read their required scopes from. It physically cannot describe a
different version of the API than the deployment serving it, because there is
only one table. A checked-in spec is a second source of truth that drifts the
first time someone adds a query parameter.
The copy vendored in this repository is therefore a snapshot, and CI re-fetches the live document daily and fails on drift rather than letting the docs quietly go stale.
marchespublics.gov.ma
│
▼
┌───────────┐ notice + DCE package (ZIP → PDF → OCR)
│ Ingest │ Python, scheduled
└─────┬─────┘
▼
┌───────────┐ structured extraction: object, lots, budget, deadlines,
│ Extract │ required documents, qualifications, penalties
└─────┬─────┘ each field tagged exact / derived / inferred / missing
▼
┌───────────┐ factor scoring against the organisation's catalogue
│ Verdict │ and recorded outcomes → GO / WATCH / NO_GO
└─────┬─────┘
▼
┌───────────┐ Cloudflare D1 (system of record) · R2 (documents)
│ Edge │ Next.js 15 on Pages · /api/v1 on Workers
└─────┬─────┘
▼
this API ──▶ signed webhooks ──▶ your systems
| Layer | Choice |
|---|---|
| Edge compute & API | Cloudflare Workers, Next.js 15 (App Router) on Pages |
| System of record | Cloudflare D1 (serverless SQLite), 18 forward migrations |
| Document storage | Cloudflare R2 — DCE archives, generated artefacts |
| Heavy extraction | Modal — OCR and long-running document jobs off the edge |
| Language models | Provider-neutral OpenAI-compatible client; models are configuration |
| Pipeline | Python 3.11, ~1,400 tests |
| Frontend | React 19, TypeScript, Tailwind |
Tenancy fails closed. A database binding with no tenant scope throws. There
is no fail-open fallback and no override flag: a query against a private table
that forgets organization_id raises rather than returning another
organisation's rows. The handful of paths that legitimately have no session must
declare it at the call site with a written reason, and that declaration is
itself refused once a second organisation exists — so it cannot rot into a leak.
The shared corpus (tenders, awards) is explicitly separated from the private
overlay, which is why the same endpoint can serve public data and private
analysis without either leaking into the other.
Idempotency, at-least-once, and an audit trail. Writes accept
Idempotency-Key: the same key with the same body replays the first response,
the same key with a different body is a 409. Webhook delivery is at-least-once
with six retries over 24 hours, every attempt recorded and readable from
/api/v1/webhook-deliveries — the honest answer to "did you actually send
it?" is a row, not a shrug.
.
├── openapi.json # OpenAPI 3.1 contract (snapshot; CI checks drift)
├── docs/ # Guides — auth, pagination, errors, webhooks, data model
├── clients/
│ ├── python/ # pip-installable client, no dependencies beyond httpx
│ └── typescript/ # zero-dependency client, fetch-based
├── examples/
│ ├── python/ # deadline watcher, award market-share analysis
│ └── node/ # signature-verifying webhook receiver
└── .github/workflows/ # daily spec-drift check
v1 is stable and in pilot use. Additive changes — new fields, new optional
parameters, new event types — ship without a version bump, so clients must
ignore unknown fields. Anything that would break a conforming client gets
/api/v2 and an announced deprecation window on v1.
The tender and award corpus is derived from notices published by Moroccan public buyers on the national procurement portal — public information by law. Soumit adds normalisation, matching and extraction on top. Document excerpts from third -party consultation packages are not republished through this API; analyses report only whether an excerpt supports a field, never the excerpt itself.
The code in this repository — clients, examples, docs — is MIT licensed. Access to the hosted API is governed by its own terms.
Built and maintained by @n4ouri.
Key requests and questions: abdel@envoisms.ma Bugs in the clients or docs: open an issue.