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:
| Kind | Use it when | Possible results |
|---|---|---|
consult | The customer has no prescription | Prescription issued, declined, no reply |
validation | The customer uploaded a prescription for you to use | Validated, 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
503carriesRetry-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 key | Live key | |
|---|---|---|
| Reaches a doctor | No | Yes |
| Customer is phoned | No | Yes |
| Result | You choose it, with simulate | The doctor decides |
| Prescription PDF | None | Yes |
| Prescription number | TEST-… | PRX-YYMMDD-NNNNNN |
| Billed | No | Yes, per finished case |
| Webhooks | Sent, 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
403forbidden. - A suspended account can still read cases and download issued prescription PDFs. Any write (create, cancel, simulate) gets
403forbidden. - A key that is missing, wrong or revoked gets
401unauthorized.
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"
}| Field | Required | Notes |
|---|---|---|
external_ref | Yes | Your own order or request id. 1–120 characters: letters, digits and . _ : / # - |
kind | Yes | consult or validation |
patient.name | Yes | The person who will take the medicine |
patient.phone | Yes | The number the doctor will call. Indian mobile number, with or without +91 |
patient.age_years, sex | No | sex is male, female or other. The doctor confirms both on the call |
patient.language | No | The language the customer prefers to speak |
patient.reported_allergies | No | Send it if you have it. It is checked against every medicine prescribed |
items | Yes, 1 to 30 | name is required on each. sku is returned to you unchanged on the matching line |
prescription_file | For validation | { "content_type": "application/pdf" | "image/jpeg" | "image/png", "data": "<base64>" }, up to 3 MB |
consent.attested | Yes, must be true | You confirm the customer agreed to a doctor's call and to the result being shared with you |
consent.reference | No | Your own record of that consent |
notes | No | Shown to the doctor |
prefer_video | No | true 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
| Field | Notes |
|---|---|
contact_evidence | How the customer was reached. Set when the case is finished. null if the customer was never reached. See below |
consult_mode | audio or video |
video_verified | true only when our video provider's log shows the doctor and the customer in the room together |
doctor | null while the case is QUEUED or OFFERED |
items | null until the case is PRESCRIPTION_ISSUED, VALIDATED or DECLINED |
prescription | Set only when the status is PRESCRIPTION_ISSUED. A validation has no prescription number and no PDF |
decline_reason | Set 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
| Status | Meaning | Final |
|---|---|---|
QUEUED | Waiting for a doctor | No |
OFFERED | Offered to a doctor, not yet accepted | No |
ASSIGNED | A doctor has the case | No |
CONTACTING_PATIENT | The doctor is calling the customer | No |
IN_CONSULT | The doctor and the customer are talking | No |
PRESCRIPTION_ISSUED | The doctor issued a prescription | Yes |
VALIDATED | The doctor confirmed the prescription you sent | Yes |
DECLINED | The doctor declined. See decline_reason | Yes |
PATIENT_UNREACHABLE | The customer did not answer | Yes |
CANCELLED | You cancelled the case | Yes |
EXPIRED | No doctor took the case in time | Yes |
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.
decision | Meaning |
|---|---|
PRESCRIBED | Prescribed as requested (for a validation: covered by the prescription) |
CHANGED | Prescribed with a change. prescribed has the new details, reason says why. Consults only; a validation never has this |
NOT_PRESCRIBED | Do not dispense. reason says why |
PENDING | Not 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) orNOT_PRESCRIBEDwith a reason. A line is neverCHANGED. 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,
prescribedholds 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
prescriptionobject, no prescription number and no PDF. The result is the statusVALIDATEDand 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.
| Class | Audio consultation | Verified video consultation |
|---|---|---|
| List O | Yes | Yes |
| List A | No | Yes |
| List B | No | No |
| Prohibited | No | No |
- 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:
- 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. - You receive a
case.video_linkwebhook whosedatais the case object plusvideo_link_sent(whether the SMS went out) andvideo_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. - The case object reports
consult_mode: "video".video_verifiedbecomestrueonly 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, andcontact_evidenceisvideo_verified.
The link is a credential for one customer's call. Do not log it or share it with anyone else.
Other endpoints
| Request | What it does |
|---|---|
GET /cases/{id} | One case |
GET /cases?external_ref=ORD-100234 | Find 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}/cancel | Cancel a case. Body: { "reason": "…" } (optional). See Cancelling |
GET /cases/{id}/prescription.pdf | The signed prescription, as a PDF. Live cases with PRESCRIPTION_ISSUED only. Allowed on a suspended account |
POST /cases/{id}/simulate | Test 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_CONSULTor already has a final status other thanCANCELLED; 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.
| Result | Billed |
|---|---|
PRESCRIPTION_ISSUED | Yes, at the consult price |
VALIDATED | Yes, at the validation price |
DECLINED | Yes if the doctor reached the customer, or if the case was a validation. Otherwise no |
PATIENT_UNREACHABLE | Only if your account is set to bill unreachable cases. It is not by default |
CANCELLED, EXPIRED | No |
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:
| Header | Value |
|---|---|
webhook-id | The delivery's id. It is the same on every retry |
webhook-timestamp | Seconds since 1970 |
webhook-signature | v1,<base64 signature> |
webhook-environment | test or live |
To verify by hand:
- Build the string
<webhook-id>.<webhook-timestamp>.<raw request body>. - Base64-decode the part of your signing secret after
whsec_to get the key. - Compute HMAC-SHA256 of the string with that key, and base64-encode it.
- Compare with the value after
v1,using a constant-time comparison. - 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
2xxstatus within 10 seconds. Do slow work after you have answered. - Accept the same event more than once. Use
webhook-idto ignore one you already handled. - Not rely on order. Use the case's
statusanddecided_at, not the order of arrival. - Not redirect. A
3xxanswer 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" }]
}
}| Status | code | What to do |
|---|---|---|
| 400 | invalid_json | The body is missing or is not valid JSON |
| 401 | unauthorized | The key is missing, wrong or revoked |
| 403 | forbidden | Not allowed with this key: a live key on a draft account, a write on a suspended account, or simulate with a live key |
| 404 | not_found | No such case on your account |
| 409 | conflict | The case is in a state that does not allow this |
| 409 | idempotency_mismatch | This external_ref exists with a different body. Use a new external_ref for a new request |
| 409 | integrity_error | A prescription PDF could not be produced. Contact us |
| 413 | too_large, file_too_large | Send a smaller request. A prescription file is 3 MB at most |
| 422 | validation_failed | Fix the fields named in details |
| 422 | invalid_phone, invalid_file | The phone number or the file could not be used |
| 429 | rate_limited | Wait for the number of seconds in the Retry-After header |
| 429 | daily_cap_reached | Your account's daily case limit is reached. Try again tomorrow |
| 500 | internal_error | Retry with the same external_ref |
| 503 | unavailable | Retry 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
- Create a test case and receive its webhooks.
- Use
simulateto produce each of the three results, and handle each. - Verify the webhook signature, and reject a request that fails it.
- Send the same
external_reftwice and confirm you get one case, and that a changed body gets409. - Handle a
CHANGEDline and aNOT_PRESCRIBEDline in one prescription. For avalidation, handleNOT_PRESCRIBEDonly. - Handle
PATIENT_UNREACHABLEandEXPIRED— tell your customer what happened. - 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.