API v1 · 1.1.1
MediSend API
File requests, handover preparation and signed events for your system.
Start in an isolated sandbox
Create a sandbox credential in the console. It is valid for 30 days and works only in /api/sandbox/v1; ms_live_ credentials are rejected there, while ms_test_ credentials are rejected by the live API. Synthetic organizations, packages and request records are isolated in separate tables. They provide no access to live files, email/SMS, recipients or billing.
Open sandboxGET /capabilities reports the environment, scopes and limit. GET /handovers accepts page and per_page (1–100). POST /handovers accepts only scenario: delivered, notification_failed, scanner_quarantined or awaiting_recipient. These are controlled outcomes, rather than actual provider delivery or file scanning. Do not send medical information. simulate_error: provider_unavailable returns HTTP 503 without creating a package.
// Node.js / TypeScript — keep the credential in a secret store.
const origin = process.env.MEDISEND_API_ORIGIN!;
const token = process.env.MEDISEND_SANDBOX_TOKEN!;
if (!token.startsWith('ms_test_')) throw Error('Sandbox credential required');
const key = crypto.randomUUID(); // Retain this key when retrying this operation.
const response = await fetch(origin + '/api/sandbox/v1/handovers', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json',
'Idempotency-Key': key, Accept: 'application/json' },
body: JSON.stringify({ scenario: 'delivered' }),
});
const result = await response.json();
if (!response.ok) throw Error(result.error?.code ?? 'request_failed');
console.log(result.data.id, result.data.synthetic); // Synthetic ID, never patient data.
// Same key + same scenario => HTTP 200 and replayed:true; changed scenario => 409.<?php
// PHP 8.3+ — credentials are read from the environment, never committed.
$origin = rtrim(getenv('MEDISEND_API_ORIGIN'), '/');
$token = getenv('MEDISEND_SANDBOX_TOKEN');
if (!str_starts_with($token, 'ms_test_')) throw new RuntimeException('Sandbox key required');
$key = bin2hex(random_bytes(16)); // Save this for retries of this operation.
$curl = curl_init($origin.'/api/sandbox/v1/handovers');
curl_setopt_array($curl, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.$token, 'Content-Type: application/json',
'Accept: application/json', 'Idempotency-Key: '.$key],
CURLOPT_POSTFIELDS => json_encode(['scenario' => 'scanner_quarantined'], JSON_THROW_ON_ERROR)]);
$body = curl_exec($curl);
if ($body === false) throw new RuntimeException('Transport failed; retain the same key');
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$result = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
if ($status >= 400) throw new RuntimeException($result['error']['code'] ?? 'request_failed');
echo $result['data']['id'].PHP_EOL;Limit: 60 requests/minute per sandbox credential. Responses include X-MediSend-Environment, X-Request-ID, X-RateLimit-Limit and X-RateLimit-Remaining; respect Retry-After on 429. Only handovers:read and handovers:write are accepted. Idempotency-Key is 8–200 characters and scoped to the sandbox credential. The live API has its separate 24-hour idempotency rules below.
For a signed event, add an owned public HTTPS endpoint subscribed to webhook.test and send the selected synthetic scenario from the sandbox. The actual queue and HMAC signature are used. Diagnostics shows the HTTP status and each attempt without retaining response bodies. To test retries, return 503 temporarily, then 204 from your test receiver. Use a dedicated receiver for simulation.
Sandbox v1 OpenAPI ↓First connection
Create one key per system with integration:read. Copy it once into your secret store.
Create your first API key# Set the API origin for your environment.
export API_BASE_URL="https://api.medisend.bg"
# Read the credential without saving it in shell history.
read -r -s MEDISEND_API_TOKEN
export MEDISEND_API_TOKEN
export IDEMPOTENCY_KEY="$(uuidgen)"API_BASE_URL is your environment’s API origin; locally use your backend origin. Paste the credential at read and press Enter. Retain the UUID for retries of the same request. First check your organization’s capabilities:
curl --fail-with-body "$API_BASE_URL/api/v1/capabilities" \
-H "Authorization: Bearer $MEDISEND_API_TOKEN" -H 'Accept: application/json'Create a file request
POST /api/v1/upload-requests creates an active request in the credential organization. title, name and email are required. Default expiry is seven days. require_access_pin enables a separate PIN. The 201 response contains data.id, data.reference, data.status, public_url, token, access_code and access_pin. Deliver access through an approved secure channel; the API does not automatically email it. This example uses synthetic data.
To create and check the request, include upload_requests:write and upload_requests:read in the credential.
curl --fail-with-body "$API_BASE_URL/api/v1/upload-requests" \
-H "Authorization: Bearer $MEDISEND_API_TOKEN" \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
--data '{"title":"Partner files","name":"Partner clinic","email":"[email protected]","requested_files_description":"PDF or imaging files","require_access_pin":true}'{"data":{"id":"<request-id>","reference":"UR-…","status":"active","created_at":"…","expires_at":"…"},"public_url":"<secure-url>","token":"<shown-once>","access_code":"<shown-once>","access_pin":"<shown-once>"}Webhooks
In Settings register a public HTTPS endpoint and select events. Copy the signing secret once. request.created fires from an actually created request. Other supported events appear below. The body contains id (event), type, created_at and data.object with id/type and optional status. It contains no medical files, patient identifiers or access elements. Persist id atomically for duplicate protection; return 2xx after durable acceptance.
Signature verification
Read MediSend-Delivery and MediSend-Signature: t=<Unix seconds>,v1=<hex HMAC>. The signature is HMAC-SHA256 over exact timestamp.deliveryId.rawBody bytes. Use constant-time comparison and reject timestamps outside five minutes. Do not reserialize JSON. Rotation signs with the new secret in v1 and old secret in v0 for 24 hours. Preserve raw body before JSON middleware.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody is the untouched HTTP request body. Read headers case-insensitively.
const parts = Object.fromEntries(signatureHeader.split(',').map(p => p.split('=')));
if (!/^\d+$/.test(parts.t) || Math.abs(Date.now()/1000 - Number(parts.t)) > 300) throw Error('timestamp');
if (!/^[a-f0-9]{64}$/.test(parts.v1)) throw Error('signature');
const expected = createHmac('sha256', signingSecret)
.update(parts.t + '.' + deliveryId + '.').update(rawBody).digest();
if (!timingSafeEqual(expected, Buffer.from(parts.v1, 'hex'))) throw Error('signature');
// Atomically persist payload.id in your event store before processing.
// For a duplicate event return 2xx without applying the operation again.Delivery and retries
Requests and deliveries shows durable records, HTTP result, attempts and next retry time. Queued is not delivered. Five attempts use delays of 60, 300, 1800 and 7200 seconds. Ten consecutive failures disable the endpoint. Fix the receiver, reactivate it, then retry exhausted deliveries (up to three manual batches with recent authentication). Event and delivery IDs remain unchanged; receivers must handle duplicates safely.
Inspect deliveriesCreate a handover
POST /api/v1/handovers creates a draft only. title and package_type are required. The 201 response is {data:{id,status:"draft",created_at,sent_at:null,expires_at}}. Upload files[] with multipart POST /handovers/{id}/files and files:write. Add a recipient with POST /handovers/{id}/recipients, recipients:write and name/email or network_organization_id. Files use existing scan gates. Complete review and sending in MediSend; there is no API to bypass it. Uploads to sent, expired or revoked handovers are rejected.
The clinic’s plan or explicitly started trial must allow creating handovers. An API key does not activate a subscription or start a trial.
Check the clinic plancurl --fail-with-body "$API_BASE_URL/api/v1/handovers" \
-H "Authorization: Bearer $MEDISEND_API_TOKEN" \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
--data '{"title":"Partner handover","package_type":"general_medical_file_transfer"}'Status and history
GET /api/v1/upload-requests/{id} returns {data:{id,reference,status,created_at,expires_at}}. GET /api/v1/handovers/{id} returns status and files with id, sha256_hash, size_bytes, scan_status and uploaded_at for API-uploaded files only. It excludes filenames, clinical metadata, clinic attachments, recipient responses and access elements. Lists /handovers and /upload-requests accept page and per_page (1–100, default 25), returning data and meta:{current_page,last_page,per_page,total}. They include only integration-created operations in the organization. A foreign or unrelated record returns 404.
export REQUEST_ID="<data.id from the create response>"
curl --fail-with-body "$API_BASE_URL/api/v1/upload-requests/$REQUEST_ID" \
-H "Authorization: Bearer $MEDISEND_API_TOKEN" -H 'Accept: application/json'Authentication
Every /api/v1 route requires Authorization: Bearer and Accept: application/json. Keys belong to one organization, are stored only as SHA-256 hashes and have selected scopes. Expiry and last use appear in Settings; last use is updated at most once per five minutes. Revocation rejects the next request with 401.
Idempotency
Send an 8–128 URL-safe Idempotency-Key when creating a draft, recipient or request. It is scoped to an operation and credential for 24 hours. Identical input returns the original response and Idempotent-Replayed: true; changed input returns 409. Access elements in the original response are encrypted for safe replay. File upload is not idempotent: after an uncertain result read GET /api/v1/handovers/{id} with handovers:read and compare files by SHA-256 and size before retrying. Scan status is separate from file acceptance.
Errors and limits
Every response has X-Request-ID. Errors contain error:{code,message,request_id,fields}. Codes: 401 invalid_credential, 403 scope_denied, 404 not_found, 409 conflict, 422 invalid_request, 429 rate_limited; other errors use request_failed. Retain the reference for support. Limit: 120 requests/minute per credential. Respect Retry-After on 429 and bound concurrency. Diagnostics stores route, method, result, timing and credential identity; no bodies or headers.
Security
Use minimum scopes and one credential per system. Never put secrets in URLs, browser code, logs or support messages. The API offers no global patient or package search. Access remains package-specific, auditable and can be revoked. Webhooks grant no file access. Private addresses and redirects are blocked. After compromise create a new key, replace it in the system and revoke the old one.
API endpoints
Available routes from the published OpenAPI contract.
GET/api/v1/handovers
Paginated integration-created handover statuses
Required scope: handovers:read
HTTP responses: 200 · 401 · 403 · 404 · 422 · 429
Example and behavior →POST/api/v1/handovers
Create a draft handover
Required scope: handovers:write
HTTP responses: 201 · 401 · 403 · 409 · 422 · 429
Example and behavior →GET/api/v1/handovers/{packageId}
Read an organization handover
Required scope: handovers:read
HTTP responses: 200 · 401 · 403 · 404 · 429
Example and behavior →POST/api/v1/handovers/{packageId}/files
Upload files to a draft handover
Required scope: files:write
HTTP responses: 201 · 401 · 403 · 404 · 409 · 422 · 429 · 503
Example and behavior →POST/api/v1/handovers/{packageId}/recipients
Add a verified-network or manual recipient
Required scope: recipients:write
HTTP responses: 201 · 401 · 403 · 404 · 409 · 422 · 429
Example and behavior →POST/api/v1/upload-requests
Create a reverse upload request
Required scope: upload_requests:write
HTTP responses: 201 · 401 · 403 · 409 · 422 · 429
Example and behavior →GET/api/v1/upload-requests
Paginated integration-created request statuses
Required scope: upload_requests:read
HTTP responses: 200 · 401 · 403 · 404 · 422 · 429
Example and behavior →GET/api/v1/capabilities
Organization integration capabilities
Required scope: integration:read
HTTP responses: 200 · 401 · 403 · 404 · 422 · 429
Example and behavior →GET/api/v1/upload-requests/{requestId}
Read integration-created file request status
Required scope: upload_requests:read
HTTP responses: 200 · 401 · 403 · 404 · 422 · 429
Example and behavior →Scopes
- integration:read
- Organization capabilities
- handovers:read
- API handover status and history
- handovers:write
- Create drafts
- files:write
- Upload to active drafts
- recipients:write
- Add recipients
- upload_requests:write
- Create requests
- upload_requests:read
- API request status and history
Events
- request.created
- Request created
- package.created
- Draft created
- package.sent
- Handover sent
- package.opened
- Handover opened
- package.acknowledged
- Receipt acknowledged
- file.uploaded
- File uploaded (does not prove a clean scan)
- upload_request.submitted
- Requested files submitted
- verification.updated
- Verification changed
- webhook.test
- Explicit test event
Troubleshooting
Keep X-Request-ID from the response. Choose the problem to find the safe next step.
401Credential rejected
Check the environment, expiry and revocation. If the key expired or was revoked, create a replacement and update your system’s secret store.
Read the detailed steps →403Required scope missing
Check the endpoint’s required scope in the reference. Create a replacement with the scopes needed and revoke the old key after verifying the replacement.
Read the detailed steps →409Retry conflicts
Compare the operation and input with the first attempt. Retry identical input with the same Idempotency-Key. A new key is for a new operation; do not change it to bypass an uncertain result.
Read the detailed steps →422Request not accepted
Correct error.fields. If no fields are listed, check the clinic plan and operation limits in MediSend. Repeating unchanged input will not address the cause.
Read the detailed steps →429Rate limit reached
Wait for Retry-After and reduce concurrent requests. Keep the same idempotency key when retrying the same operation.
Read the detailed steps →TimeoutOutcome unknown
For creation, retry the same operation with the same key within its lifetime. For an upload, first compare the recorded SHA-256 and size using the handover GET endpoint; uploads are not idempotent.
Read the detailed steps →WebhookEvent not received
Inspect the delivery, HTTP result and next attempt. Fix the receiver before a manual retry. HTTP acceptance does not establish completed processing in your system.
Read the detailed steps →