Oushodh Python SDK
The official Python client for the Oushodh API.
What Oushodh is
Oushodh serves Bangladesh medicine data over a REST API: brands, generics, prices and makers. Make a key in the member portal at app.oushodh.dev, and read the docs at oushodh.dev/docs.
The SDK needs Python 3.10 or newer. Its one dependency is httpx. It has a sync
client, Oushodh, and an async client, AsyncOushodh, and it is fully typed.
Install
Install release 0.1.0 from GitHub:
pip install "git+https://github.com/oushodh/oushodh-python.git@v0.1.0"
# or
uv add "git+https://github.com/oushodh/oushodh-python.git@v0.1.0"
Install from PyPI is coming soon.
Quickstart
Make a key on the Keys page of the member portal. A key starts with oush_ and is shown once. Keep it on your
server: never put it in a web page or an app.
export OUSHODH_API_KEY=oush_...
from oushodh import Oushodh
client = Oushodh() # reads OUSHODH_API_KEY
result = client.search("paracetamol", limit=5)
for medicine in result["items"]:
print(medicine["id"], medicine["product_name"])
medicine = client.get_medicine("MED-012933")
unit = medicine["prices"]["unit"]
print(medicine["product_name"], unit and unit["amount"], "BDT a unit")
Oushodh("oush_...") takes the key directly. With no key and no OUSHODH_API_KEY, it raises an OushodhError.
Close the client when you are done, or use it in a with block. AsyncOushodh has the same methods: await them, and
use async for to page.
from oushodh import AsyncOushodh
async with AsyncOushodh() as client:
async for medicine in client.medicines(q="paracetamol"):
print(medicine["id"])
| Method | Route | Returns |
|---|---|---|
meta() |
GET /v1/meta |
Meta |
list_medicines(limit=, cursor=, updated_since=, q=) |
GET /v1/medicines |
Page |
medicines(limit=, updated_since=, q=) |
GET /v1/medicines, each page |
an iterator of Medicine |
search(q, limit=) |
GET /v1/medicines/search |
SearchResult |
get_medicine(ref) |
GET /v1/medicines/{ref} |
MedicineDetail |
image_url(file) |
none | the picture's address |
get_image(file) |
GET /v1/images/{file} |
the picture's bytes |
meta() names your account and counts the medicines your plan reads. Call it to check that a key works.
get_medicine() takes an ID in any profile, an old ID or a UUID. The answer always carries the medicine's current
ID, and its monograph.
Every model is a typed dict, so it is a plain dict at run time, with the API's field names. A value no source gives
is None. Fields the API adds later are kept in the dict.
Paging
list_medicines() returns one page: items, next and total. Pass next back as cursor for the next page.
next is None on the last page. limit is 1 to 500, and 100 when you leave it out.
page = client.list_medicines(limit=500)
while True:
for medicine in page["items"]:
...
if page["next"] is None:
break
page = client.list_medicines(limit=500, cursor=page["next"])
medicines() does the same for you. It yields every medicine and follows next to the end:
for medicine in client.medicines(limit=500):
print(medicine["id"], medicine["product_name"])
Search has no cursor. To page through every match, use medicines(q=...).
Keeping a copy in sync
Copy every medicine once, then fetch only what changed.
- The first sync pages through every medicine and keeps the newest
last_updated. - Each later sync passes that time as
updated_sinceand pages to the end. It gets the medicines changed at or after that time, oldest change first.
since = load_last_sync() # None on the first sync
for medicine in client.medicines(limit=500, updated_since=since):
save_medicine(medicine["uuid"], medicine)
if since is None or medicine["last_updated"] > since:
since = medicine["last_updated"]
save_last_sync(since)
updated_sincetakes a string such as2026-11-08T09:30:00Z, or adatetimewith a timezone. The client sends adatetimein UTC, to the whole second. Adatetimewith no timezone raises aValueError.- "At or after" means a change in the same second is never missed. The medicine that set the time comes back once
more, so save by
uuid. - Store
uuidas the key: it never changes. Showidto people. - Medicines are never deleted and IDs are never reused, so a sync never has to remove one.
Images
product_image is the address of the medicine's picture, or None. images lists every picture, product_image
first. An address under /v1/images/ is a copy Oushodh keeps. It needs no key, so an <img> tag can show it, and it
never changes. Any other address belongs to the source, and it can move or be removed.
get_image() takes the file name, not the whole address, and sends no key:
url = medicine["product_image"]
if url and url.startswith(client.image_url("")):
jpeg = client.get_image(url.rsplit("/", 1)[-1])
image_url(file) builds the address of a file, such as
50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c.jpg.
Errors
Every error is an OushodhError. It has status (the HTTP status), code (the API's error code) and message.
Match on the class or on code, never on message: the message is for people and may change.
| Class | Status | code |
|---|---|---|
BadRequestError |
400 | bad_request |
AuthenticationError |
401 | unauthorized |
PermissionDeniedError |
403 | plan_expired or plan_limit |
NotFoundError |
404 | not_found |
RateLimitError |
429 | rate_limited or quota_exceeded |
InternalServerError |
5xx | internal |
APIConnectionError |
none | none: the network failed |
APITimeoutError |
none | none: the request timed out |
APITimeoutError is an APIConnectionError. Both have status and code set to None. When an error body is not
JSON, code is None and the message is HTTP <status>. The key never appears in an error message.
from oushodh import NotFoundError, PermissionDeniedError
try:
medicine = client.get_medicine("MED-999999")
except NotFoundError:
medicine = None # No medicine has that ID.
except PermissionDeniedError as error:
if error.code != "plan_limit":
raise
medicine = None # The medicine is beyond the medicines your plan reads.
Retries and rate limits
Your plan sets how many requests each key may make in a minute (60 a minute on Developer). It also sets how many requests your account may make in a month. See Rate limits.
- Over the minute's limit, the API answers
429 rate_limited. TheRetry-Afterheader says how many seconds to wait. The client waits that long and tries again, up tomax_retriestimes. After that it raises aRateLimitErrorwith the seconds inretry_after. - When the month's requests are used up, the API answers
429 quota_exceededuntil the next month. The client never retries it. It raises aRateLimitErrorat once, andretry_aftercounts the seconds to the next month. - After a
5xx, a network error or a timeout, the client backs off and tries again, up tomax_retriestimes. Each wait is about twice the last, with a random part, and at most 8 seconds. - The client never retries a
400,401,403or404: the same request gets the same answer.
Every route is a GET, so a retry is always safe.
Plan limits
Developer reads the first 5,000 medicines and Team the first 20,000, in the order of their permanent number. Agency and Custom read all of them.
Lists and search leave out the medicines beyond your plan's number. A lookup by ID of such a medicine answers
403 plan_limit: the client raises a PermissionDeniedError with code plan_limit. An account with no active plan
gets 403 plan_expired on every call.
Configuration
Pass options to the client as keyword arguments:
client = Oushodh("oush_...", timeout=10.0, max_retries=3)
| Option | Default | What it does |
|---|---|---|
api_key |
OUSHODH_API_KEY |
Your API key. |
base_url |
https://api.oushodh.dev |
Where the API is. |
timeout |
30.0 |
Seconds to wait for each request. |
max_retries |
2 |
Retries after a 429 rate_limited, a 5xx or a network error. |
http_client |
a new httpx.Client |
Your own httpx.Client (httpx.AsyncClient for AsyncOushodh). |
In your own tests, pass an httpx.Client with a mock transport, so no request leaves your machine:
import httpx
transport = httpx.MockTransport(
lambda request: httpx.Response(
200, json={"client": "acme", "id_profile": "default", "medicines": 1, "last_import": None}
)
)
client = Oushodh("oush_test_123", http_client=httpx.Client(transport=transport))
Development
uv sync
uv run ruff check
uv run ruff format --check
uv run mypy
uv run pytest
The tests use httpx.MockTransport. They never call the API.
Licence
MIT. Copyright Oushodh. See LICENSE.
Metadata
Release files for oushodh 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| oushodh-0.1.0.tar.gz | 51.2 kB | With provenance | Details for oushodh-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| oushodh-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for oushodh-0.1.0-py3-none-any.whl |
Total release size: 65.5 kB
Release files / oushodh-0.1.0.tar.gz
| Download URL | oushodh-0.1.0.tar.gz |
|---|---|
| Size | 51.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c1235b2192531e96bf6b027b55261560e42b4b39f1a46b64d710ff5a2fca1b5f
|
|
BLAKE2b-256 checksum How to use checksums |
f5cdfae3a5ea71af904c2d7de89925093ae9c5bcba23baae29a13618b0d43e31
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 12, 2026.
Transparency logRelease files / oushodh-0.1.0-py3-none-any.whl
| Download URL | oushodh-0.1.0-py3-none-any.whl |
|---|---|
| Size | 14.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
504fd33c18acf6ce88d3184fec8a1361145b8b9c3cedfe0d85799bcb168e9341
|
|
BLAKE2b-256 checksum How to use checksums |
93629ebac473ff5aa2b06cde03eee871e2dd7c71f7f6bb9c1219e50c868b640b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 12, 2026.
Transparency logSecurity for oushodh 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for oushodh-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 12, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.