Skip to main content
PingMeDoc

Prescription API documentation

For the engineering team of a business that sends prescription requests to PingMeDoc™. Want to try it first? Get a free test key.

This guide is for the engineering team of a business that sends prescription requests to PingMeDoc. It covers everything needed to go from a test key to live traffic.

What the service does

You send us a customer who wants medicines that need a prescription. A registered doctor on our panel phones the customer, consults, and decides. You get the decision back by webhook, usually within a few minutes.

There are two kinds of request:

KindUse it whenPossible results
consultThe customer has no prescriptionPrescription issued, declined, no reply
validationThe customer uploaded a prescription for you to useValidated, declined, no reply

The doctor decides independently. A request is not a guarantee of a prescription: the doctor may change a medicine, remove one, or decline. Some medicines cannot be prescribed on a first consultation, or on a phone call. Those lines are not prescribed. See Which medicines can be prescribed.

A doctor cannot sign until their registration number and registering council are on file, and for an issued prescription their signature too. Every prescription we issue carries them.

Before you start

We give you two API keys: a test key (pmdrx_test_…) and a live key (pmdrx_live_…). A key is shown once when it is created. Keep it in your server's secret store. Never put it in an app or a web page.

  • Base URL: https://www.pingmedoc.com/api/partner/v1
  • Send the key on every request: Authorization: Bearer <key>
  • Send and receive JSON (Content-Type: application/json)
  • Call from your server only. The API does not allow browser calls.
  • Every 503 carries Retry-After: 5.

Check your key works:

curl https://www.pingmedoc.com/api/partner/v1/ping \
  -H "Authorization: Bearer $PMD_KEY"

Test and live

The key decides which one you are using. There is no separate URL.

Test keyLive key
Reaches a doctorNoYes
Customer is phonedNoYes
ResultYou choose it, with simulateThe doctor decides
Prescription PDFNoneYes
Prescription numberTEST-…PRX-YYMMDD-NNNNNN
BilledNoYes, per finished case
WebhooksSent, with "environment":"test"Sent, "environment":"live"

A test case that is never simulated expires like a live one, after the same time limit.

Account states

  • A draft account can use test keys only. A live key on a draft account gets 403 forbidden.
  • A suspended account can still read cases and download issued prescription PDFs. Any write (create, cancel, simulate) gets 403 forbidden.
  • A key that is missing, wrong or revoked gets 401 unauthorized.

Create a case

POST /cases

{
  "external_ref": "ORD-100234",
  "kind": "consult",
  "patient": {
    "name": "Asha Raman",
    "phone": "+919876543210",
    "age_years": 34,
    "sex": "female",
    "language": "Tamil",
    "address": "12 Lake View Road, Chennai 600034",
    "reported_allergies": "Penicillin",
    "reported_conditions": "Diabetes"
  },
  "items": [
    {
      "name": "Pantoprazole",
      "strength": "40 mg",
      "form": "tablet",
      "quantity": 1,
      "sku": "SKU-8812"
    }
  ],
  "consent": { "attested": true, "reference": "consent-log-77812" },
  "notes": "Customer prefers a call after 6 pm"
}
FieldRequiredNotes
external_refYesYour own order or request id. 1–120 characters: letters, digits and . _ : / # -
kindYesconsult or validation
patient.nameYesThe person who will take the medicine
patient.phoneYesThe number the doctor will call. Indian mobile number, with or without +91
patient.age_years, sexNosex is male, female or other. The doctor confirms both on the call
patient.languageNoThe language the customer prefers to speak
patient.reported_allergiesNoSend it if you have it. It is checked against every medicine prescribed
itemsYes, 1 to 30name is required on each. sku is returned to you unchanged on the matching line
prescription_fileFor validation{ "content_type": "application/pdf" | "image/jpeg" | "image/png", "data": "<base64>" }, up to 3 MB
consent.attestedYes, must be trueYou confirm the customer agreed to a doctor's call and to the result being shared with you
consent.referenceNoYour own record of that consent
notesNoShown to the doctor
prefer_videoNotrue asks the doctor to consult by video. See Video consultations

Response: 201 with { "created": true, "case": { … } }.

Sending the same request twice is safe. external_ref identifies the case. Replaying an external_ref with an identical body returns 200 with { "created": false, "case": { … } }, and no second case is made. Replaying it with a different body returns 409 idempotency_mismatch; use a new external_ref for a new request. Retry a timed-out request with the same reference and the same body. Idempotency is per environment: a test case and a live case may share a reference.

The case object

{
  "id": "6f1c2a0e-5d0b-4a55-9d41-0d8d5b0f7c11",
  "external_ref": "ORD-100234",
  "environment": "live",
  "kind": "consult",
  "status": "PRESCRIPTION_ISSUED",
  "created_at": "2026-10-04T10:15:02Z",
  "assigned_at": "2026-10-04T10:15:20Z",
  "connected_at": "2026-10-04T10:16:05Z",
  "consult_mode": "audio",
  "video_verified": false,
  "contact_evidence": "provider_connected",
  "decided_at": "2026-10-04T10:19:41Z",
  "expires_at": "2026-10-04T12:15:02Z",
  "doctor": {
    "name": "Dr. K. Meera",
    "registration_no": "TNMC 98765",
    "registration_council": "Tamil Nadu Medical Council",
    "qualifications": "MBBS, MD"
  },
  "decline_reason": null,
  "prescription": {
    "number": "PRX-261022-000123",
    "content_hash": "9b0c…",
    "valid_days": 30,
    "diagnosis": "Gastro-oesophageal reflux",
    "advice": "Avoid late meals",
    "follow_up": "Review in 2 weeks if no better"
  },
  "items": [
    {
      "line_no": 1,
      "partner_sku": "SKU-8812",
      "requested_name": "Pantoprazole",
      "added_by_doctor": false,
      "decision": "PRESCRIBED",
      "prescribed": {
        "name": "Pantoprazole",
        "generic_name": "Pantoprazole",
        "strength": "40 mg",
        "form": "tablet",
        "route": "oral",
        "dose": "1 tablet",
        "frequency": "Once daily",
        "duration_days": 14,
        "timing": "Before breakfast",
        "quantity": 1,
        "quantity_unit": "pack"
      },
      "reason": null
    }
  ]
}

Fields worth a note

FieldNotes
contact_evidenceHow the customer was reached. Set when the case is finished. null if the customer was never reached. See below
consult_modeaudio or video
video_verifiedtrue only when our video provider's log shows the doctor and the customer in the room together
doctornull while the case is QUEUED or OFFERED
itemsnull until the case is PRESCRIPTION_ISSUED, VALIDATED or DECLINED
prescriptionSet only when the status is PRESCRIPTION_ISSUED. A validation has no prescription number and no PDF
decline_reasonSet only when the status is DECLINED

contact_evidence is one of:

  • provider_connected: our phone bridge connected the call between the doctor and the customer.
  • video_verified: the doctor and the customer were both in the video room, from the video provider's log.
  • doctor_attested: the doctor dialled by hand and recorded that they spoke to the customer. There is no machine record of the call.
  • null: the customer was never reached.

It is in the case object that GET /cases/{id} returns and in every webhook payload. If a machine record exists, it wins over the doctor's word. In test mode a simulated result sets provider_connected, or video_verified when you simulate with consult_mode: "video".

Status

StatusMeaningFinal
QUEUEDWaiting for a doctorNo
OFFEREDOffered to a doctor, not yet acceptedNo
ASSIGNEDA doctor has the caseNo
CONTACTING_PATIENTThe doctor is calling the customerNo
IN_CONSULTThe doctor and the customer are talkingNo
PRESCRIPTION_ISSUEDThe doctor issued a prescriptionYes
VALIDATEDThe doctor confirmed the prescription you sentYes
DECLINEDThe doctor declined. See decline_reasonYes
PATIENT_UNREACHABLEThe customer did not answerYes
CANCELLEDYou cancelled the caseYes
EXPIREDNo doctor took the case in timeYes

A final status never changes.

A case in a doctor's hands is never expired. If the assigned doctor goes quiet for more than 45 minutes with no call in progress, the case returns to the queue and is offered again. case.expired is sent only for cases no doctor took. The time limit for a case to be taken is set on your account (two hours unless we agreed another); a case that is put back in the queue gets at least 30 more minutes.

What to dispense

Dispense only lines whose decision is PRESCRIBED or CHANGED, and dispense what is in prescribed, not what you requested. prescribed is null on any other line.

decisionMeaning
PRESCRIBEDPrescribed as requested (for a validation: covered by the prescription)
CHANGEDPrescribed with a change. prescribed has the new details, reason says why. Consults only; a validation never has this
NOT_PRESCRIBEDDo not dispense. reason says why
PENDINGNot decided yet

A line with "added_by_doctor": true is a medicine the doctor added. It has no partner_sku. Only a consult can have one.

Decline reasons

not_clinically_indicated, needs_in_person, prohibited_remotely, prescription_invalid, identity_not_confirmed, patient_declined, other. decline_reason.text carries the doctor's own words.

Validations

A validation is for a customer who already has a prescription. A doctor checks the attached file. No call to the customer is needed.

  • Each line can only be PRESCRIBED (covered by the attached prescription) or NOT_PRESCRIBED with a reason. A line is never CHANGED. The server and the database both refuse it.
  • A doctor cannot add medicines. If the customer needs something else, send a consult.
  • On a covered line, prescribed holds the medicine name; the strength, dose and the rest are normally empty. They are on the customer's own prescription; dispense against that.
  • A validation has no prescription object, no prescription number and no PDF. The result is the status VALIDATED and the line decisions.
  • A Prohibited medicine is refused even when the attached prescription lists it. That line becomes NOT_PRESCRIBED.
  • If no line is covered, the doctor declines the case instead.

Which medicines can be prescribed

Under the Telemedicine Practice Guidelines 2020, every case you send is a first consultation: the doctor has never seen the customer in person.

ClassAudio consultationVerified video consultation
List OYesYes
List ANoYes
List BNoNo
ProhibitedNoNo
  • List O: over-the-counter medicines, such as paracetamol or cetirizine.
  • List A: needs a verified video consultation first.
  • List B: refused on a first consultation, because there is no earlier in-person consultation. These are mostly medicines for long-term conditions.
  • Prohibited: Schedule X, narcotic and psychotropic medicines, never prescribed remotely.

A medicine we cannot classify is treated as List A, so it needs video.

A line that cannot be prescribed does not decline the whole case. The doctor cannot sign while it is on the prescription, so they mark that line NOT_PRESCRIBED with a reason and can still prescribe the rest. If no line can be prescribed, the doctor declines the case, usually with the reason prohibited_remotely or needs_in_person.

Send "prefer_video": true when the basket may contain List A medicines. Without video, those lines come back NOT_PRESCRIBED.

Video consultations

By default the doctor consults by phone, and only List O medicines can be prescribed. Over a verified video call they may also prescribe List A.

Send "prefer_video": true when your basket holds medicines that are likely to be List A. The doctor sees the request and can start a video call from the case. When they do:

  1. The customer is texted a one-time join link (https://www.pingmedoc.com/rx-call#t=…). It needs no sign-in and works for two hours. A resent link replaces the earlier one.
  2. You receive a case.video_link webhook whose data is the case object plus video_link_sent (whether the SMS went out) and video_join_expires_at. The link itself is never sent to you: whoever opens it counts as the customer for the video check, so it goes to the customer's phone only. Tell your customer to look for our SMS.
  3. The case object reports consult_mode: "video". video_verified becomes true only after our records show the doctor and the customer were in the call together for at least a minute. A prescription for a List A medicine is issued only then, and the prescription PDF reads "Consultation: video (verified)". The doctor and the customer then count as having spoken, and contact_evidence is video_verified.

The link is a credential for one customer's call. Do not log it or share it with anyone else.

Other endpoints

RequestWhat it does
GET /cases/{id}One case
GET /cases?external_ref=ORD-100234Find a case by your reference
GET /cases?status=QUEUED&limit=25&before=<next_before>List cases, newest first, 1 to 100 a page. next_before in the answer is an opaque cursor: pass it back unchanged as before. It is null on the last page. Cases created in the same instant are not skipped
POST /cases/{id}/cancelCancel a case. Body: { "reason": "…" } (optional). See Cancelling
GET /cases/{id}/prescription.pdfThe signed prescription, as a PDF. Live cases with PRESCRIPTION_ISSUED only. Allowed on a suspended account
POST /cases/{id}/simulateTest key only. Body: { "outcome": "PRESCRIPTION_ISSUED" | "DECLINED" | "PATIENT_UNREACHABLE", "consult_mode": "audio" | "video" }. "video" also sends a case.video_link webhook

Cancelling

A case can be cancelled while its status is QUEUED, OFFERED, ASSIGNED or CONTACTING_PATIENT. You get the case back with status CANCELLED, and a case.cancelled webhook is sent. A cancelled case is not billed.

The answer is 409 conflict when:

  • the case is IN_CONSULT or already has a final status other than CANCELLED; or
  • the customer's phone is ringing or the doctor is on the call with the customer. Wait for the call to end. A call that is only ringing the doctor's phone does not stop a cancel.

Cancelling a case that is already CANCELLED returns 200 with the case.

Billing

You are charged per finished live case, at the prices on your account. Test cases are never billed.

ResultBilled
PRESCRIPTION_ISSUEDYes, at the consult price
VALIDATEDYes, at the validation price
DECLINEDYes if the doctor reached the customer, or if the case was a validation. Otherwise no
PATIENT_UNREACHABLEOnly if your account is set to bill unreachable cases. It is not by default
CANCELLED, EXPIREDNo

A declined consult is charged at the consult price; a declined validation at the validation price.

Webhooks

Give us an https:// address on the standard port (443) and we post each change to it. Webhooks are the intended way to learn the result; you do not need to poll.

Events

case.assigned, case.connected, case.video_link, case.prescription_issued, case.validated, case.declined, case.patient_unreachable, case.cancelled, case.expired, and webhook.test.

Test and live webhooks

Events from test keys go to your test webhook URL, signed with a separate test signing secret. If no test URL is set, they go to the live URL only until you have a live key; after that they are not sent. Every delivery carries a webhook-environment: test or live header.

Body

{
  "type": "case.prescription_issued",
  "created_at": "2026-10-04T10:19:41Z",
  "data": { "…": "the case object, exactly as GET /cases/{id} returns it" }
}

Verify the signature

Every webhook is signed following the Standard Webhooks specification, so you can use any Standard Webhooks library. These headers are sent:

HeaderValue
webhook-idThe delivery's id. It is the same on every retry
webhook-timestampSeconds since 1970
webhook-signaturev1,<base64 signature>
webhook-environmenttest or live

To verify by hand:

  1. Build the string <webhook-id>.<webhook-timestamp>.<raw request body>.
  2. Base64-decode the part of your signing secret after whsec_ to get the key.
  3. Compute HMAC-SHA256 of the string with that key, and base64-encode it.
  4. Compare with the value after v1, using a constant-time comparison.
  5. Reject the webhook if the timestamp is more than 5 minutes from your clock.

When the signing secret is rotated, the previous secret keeps signing for 24 hours. During that time webhook-signature carries two space-separated v1,<signature> values. Accept the request if either matches. The sample below does this.

import crypto from "node:crypto";

export function verify(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${ts}.${rawBody}`)
    .digest("base64");
  return String(headers["webhook-signature"])
    .split(" ")
    .some((part) => {
      const sig = Buffer.from(part.replace(/^v1,/, ""));
      const exp = Buffer.from(expected);
      return sig.length === exp.length && crypto.timingSafeEqual(sig, exp);
    });
}

Verify against the raw body, before any JSON parsing.

Your endpoint must

  • Answer with any 2xx status within 10 seconds. Do slow work after you have answered.
  • Accept the same event more than once. Use webhook-id to ignore one you already handled.
  • Not rely on order. Use the case's status and decided_at, not the order of arrival.
  • Not redirect. A 3xx answer counts as a failure. We do not follow redirects.

Where we connect

We connect only to the public IP addresses your host resolves to at send time. A host that resolves to a private, loopback, link-local or carrier-grade NAT address is refused. The address must be https:// on port 443.

Retries

A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After six failed attempts, about 15 hours in all, we stop. Our team can send it again on request, and GET /cases/{id} always has the current state.

Errors

{
  "error": {
    "code": "validation_failed",
    "message": "Some fields are invalid.",
    "details": [{ "field": "patient.phone", "message": "Must be a phone number" }]
  }
}
StatuscodeWhat to do
400invalid_jsonThe body is missing or is not valid JSON
401unauthorizedThe key is missing, wrong or revoked
403forbiddenNot allowed with this key: a live key on a draft account, a write on a suspended account, or simulate with a live key
404not_foundNo such case on your account
409conflictThe case is in a state that does not allow this
409idempotency_mismatchThis external_ref exists with a different body. Use a new external_ref for a new request
409integrity_errorA prescription PDF could not be produced. Contact us
413too_large, file_too_largeSend a smaller request. A prescription file is 3 MB at most
422validation_failedFix the fields named in details
422invalid_phone, invalid_fileThe phone number or the file could not be used
429rate_limitedWait for the number of seconds in the Retry-After header
429daily_cap_reachedYour account's daily case limit is reached. Try again tomorrow
500internal_errorRetry with the same external_ref
503unavailableRetry after the number of seconds in the Retry-After header (always 5)

Limits per key: 120 requests a minute for creating, cancelling and simulating; 600 a minute for reading.

Going live checklist

  1. Create a test case and receive its webhooks.
  2. Use simulate to produce each of the three results, and handle each.
  3. Verify the webhook signature, and reject a request that fails it.
  4. Send the same external_ref twice and confirm you get one case, and that a changed body gets 409.
  5. Handle a CHANGED line and a NOT_PRESCRIBED line in one prescription. For a validation, handle NOT_PRESCRIBED only.
  6. Handle PATIENT_UNREACHABLE and EXPIRED — tell your customer what happened.
  7. Swap in the live key.

Customer data

  • Send only what the doctor needs. Do not send payment details or identity document numbers.
  • The phone number is used to make the consultation call and for nothing else.
  • The customer's name and phone number are never included in a webhook. Match a webhook to your order by external_ref.
  • Anyone holding the printed prescription can confirm it is genuine by scanning the code on it. That page shows the prescription number, the patient's name masked (first name and last initial), the doctor and the registering council, the issue date, how long it is valid, the consultation mode, and the medicines as signed. It never shows the diagnosis or the phone number. On every view it re-checks the stored content against the prescription's content_hash. If they differ it says "Cannot be verified" and the pharmacist must not dispense.