Skip to main content

Partner API

DawaTrack Partner API

Track, trace, and recall compliance for manufacturers, distributors and pharmacies — integrate directly from your own ERP, warehouse system or POS. Everything below is real: every endpoint, code sample and response shape matches what's actually deployed.

dawatrack.com/partner-api/v1/docs/
DawaTrack Partner API interactive Swagger documentation - browsable endpoints grouped by resource, with request/response schemas

The real interactive API explorer, live from this deployment.

Overview

This is the API a manufacturer's ERP (e.g. SAP Business One), a distributor's warehouse system, or a third-party pharmacy system calls — it is not the pharmapp tenant application itself.

Base URL: https://api.dawatrack.com/partner-api/v1/ — also reachable at https://dawatrack.com/partner-api/v1/. Same app, same data; api. is just a dedicated subdomain, not a separate service.

Partner typeCan do
MANUFACTURERRegister/update its own products, confirm shipments into a facility's inventory, open a recall, read its own recalls/trace history
DISTRIBUTOR / PHARMACYRead recalls affecting its own facility, acknowledge a recall notice for that facility

There is no PPB partner type and no way to open a statutory recall through this API — PPB has no live API as of this writing, so a statutory recall is always entered by an internal DawaTrack compliance officer through separate tooling. Every recall opened here is tagged trigger_type: VOLUNTARY, and that field cannot be overridden by the request body.

Sandbox vs. production

Every credential carries an environment: SANDBOX or PRODUCTION — same pattern as Daraja or Stripe test/live keys. Get a sandbox credential instantly, no review, no licence number — it comes pre-seeded with a demo manufacturer, a demo product and a demo batch, so reads return real-looking data from the first call.

EnvironmentCan doBlocked
SANDBOXAll reads, webhooks, supply-chain events, recall drills — against your own isolated demo dataWriting the shared product catalogue, opening a real recall, confirming a real batch shipment
PRODUCTIONEverything, against real facility dataNothing — requires the review below first

A sandbox credential and a production credential can both exist on the same partner account — apply for production once you're ready to go live; your existing sandbox credentials keep working afterward, so you always have somewhere safe to test changes.

Postman collection

Every endpoint on this page — auth, products, batches, events, recalls, drills, webhooks, and the public recall lookup — is also available as a ready-to-run Postman collection, generated directly from this API's real request/response shapes.

Import without downloading anything:

  1. In Postman: ImportLink tab.
  2. Paste this URL: https://www.dawatrack.com/static/postman/dawatrack-partner-api.postman_collection.ea245196efc9.json
  3. Set the collection's client_id/client_secret variables (Postman → collection → Variables tab), then run 0. Auth → Get Access Token — its test script saves the returned token automatically, so every other request just works.

This is a self-hosted collection imported by link, not one published to Postman's own workspace network — so there's no one-click "Run in Postman" badge here (that requires publishing to a public Postman workspace, a one-time step on our side). Importing by link works identically and needs no Postman account beyond having the app itself.

1. Getting credentials

For production, apply here with your organization name, partner type, and either a PPB licence number (manufacturer) or business registration number (distributor/pharmacy). No live PPB or business-registry API exists yet, so a human on our compliance team verifies the claimed number manually before approving your application — this typically takes 1-3 business days.

Once approved, your client_id and client_secret are emailed to you directly, scoped to a fixed permission set for your partner type. That email is the only time your plaintext client_secret is ever shown — it's hashed at rest and cannot be recovered afterward. If it's lost or compromised, contact us for a new credential rather than trying to recover the old one.

A distributor/pharmacy credential additionally needs a facility link (which tenant schema it represents) before recalls:read/recalls:acknowledge do anything useful — mention your facility when you apply, or after approval, so it can be linked.

2. Authenticating

This API uses the OAuth2 client-credentials grant. Exchange your credentials for a short-lived (1 hour) bearer token:

curl -X POST https://api.dawatrack.com/partner-api/v1/oauth/token/ \
  -H "Content-Type: application/json" \
  -d '{"client_id": "dtpk_...", "client_secret": "..."}'
{
  "access_token": "AbC123...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "recalls:read recalls:write",
  "environment": "SANDBOX"
}

Send it on every subsequent call:

Authorization: Bearer AbC123...

The token is opaque (not a JWT) — treat it as a bearer secret, don't attempt to decode it. Request a new one once it expires; there is no refresh-token flow.

Code examples

Get a token, then trace a batch — the two-call pattern every integration starts with

The endpoint used here, GET /batches/{gtin}/{batch_number}/, is documented in full under Batches below. These snippets are illustrative — minimal error handling, no retry/backoff — not a production client library; the request/response shapes match what the API actually sends and expects.

# 1. Get an access token
curl -X POST https://api.dawatrack.com/partner-api/v1/oauth/token/ \
  -H "Content-Type: application/json" \
  -d '{"client_id": "dtpk_...", "client_secret": "..."}'

# 2. Use it to trace a batch (GTIN + batch number)
curl https://api.dawatrack.com/partner-api/v1/batches/12345678901231/B2026001/ \
  -H "Authorization: Bearer <access_token>"
# pip install requests
import requests

API_BASE = "https://api.dawatrack.com/partner-api/v1"

token_resp = requests.post(
    f"{API_BASE}/oauth/token/",
    json={"client_id": "dtpk_...", "client_secret": "..."},
)
token_resp.raise_for_status()
access_token = token_resp.json()["access_token"]

trace_resp = requests.get(
    f"{API_BASE}/batches/12345678901231/B2026001/",
    headers={"Authorization": f"Bearer {access_token}"},
)
trace_resp.raise_for_status()
print(trace_resp.json())
// Node 18+ (global fetch), or any modern browser/runtime
const API_BASE = "https://api.dawatrack.com/partner-api/v1";

const tokenRes = await fetch(`${API_BASE}/oauth/token/`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ client_id: "dtpk_...", client_secret: "..." }),
});
const { access_token } = await tokenRes.json();

const traceRes = await fetch(`${API_BASE}/batches/12345678901231/B2026001/`, {
  headers: { Authorization: `Bearer ${access_token}` },
});
const trace = await traceRes.json();
console.log(trace);
<?php
// Requires the cURL extension (bundled with most PHP installs)
$apiBase = "https://api.dawatrack.com/partner-api/v1";

$ch = curl_init("$apiBase/oauth/token/");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
    CURLOPT_POSTFIELDS => json_encode([
        "client_id" => "dtpk_...",
        "client_secret" => "...",
    ]),
]);
$token = json_decode(curl_exec($ch), true)["access_token"];
curl_close($ch);

$ch = curl_init("$apiBase/batches/12345678901231/B2026001/");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
]);
$trace = json_decode(curl_exec($ch), true);
curl_close($ch);

print_r($trace);
// java.net.http.HttpClient, built in since Java 11.
// Use a real JSON library (e.g. Jackson) in production - string
// splitting is only for a minimal, dependency-free example.
import java.net.URI;
import java.net.http.*;
import java.net.http.HttpResponse.BodyHandlers;

var client = HttpClient.newHttpClient();
var apiBase = "https://api.dawatrack.com/partner-api/v1";

var tokenReq = HttpRequest.newBuilder()
    .uri(URI.create(apiBase + "/oauth/token/"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(
        "{\"client_id\":\"dtpk_...\",\"client_secret\":\"...\"}"))
    .build();
var tokenBody = client.send(tokenReq, BodyHandlers.ofString()).body();
var accessToken = tokenBody.split("\"access_token\":\"")[1].split("\"")[0];

var traceReq = HttpRequest.newBuilder()
    .uri(URI.create(apiBase + "/batches/12345678901231/B2026001/"))
    .header("Authorization", "Bearer " + accessToken)
    .GET()
    .build();
System.out.println(client.send(traceReq, BodyHandlers.ofString()).body());

3. Scopes

Your credential is issued with a fixed subset of these scopes. A call missing the required scope gets 403 Forbidden.

ScopeGrants
products:readGET /products/, GET /products/{gtin}/
products:writePOST /products/
facilities:readGET /facilities/verify/
batches:readGET /batches/, GET /batches/{gtin}/{batch_number}/, GET /batches/{gtin}/{batch_number}/tree/, GET /units/{gtin}/{serial}/, GET /aggregations/{sscc}/
batches:writePOST /batches/ (manufacturer only)
events:writePOST /events/, POST /aggregations/ (manufacturer only)
recalls:readGET /recalls/, GET /recalls/{id}/, GET /recalls/{id}/tree/, GET /recalls/{id}/report/ (report is manufacturer only; tree is open to both, scoped per partner)
recalls:writePOST /recalls/, POST /recalls/drill/ (manufacturer only)
recalls:acknowledgePOST /recalls/{id}/acknowledge/ (distributor/pharmacy)
webhooks:manageGET/POST /webhooks/, DELETE /webhooks/{id}/, GET /webhooks/{id}/deliveries/

Products

The medicine catalogue

GET /products/ — browse the catalogue. Supports ?search=, ?dosage_form=, ?schedule=, ?is_controlled=.

GET /products/{gtin}/ — look up one product by its GS1 GTIN (8, 12, 13 or 14 digits — normalized internally).

POST /products/ requires products:write Production only — register or update a product you own:

{
  "gtin": "12345678901231",
  "generic_name": "Amoxicillin",
  "strength": "500mg",
  "dosage_form": "CAP",
  "schedule": "OTC",
  "is_controlled": false
}

Matched first by GTIN, then by (generic name, strength, dosage form) if no GTIN is given. You can only create a new product or update one your own credential already owns.

PPB authenticity fields — every response also carries ppb_registration_number, ppb_registration_date, ppb_registration_expiry_date, country_of_origin, is_locally_manufactured, local_technical_representative, needs_review, import_source, and a computed ppb_verified boolean — true only when import_source is PPB_KENYA and needs_review is false. "Exists in the catalogue" and "genuinely PPB-verified" are not the same thing — check this field rather than assuming.

Facilities

Checking a license number against PPB's real register

GET /facilities/verify/?license_number=... requires facilities:read — checks a license number against PPB's own government facility register (11,000+ real Kenyan pharmacies, hospitals, wholesalers and manufacturers, synced nightly), not just Dawatrack's own supplier/tenant tables. Lets you check any Kenyan facility's license before deciding whether to trust or ship to it.

{
  "license_number": "BU202608770",
  "found_in_ppb_register": true,
  "facility_name": "CUREWAVE PHARMACY",
  "business_type": "RETAIL",
  "county": "Nairobi",
  "license_status": "VALID",
  "valid_till": "2026-12-31",
  "ppb_last_synced_at": "2026-09-03T04:30:12Z",
  "is_dawatrack_supplier": false,
  "is_dawatrack_pharmacy": false
}

Always 200, never 404 for "not in PPB's register" — found_in_ppb_register: false is itself a valid answer. is_dawatrack_supplier/is_dawatrack_pharmacy are a separate, independent cross-reference against Dawatrack's own accounts — check both on their own terms rather than assuming they agree with found_in_ppb_register. 400 if license_number is missing.

Batches

Trace and commissioning

GET /batches/ — your own distribution ledger.

GET /batches/{gtin}/{batch_number}/ — full distribution history for one lot. Flat, direct-from-supplier deliveries only — see the tree endpoint below for wholesale resale hops.

GET /batches/{gtin}/{batch_number}/tree/ — the multi-hop distribution tree for this batch, before any recall exists: every facility that holds or has held it, nested under whoever resold it to whom, each node carrying live quantity_sold and quantity_on_hand (not a cached snapshot — a fresh read on every call). Scoped the same as GET /recalls/{id}/tree/ below: a manufacturer gets the whole tree; a distributor/pharmacy gets only their own linked facility/facilities as roots, including one that holds this batch purely through a wholesale resale.

[
  {
    "notice": {
      "schema_name": "riverside", "pharmacy_name": "Riverside Pharmacy",
      "medicine_name": "Amoxicillin 500mg", "gtin": "12345678901231",
      "hop": 1, "quantity": 200, "quantity_sold": 35, "quantity_on_hand": 165
    },
    "children": [
      {
        "notice": {
          "schema_name": "sunrise", "pharmacy_name": "Sunrise Pharmacy",
          "medicine_name": "Amoxicillin 500mg", "gtin": "12345678901231",
          "hop": 2, "quantity": 25, "quantity_sold": 0, "quantity_on_hand": 25
        },
        "children": []
      }
    ]
  }
]

POST /batches/ requires batches:write, manufacturer only Production only — confirm a shipment into a facility that has already received it into its own inventory:

{
  "gtin": "12345678901231",
  "batch_number": "B2026001",
  "quantity": 500,
  "schema_name": "sunrise_pharmacy"
}

Or with a scanned GS1 DataMatrix string instead of gtin/batch_number: {"gs1_element": "01...1726...", "quantity": 500, "schema_name": "..."}.

Returns 404 if no matching batch exists yet in that facility's inventory, 403 if it belongs to a different supplier.

Events

Supply-chain log

POST /events/ requires events:write — append-only ship/receive/dispense/quarantine log:

{
  "event_type": "SHIP",
  "gtin": "12345678901231",
  "batch_number": "B2026001",
  "occurred_at": "2026-08-20T09:00:00Z"
}

occurred_at defaults to now if omitted.

Units & aggregations

Unit-level serialization, for PPB's January 2027 mandate

GET /units/{gtin}/{serial}/ requires batches:read — one physical unit's full custody trail, newest first, plus its current status:

{
  "gtin": "12345678901231",
  "serial_number": "SN00042",
  "status": "SHIP",
  "events": [
    { "event_type": "SHIP", "occurred_at": "2026-08-21T10:00:00Z", "...": "..." },
    { "event_type": "COMMISSION", "occurred_at": "2026-08-20T09:00:00Z", "...": "..." }
  ]
}

status is the most recent event's type — one of COMMISSION, SHIP, RECEIVE, DISPENSE, QUARANTINE, AGGREGATE. Returns 404 if the unit was never commissioned, or isn't visible to your credential.

POST /aggregations/ requires events:write, manufacturer only — declare what's packed under one SSCC (pallet/case):

{
  "sscc": "003456789012345675",
  "children": [
    { "gtin": "12345678901231", "serial": "SN00042" },
    { "gtin": "12345678901231", "serial": "SN00043" }
  ]
}

Every child unit must already have a COMMISSION event on file — not restricted to units your own credential commissioned, since a repackager aggregating another manufacturer's already-commissioned units is a real scenario. This is capture-and-forward only: DawaTrack doesn't independently verify aggregation integrity, since GS1 doesn't encode contents in the SSCC digits and PPB's central repository (PRIMS) is the verification authority for that.

GET /aggregations/{sscc}/ requires batches:read — look up what was declared packed under an SSCC. Not scoped to the requesting partner — the point is a downstream distributor or pharmacy reading what the manufacturer declared.

Returns 403 if a child batch belongs to a different supplier account than yours, 404 if a child unit was never commissioned or no aggregation exists for the SSCC, 400 for a malformed SSCC (checked against its GS1 check digit).

Recalls

GET /recalls/ — recalls you opened (manufacturer) or that affect a facility you're linked to.

GET /recalls/{id}/ — full detail: PPB deadlines, acknowledgment progress, recall_level and geographic_scope (PPB's own published wording), patient_instructions (when the recall has one, distinct from the facility-facing instructions), and manufacturer/generic_name/local_technical_representative read straight off the recalled product's own catalogue record.

GET /recalls/{id}/tree/ — the distribution tree, nested under whoever resold to whom, however many wholesale hops deep. A manufacturer gets the whole tree; a distributor/pharmacy gets one root per facility their credential is linked to, each with only that facility's own downstream attached — never the rest of the tree.

[
  {
    "notice": { "id": 501, "hop_depth": 1, "pharmacy_name": "Riverside Pharmacy", "quantity_on_hand_at_recall": 40, "quarantined_at": "2026-09-01T13:19:57Z", "acknowledged_at": null },
    "children": [
      { "notice": { "id": 507, "hop_depth": 2, "pharmacy_name": "Sunrise Pharmacy", "quantity_on_hand_at_recall": 12, "quarantined_at": "2026-09-01T13:21:04Z", "acknowledged_at": null }, "children": [] }
    ]
  }
]

POST /recalls/ requires recalls:write, manufacturer only Production only — open a recall for a batch you've actually shipped. Use the drill endpoint below to try this on a sandbox credential instead:

{
  "batch_number": "B2026001",
  "reason": "CONTAMINATION",
  "severity_class": "CLASS_II",
  "instructions": "Quarantine and await collection instructions."
}

reason: QUALITY_DEFECT, CONTAMINATION, MISLABELING, ADVERSE_REACTION, REGULATORY_WITHDRAWAL, OTHER. severity_class: CLASS_I (serious harm/death), CLASS_II (temporary/reversible), CLASS_III (unlikely adverse reaction) — per PPB Guidelines Section 7, and drives re-notification timing (24h/72h/7d). Every affected facility is notified and its batch quarantined before this call returns — including a facility that received the batch secondhand via another pharmacy's confirmed wholesale resale, not just facilities you shipped to directly.

POST /recalls/{id}/acknowledge/ requires recalls:acknowledge — confirm receipt for your own facility:

{ "schema_name": "sunrise_pharmacy", "notes": "Stock pulled from shelf." }

schema_name can be omitted if linked to exactly one facility.

Recall drill

Exercise your recall system without notifying anyone

POST /recalls/drill/ requires recalls:write, manufacturer only Sandbox-safe — for PPB's annual "challenge your own recall system" requirement (Guidelines §2.11.4l). Same body as POST /recalls/.

{
  "batch_number": "B2026001",
  "reason": "CONTAMINATION",
  "severity_class": "CLASS_II",
  "instructions": "DRILL - no action required."
}

Counts how many facilities a real recall would reach (drill_facilities_reached, including facilities reached only through a wholesale resale of the batch), broken down by hop depth in drill_facilities_by_hop, but creates no notice, sends no email, quarantines nothing. The returned record (is_drill: true) is your queryable audit evidence. Deliberately a separate endpoint from POST /recalls/ — the wrong choice fails safe (nothing happens), never unsafe.

Recall report

PPB compliance-report export

GET /recalls/{id}/report/?stage=initial|follow_up|final requires recalls:read, manufacturer only

One of the three timeline-fixed reports PPB requires per recall (Guidelines §2.13.3): initial at 1 week, follow-up at 2 weeks, final at 4 weeks.

{
  "recall_id": 42,
  "stage": "initial",
  "facilities_total": 12,
  "facilities_acknowledged": 9,
  "facilities_pending": 3,
  "percentage_acknowledged": 75.0,
  "total_units_on_hand_at_recall": 480,
  "notices": [ ... ]
}

Reports only what's actually tracked — acknowledgment and quantity-on-hand snapshots. It does not report "percentage returned/destroyed": that disposition data isn't captured, so it won't invent numbers nobody confirmed.

Public recall lookup

No credential required

GET /recall-lookup/{gtin}/{batch_number}/ — the one endpoint here needing no authentication. "Has this batch been recalled?" — free, IP-rate-limited (60/hour), meant to be embedded directly in a dispense flow or consumer app as a cheap safety check.

curl https://api.dawatrack.com/partner-api/v1/recall-lookup/07712345678909/B2026001/
{
  "batch_number": "B2026001",
  "recalled": true,
  "recalls": [
    {
      "supplier_name": "Acme Pharma",
      "medicine_name": "Amoxicillin 500mg",
      "gtin": "12345678901231",
      "status": "OPEN",
      "severity_class": "CLASS_II",
      "instructions": "Quarantine and await collection instructions.",
      "initiated_at": "2026-08-20T09:00:00Z"
    }
  ]
}

Matches on {gtin} and {batch_number} together, not batch_number alone — a batch/lot number is assigned independently by each manufacturer, so two unrelated products can legitimately share the same batch_number text. A recall with no GTIN on file is never returned, even if the batch number matches.

Never includes facility-specific data (which pharmacies were notified, quantities) — that's tenant distribution data, not public safety information. Recall drills never appear here.

Webhooks

Push instead of polling

POST /webhooks/ requires webhooks:manage — register a URL to receive events instead of polling GET /recalls/:

{
  "target_url": "https://your-system.example.com/dawatrack-webhooks",
  "event_types": ["recall.issued", "recall.escalated", "hold.lifted"]
}

The response includes a secret — save it, it's not re-displayed. GET /webhooks/ lists subscriptions, DELETE /webhooks/{id}/ removes one, GET /webhooks/{id}/deliveries/ shows delivery attempts including permanent failures.

EventFires when
recall.issuedA recall is opened — via POST /recalls/ or through the supplier portal directly — and you're the manufacturer or an affected facility
recall.escalatedA facility hasn't acknowledged within its severity window and gets re-notified
hold.liftedEvery facility affected by a recall has acknowledged it

Verifying a delivery

Every delivery carries:

X-Dawatrack-Signature: sha256=<hex hmac>
X-Dawatrack-Event-Id: <uuid, stable across retries>
X-Dawatrack-Event-Type: recall.issued
import hashlib, hmac

expected = hmac.new(your_webhook_secret.encode(), request.body, hashlib.sha256).hexdigest()
received = received_signature.removeprefix("sha256=")
if not hmac.compare_digest(expected, received):
    reject()
const crypto = require("crypto");

function verifyWebhook(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const received = signatureHeader.replace(/^sha256=/, "");
  // Buffers must be equal length for timingSafeEqual - guard first.
  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
<?php
function verify_webhook(string $rawBody, string $signatureHeader, string $secret): bool {
    $expected = hash_hmac("sha256", $rawBody, $secret);
    $received = str_replace("sha256=", "", $signatureHeader);
    return hash_equals($expected, $received); // constant-time compare
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

boolean verifyWebhook(String rawBody, String signatureHeader, String secret) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    byte[] hash = mac.doFinal(rawBody.getBytes(StandardCharsets.UTF_8));
    String expected = HexFormat.of().formatHex(hash);
    String received = signatureHeader.replace("sha256=", "");
    return MessageDigest.isEqual(expected.getBytes(), received.getBytes()); // constant-time
}

Use X-Dawatrack-Event-Id to deduplicate — the same delivery is retried at 1m, 5m, 30m, 2h, then 12h before being marked permanently FAILED.

5. Idempotency

Any POST that could plausibly be retried accepts an Idempotency-Key header:

Idempotency-Key: your-own-unique-key-per-attempt

A repeated key from the same credential, on a call that already succeeded, returns the original response instead of executing again. Use a fresh key for a genuinely new attempt.

The key is claimed before the handler runs, so two attempts with the same key arriving concurrently are safe too — the second gets 409 Conflict (a request with that key is already in progress) rather than executing twice. Retry after a 409 the same way you'd retry after a timeout.

6. Rate limits

5,000 requests/day per partner by default (separate from the 20/hour limit on the token endpoint itself, deliberately tight since it's a credential-guessing target). Contact us if your integration needs a higher limit.

7. Errors

Errors are {"detail": "..."} with a standard HTTP status code:

StatusMeaning
400Malformed request (bad GTIN, missing field, invalid choice)
401Missing/invalid/expired bearer token
403Valid token, but missing scope, wrong partner type, or not authorized for this resource
404Not found — including resources belonging to a different partner, so existence is never confirmable
409A request with the same Idempotency-Key is already in progress — retry shortly
429Rate limit exceeded