Skip to main content

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:

DirectionWhatAuth
You → PharmaFlowGET /api/v1/integrations/inventoryAPI key
You → PharmaFlowPOST /api/v1/integrations/orders, POST …/orders/{id}/cancelAPI key
PharmaFlow → YouPOST to your webhook URLHMAC signature
Keep keys on your serverAn API key can place orders. Never put it in browser or mobile code.

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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

2. Read inventory

GET /api/v1/integrations/inventory?limit=100&cursor=<next_cursor>&updated_since=2026-10-24T00:00:00Z

Returns 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, 5xx and network errors are retried after 1 min, 5 min, 15 min, 1 h and 3 h (a Retry-After header is honoured, up to 1 h). After six attempts the event is marked Failed.
  • Other 4xx answers (for example 401 because 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 https address on port 443. Private, loopback and cloud-metadata addresses are refused.

Error reference

HTTPerror.codeMeaning / what to do
401invalid_api_keyMissing, wrong or revoked key.
403insufficient_scopeThe key lacks the scope for this call.
404order_not_foundNo order with that external_order_id.
409order_rejectedSee details.errors: insufficient_stock, not_eligible, prescription_required, not_found.
409order_not_cancellableThe pharmacy already completed the order.
422idempotency_key_required / idempotency_key_reuse / invalid_requestFix the request; a key cannot be reused for a different order.
429rate_limitedSlow down; wait the number of seconds in Retry-After. The limit is 120 requests per minute per key.