Documentation / Online store
Connect a custom store (API and webhooks)
Developer guide: API keys, the inventory API, idempotent orders and signed webhooks.
Overview
Everything below uses plain HTTPS and JSON. Your server talks to PharmaFlow in three ways:
| Direction | What | Auth |
|---|---|---|
| You → PharmaFlow | GET /api/v1/integrations/inventory | API key |
| You → PharmaFlow | POST /api/v1/integrations/orders, POST …/orders/{id}/cancel | API key |
| PharmaFlow → You | POST to your webhook URL | HMAC signature |
1. Create an API key
Open Store Sync → API keys, type a name and press Create key. The full key (starts with pf_live_) is shown once; PharmaFlow stores only a fingerprint. Revoke a key at any time. Send it on every request:
Authorization: Bearer pf_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx2. Read inventory
GET /api/v1/integrations/inventory?limit=100&cursor=<next_cursor>&updated_since=2026-10-24T00:00:00ZReturns only products offered online. Page through with next_cursor until it is null. updated_since returns products changed since a time; because reservations and expiry can change availability without editing a product, also run a full pass now and then.
{
"api_version": "2026-10-01",
"generated_at": "2026-10-24T10:15:00.000Z",
"items": [
{
"id": "6b0f…",
"sku": "590000100000",
"name": "Paracetamol 500 mg",
"generic_name": "Paracetamol",
"category": "Pain Relief",
"price": 3.5,
"available_quantity": 164,
"in_stock": true,
"eligible": true,
"updated_at": "2026-10-24T09:58:12.000Z"
}
],
"next_cursor": null
}3. Place and cancel orders
Always send an Idempotency-Key (8–128 characters, unique per order attempt). If a request times out, repeat it with the same key: you get the original order back (200 with idempotent_replay: true) and nothing is reserved twice. Prices you send are ignored; PharmaFlow prices every line.
POST /api/v1/integrations/orders
Authorization: Bearer pf_live_…
Idempotency-Key: 7f3c1d52-6a0e-4a37-9d3e-2c1c2ab6f001
Content-Type: application/json
{
"external_order_id": "shop-10234",
"items": [
{ "sku": "590000100000", "quantity": 2 },
{ "medicine_id": "6b0f…", "quantity": 1 }
],
"customer": { "name": "Ivy Chen", "phone": "+1 555 0210", "email": "ivy@example.com" },
"delivery": { "method": "delivery", "address": { "line1": "1 Test St", "city": "Springfield" } },
"notes": "Leave with the neighbour"
}201 Created — stock is reserved for 48 hours or until the pharmacy completes or you cancel the order:
{
"id": "…", "order_no": "EXT-K3M9P2QA", "external_order_id": "shop-10234", "status": "PENDING",
"items": [{ "medicine_id": "6b0f…", "name": "Paracetamol 500 mg", "quantity": 2, "unit_price": 3.5, "total_price": 7 }],
"subtotal": 7, "total_amount": 7, "reserved_until": "2026-10-26T10:15:00.000Z", "idempotent_replay": false
}409 Conflict — nothing is reserved if any line fails; the reasons are per line:
{ "error": { "code": "order_rejected", "message": "One or more items cannot be reserved. Nothing was reserved.",
"details": { "errors": [
{ "medicine_id": "6b0f…", "code": "insufficient_stock", "requested": 50, "available": 12 },
{ "medicine_id": "91ac…", "code": "not_eligible" }
] } } }Cancel with POST /api/v1/integrations/orders/{external_order_id}/cancel (optional body {"reason": "…"}). It releases the stock and is safe to repeat. Once the pharmacy has completed an order it can no longer be cancelled through the API (409 order_not_cancellable).
4. Receive stock updates
In Store Sync → Connect your own store, enter a public https URL and press Connect store. Copy the signing secret (whsec_…); it is shown once. PharmaFlow now POSTs to that URL after stock changes:
POST https://your-store.example.com/pharmaflow/webhook
Content-Type: application/json
X-PharmaFlow-Event-Id: evt_5d0c…
X-PharmaFlow-Event-Type: inventory.updated
X-PharmaFlow-Delivery-Attempt: 1
X-PharmaFlow-Signature: t=1792836900,v1=9f2c…
{
"id": "evt_5d0c…", "type": "inventory.updated", "api_version": "2026-10-01",
"created_at": "2026-10-24T10:15:00.000Z", "origin": "pos.stock_change",
"data": { "items": [ { "id": "6b0f…", "sku": "590000100000", "name": "Paracetamol 500 mg", "price": 3.5,
"available_quantity": 162, "in_stock": true, "eligible": true, "updated_at": "…" } ] }
}Other event types: order.updated (data.external_order_id, data.status) and ping (the test button). An item with eligible: false should be hidden in your store.
Verifying signatures
Compute HMAC-SHA256 of {t}.{raw body} with your signing secret and compare it, in constant time, with v1. Reject requests whose t is more than 5 minutes old. Use the raw body, not a re-serialised one.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, now = Date.now()) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(now / 1000 - t) > 300) return false; // too old (replay)
const expected = createHmac("sha256", secret).update(t + "." + rawBody).digest();
const given = Buffer.from(parts.v1 || "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}Answer with any 2xx as soon as the signature is valid; do the slow work afterwards. De-duplicate on X-PharmaFlow-Event-Id.
Retries and failures
- Timeout: 8 seconds. Redirects are not followed; use the final URL.
408, 425, 429, 5xxand network errors are retried after 1 min, 5 min, 15 min, 1 h and 3 h (aRetry-Afterheader is honoured, up to 1 h). After six attempts the event is marked Failed.- Other
4xxanswers (for example401because your signature check is wrong) fail immediately, because retrying will not help. - Fix the problem, then press Retry failed updates. Every event keeps its id across attempts.
- The webhook URL must be a public
httpsaddress on port 443. Private, loopback and cloud-metadata addresses are refused.
Error reference
| HTTP | error.code | Meaning / what to do |
|---|---|---|
| 401 | invalid_api_key | Missing, wrong or revoked key. |
| 403 | insufficient_scope | The key lacks the scope for this call. |
| 404 | order_not_found | No order with that external_order_id. |
| 409 | order_rejected | See details.errors: insufficient_stock, not_eligible, prescription_required, not_found. |
| 409 | order_not_cancellable | The pharmacy already completed the order. |
| 422 | idempotency_key_required / idempotency_key_reuse / invalid_request | Fix the request; a key cannot be reused for a different order. |
| 429 | rate_limited | Slow down; wait the number of seconds in Retry-After. The limit is 120 requests per minute per key. |