oushodh / oushodh-php
PHP SDK for the Oushodh medicine data API
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^8.3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^13.4
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-11-13 11:02:11 UTC
README
The official PHP 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 PHP 8.2 or newer. Its one runtime dependency is Guzzle 8.
Install
Install it with Composer from Packagist:
composer require oushodh/oushodh-php
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_...
use Oushodh\Client; $oushodh = new Client(); // reads OUSHODH_API_KEY $result = $oushodh->search('paracetamol', limit: 5); foreach ($result->items as $medicine) { echo $medicine->id, ' ', $medicine->productName, "\n"; } $medicine = $oushodh->getMedicine('MED-012933'); echo $medicine->productName, ': ', $medicine->prices->unit?->amount, " BDT a unit\n";
new Client('oush_...') takes the key directly. With no key and no OUSHODH_API_KEY, it throws an
InvalidArgumentException.
| Method | Route | Returns |
|---|---|---|
meta() |
GET /v1/meta |
Meta |
listMedicines(limit:, cursor:, updatedSince:, q:) |
GET /v1/medicines |
Page |
medicines(limit:, cursor:, updatedSince:, q:) |
GET /v1/medicines, every page |
Generator of Medicine |
search($q, limit:) |
GET /v1/medicines/search |
SearchResult |
getMedicine($ref) |
GET /v1/medicines/{ref} |
MedicineDetail |
imageUrl($file) |
none | the picture's address |
getImage($file) |
GET /v1/images/{file} |
the JPEG bytes |
meta() names your account and counts the medicines your plan reads. Call it to check that a key works.
getMedicine() takes an ID in any profile, an old ID or a UUID. The answer always carries the medicine's current ID,
and its monograph.
Fields keep their API names in camel case: product_name is $medicine->productName. A value no source gives is
null. Every model also has raw, the object as the API sent it, so a field added after this version is still
there: $medicine->raw['new_field'].
Paging
listMedicines() returns one page: items, next and total. Pass next back as cursor for the next page.
next is null on the last page. limit is 1 to 500, and 100 when you leave it out.
$page = $oushodh->listMedicines(limit: 500); while (true) { foreach ($page->items as $medicine) { // ... } if ($page->next === null) { break; } $page = $oushodh->listMedicines(limit: 500, cursor: $page->next); }
medicines() does the same for you. It yields every medicine and follows next to the end:
foreach ($oushodh->medicines(limit: 500) as $medicine) { echo $medicine->id, ' ', $medicine->productName, "\n"; }
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
lastUpdated. - Each later sync passes that time as
updatedSinceand pages to the end. It gets the medicines changed at or after that time, oldest change first.
$since = loadLastSync(); // null on the first sync foreach ($oushodh->medicines(limit: 500, updatedSince: $since) as $medicine) { saveMedicine($medicine->uuid, $medicine); if ($since === null || $medicine->lastUpdated > $since) { $since = $medicine->lastUpdated; } } saveLastSync($since);
updatedSincetakes a string such as2026-11-08T09:30:00Z, or aDateTimeInterface. The client sends aDateTimeInterfacein UTC, to the whole second.- "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
productImage is the address of the medicine's picture, or null. images lists every picture, productImage
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.
getImage() takes the file name, not the whole address, and sends no key:
$url = $medicine->productImage; if ($url !== null && str_starts_with($url, $oushodh->imageUrl(''))) { $jpeg = $oushodh->getImage(basename($url)); }
imageUrl($file) builds the address of a file, such as
50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c.jpg.
Errors
Every error is an Oushodh\Exception\OushodhException. It has status, the HTTP status, and errorCode, an
Oushodh\ErrorCode. Match on the class or on errorCode, never on the message: the message is for people and may
change.
| Class | Status | errorCode |
|---|---|---|
BadRequestException |
400 | BadRequest |
AuthenticationException |
401 | Unauthorized |
PlanException |
403 | PlanExpired or PlanLimit |
NotFoundException |
404 | NotFound |
RateLimitException |
429 | RateLimited or QuotaExceeded |
ServerException |
5xx | Internal |
ConnectionException |
none | none: no answer came back |
When an error body is not JSON, errorCode is null and the message is HTTP <status>. The key never appears in an
error message.
use Oushodh\ErrorCode; use Oushodh\Exception\NotFoundException; use Oushodh\Exception\PlanException; try { $medicine = $oushodh->getMedicine('MED-999999'); } catch (NotFoundException) { // No medicine has that ID. } catch (PlanException $e) { if ($e->errorCode === ErrorCode::PlanLimit) { // 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 tomaxRetriestimes. After that it throws aRateLimitExceptionwith the seconds inretryAfter. - When the month's requests are used up, the API answers
429 quota_exceededuntil the next month. The client never retries it. It throws aRateLimitExceptionat once, andretryAftercounts the seconds to the next month. - After a
5xxor a network error, the client backs off and tries again, up tomaxRetriestimes. It waits 0.5 s, then 1 s, and so on, plus a random part. - 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 throws a PlanException with ErrorCode::PlanLimit. An account with no active plan gets
403 plan_expired (ErrorCode::PlanExpired) on every call.
Configuration
Pass options to the constructor as named arguments:
$oushodh = new Client(apiKey: 'oush_...', timeout: 10.0, maxRetries: 3);
| Option | Default | What it does |
|---|---|---|
apiKey |
OUSHODH_API_KEY |
Your API key. |
baseUrl |
https://api.oushodh.dev |
Where the API is. |
timeout |
30.0 |
Seconds to wait for one answer. |
maxRetries |
2 |
Retries after a 429 rate_limited, a 5xx or a network error. |
http |
a new Guzzle client | Your own Guzzle client, or a Guzzle handler such as a MockHandler. |
In your own tests, pass a Guzzle MockHandler as http, so no request leaves your machine:
use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Psr7\Response; $mock = new MockHandler([ new Response(200, [], '{"client":"acme","id_profile":"default","medicines":1,"last_import":null}'), ]); $oushodh = new Client('oush_test_123', http: $mock);
Development
The dev tools need PHP 8.4 or newer.
composer install
composer test
composer analyse
composer lint
composer test runs PHPUnit, composer analyse runs PHPStan and composer lint runs PHP CS Fixer. The tests use a
Guzzle MockHandler. They never call the API.
Licence
MIT. Copyright Oushodh. See LICENSE.