Callbacks

When operations complete (send fax, receive fax, direct message, email, AI document), Medsender POSTs to your callback_url. Use this to update your system.

Headers

  • X-Medsender-Signature: HMAC-SHA256 signature for verifying authenticity

Verifying the Signature

Callbacks include an X-Medsender-Signature header so you can confirm the request came from Medsender before trusting the payload. The header is a lowercase hex HMAC-SHA256 digest, keyed with your API key, over the callback's token and timestamp concatenated together. Verification is currently supported for the three callback types in the table below; other callback types carry the header but cannot yet be verified from their payloads.

  1. Parse the JSON from the form field for this callback type (see the table below).
  2. Concatenate the token and timestamp exactly as they appear in the payload JSON — do not re-parse or reformat the timestamp.
  3. Compute a hex-encoded HMAC-SHA256 of that string, keyed with your API key.
  4. Compare the result with the X-Medsender-Signature header using a constant-time comparison.
Callback typeForm fieldTokenTimestampSigning key
Sent FaxrecordDetailssendTokencompletedAtTest key when isTest is true, otherwise live key
Received FaxrecordDetailssendTokencompletedAtTest key when isTest is true, otherwise live key
AI DocumentAiDocumentDetailsaiResult.aiTokenaiResult.completedAtAlways the live key
  • Use the exact token and timestamp strings from the payload. Parsing the timestamp into a date and reformatting it will change the digest.
  • If completedAt is JSON null, concatenate the literal string null (for example, sendToken + null).
  • The header value is a bare hex digest — there is no sha256= prefix.
  • Reject mismatches with a non-2xx response, such as 401 — see the Retry section below for redelivery behavior.
  • Signatures are computed with the API key of the developer account linked to your organization. Example values shown in the code below are placeholders — use your own key from the dashboard.
Node
import crypto from 'crypto';
import express from 'express';
import multer from 'multer'; // received-fax callbacks arrive as multipart/form-data (PDF file included)
const app = express();
const upload = multer();
app.use(express.urlencoded({ extended: true })); // sent-fax and AI callbacks are form-urlencoded

const TEST_API_KEY = process.env.MEDSENDER_TEST_API_KEY;
const LIVE_API_KEY = process.env.MEDSENDER_LIVE_API_KEY;

function verifySignature(token, timestamp, header, apiKey) {
  // Use the exact strings from the payload; a null timestamp signs the literal string "null"
  const base = token + (timestamp === null ? 'null' : timestamp);
  const expected = crypto.createHmac('sha256', apiKey).update(base).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(header || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Fax callbacks: signature covers sendToken + completedAt
// (multer parses the multipart received-fax body; express.urlencoded parses sent-fax)
app.post('/webhook/fax', upload.single('file'), (req, res) => {
  const data = JSON.parse(req.body.recordDetails);
  const apiKey = data.isTest ? TEST_API_KEY : LIVE_API_KEY; // fax signatures are keyed by isTest
  if (!verifySignature(data.sendToken, data.completedAt, req.get('X-Medsender-Signature'), apiKey)) {
    return res.status(401).send('invalid signature'); // fax callbacks are retried
  }
  res.status(200).send('OK');
});

// AI document callbacks: signature covers aiResult.aiToken + aiResult.completedAt (always the live key)
app.post('/webhook/ai', (req, res) => {
  const { aiResult } = JSON.parse(req.body.AiDocumentDetails);
  if (!verifySignature(aiResult.aiToken, aiResult.completedAt, req.get('X-Medsender-Signature'), LIVE_API_KEY)) {
    return res.status(401).send('invalid signature'); // AI callbacks are NOT redelivered
  }
  res.status(200).send('OK');
});
import crypto from 'crypto';
import express from 'express';
import multer from 'multer'; // received-fax callbacks arrive as multipart/form-data (PDF file included)
const app = express();
const upload = multer();
app.use(express.urlencoded({ extended: true })); // sent-fax and AI callbacks are form-urlencoded

const TEST_API_KEY = process.env.MEDSENDER_TEST_API_KEY;
const LIVE_API_KEY = process.env.MEDSENDER_LIVE_API_KEY;

function verifySignature(token, timestamp, header, apiKey) {
  // Use the exact strings from the payload; a null timestamp signs the literal string "null"
  const base = token + (timestamp === null ? 'null' : timestamp);
  const expected = crypto.createHmac('sha256', apiKey).update(base).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(header || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Fax callbacks: signature covers sendToken + completedAt
// (multer parses the multipart received-fax body; express.urlencoded parses sent-fax)
app.post('/webhook/fax', upload.single('file'), (req, res) => {
  const data = JSON.parse(req.body.recordDetails);
  const apiKey = data.isTest ? TEST_API_KEY : LIVE_API_KEY; // fax signatures are keyed by isTest
  if (!verifySignature(data.sendToken, data.completedAt, req.get('X-Medsender-Signature'), apiKey)) {
    return res.status(401).send('invalid signature'); // fax callbacks are retried
  }
  res.status(200).send('OK');
});

// AI document callbacks: signature covers aiResult.aiToken + aiResult.completedAt (always the live key)
app.post('/webhook/ai', (req, res) => {
  const { aiResult } = JSON.parse(req.body.AiDocumentDetails);
  if (!verifySignature(aiResult.aiToken, aiResult.completedAt, req.get('X-Medsender-Signature'), LIVE_API_KEY)) {
    return res.status(401).send('invalid signature'); // AI callbacks are NOT redelivered
  }
  res.status(200).send('OK');
});

Payload Format

Callbacks are sent as form data with a JSON string in a named field. Parse the form field, then parse the JSON inside.

  • Sent Fax: recordDetails field with JSON string (keys in camelCase)
  • Received Fax: recordDetails field with JSON string + file field with PDF attachment (keys in camelCase)
  • AI Document: AiDocumentDetails field with JSON string (nested keys in camelCase)

Retry

If your endpoint returns a non-2xx response, fax callbacks are retried at intervals over the following hours (a 405 response is treated as permanent and is not retried). AI Document callbacks are not retried after your endpoint returns a response — but if no response is received (timeout or connection failure), delivery may be reattempted, so handle AI callbacks idempotently using aiToken.

Webhook Handler Example

Parse the form-urlencoded body and extract the JSON from the appropriate field.

Node
import express from 'express';
import multer from 'multer'; // For handling multipart/form-data (received fax)
const app = express();
const upload = multer({ dest: 'uploads/' });
app.use(express.urlencoded({ extended: true }));

// Sent fax callback (form-urlencoded)
app.post('/webhook/sent-fax', (req, res) => {
  const data = JSON.parse(req.body.recordDetails);
  console.log('Fax status:', data.sentStatus);
  console.log('Send token:', data.sendToken);
  res.status(200).send('OK');
});

// Received fax callback (multipart with PDF file)
app.post('/webhook/received-fax', upload.single('file'), (req, res) => {
  const data = JSON.parse(req.body.recordDetails);
  console.log('From:', data.fromNumber);
  console.log('PDF saved to:', req.file?.path);
  res.status(200).send('OK');
});

// AI document callback (form-urlencoded)
app.post('/webhook/ai', (req, res) => {
  const data = JSON.parse(req.body.AiDocumentDetails);
  console.log('Classification:', data.aiResult.documentClassification);
  res.status(200).send('OK');
});
import express from 'express';
import multer from 'multer'; // For handling multipart/form-data (received fax)
const app = express();
const upload = multer({ dest: 'uploads/' });
app.use(express.urlencoded({ extended: true }));

// Sent fax callback (form-urlencoded)
app.post('/webhook/sent-fax', (req, res) => {
  const data = JSON.parse(req.body.recordDetails);
  console.log('Fax status:', data.sentStatus);
  console.log('Send token:', data.sendToken);
  res.status(200).send('OK');
});

// Received fax callback (multipart with PDF file)
app.post('/webhook/received-fax', upload.single('file'), (req, res) => {
  const data = JSON.parse(req.body.recordDetails);
  console.log('From:', data.fromNumber);
  console.log('PDF saved to:', req.file?.path);
  res.status(200).send('OK');
});

// AI document callback (form-urlencoded)
app.post('/webhook/ai', (req, res) => {
  const data = JSON.parse(req.body.AiDocumentDetails);
  console.log('Classification:', data.aiResult.documentClassification);
  res.status(200).send('OK');
});

Example: Sent Fax

Triggered after a fax finishes sending. Form field: recordDetails

JSON
// Form field: recordDetails
// Content-Type: application/x-www-form-urlencoded
{
  "fromNumber": "+15550100001",
  "toNumber": "+15550100002",
  "sendToken": "f5cfd99304f9",
  "sentAt": "2025-01-15T14:30:00.000Z",
  "completedAt": "2025-01-15T14:32:00.000Z",
  "sentStatus": "success",
  "numPages": 3,
  "errorDetails": null,
  "isTest": false,
  "secureLink": "https://storage.googleapis.com/medsender-example/..."
}
// Form field: recordDetails
// Content-Type: application/x-www-form-urlencoded
{
  "fromNumber": "+15550100001",
  "toNumber": "+15550100002",
  "sendToken": "f5cfd99304f9",
  "sentAt": "2025-01-15T14:30:00.000Z",
  "completedAt": "2025-01-15T14:32:00.000Z",
  "sentStatus": "success",
  "numPages": 3,
  "errorDetails": null,
  "isTest": false,
  "secureLink": "https://storage.googleapis.com/medsender-example/..."
}

Payload Fields

FieldDescription
sendTokenUnique identifier for this fax
fromNumberYour Medsender fax number that sent this fax
toNumberDestination fax number
sentStatusDelivery status: "success" or "failure"
sentAtWhen the fax was queued (ISO 8601)
completedAtWhen the fax finished sending (ISO 8601)
numPagesNumber of pages sent
errorDetailsError message if sentStatus is "failure"
isTestWhether this was a test fax
secureLinkURL to download the fax PDF

Example: Received Fax

Triggered after a fax is received on your number. Form field: recordDetails + file (PDF attachment)

JSON
// Form fields: recordDetails (JSON string) + file (PDF attachment)
// Content-Type: multipart/form-data
// All keys are camelCase
{
  "fromNumber": "+15550100002",
  "toNumber": "+15550100001",
  "sendToken": "887183cc2a5f",
  "callerName": "EXAMPLE CLINIC",
  "sentAt": "2025-01-15T09:15:00.000Z",
  "completedAt": "2025-01-15T09:16:00.000Z",
  "numPages": 3,
  "isTest": false,
  "faxStatus": "success",
  "errorDetails": null,
  "client": "client_001",
  "patientName": "John Doe",
  "patientDob": "1990-01-15",
  "callbackStatus": "success",
  "documentClassification": "Referral",
  "secondaryCategory": null,
  "patientFirstName": "John",
  "patientMiddleName": null,
  "patientLastName": "Doe",
  "insuranceMemberId": "MEM000000001",
  "referenceNumber": "REF-2025-001",
  "authorizationNumber": null,
  "codes": [{"code": "99213"}],
  "authDateRangeStart": null,
  "authDateRangeEnd": null,
  "denialReason": null
}
// Note: PDF file is also included as 'file' field
// AI extraction fields (documentClassification, patientName, etc.)
// are populated when that feature is enabled for your account
// Form fields: recordDetails (JSON string) + file (PDF attachment)
// Content-Type: multipart/form-data
// All keys are camelCase
{
  "fromNumber": "+15550100002",
  "toNumber": "+15550100001",
  "sendToken": "887183cc2a5f",
  "callerName": "EXAMPLE CLINIC",
  "sentAt": "2025-01-15T09:15:00.000Z",
  "completedAt": "2025-01-15T09:16:00.000Z",
  "numPages": 3,
  "isTest": false,
  "faxStatus": "success",
  "errorDetails": null,
  "client": "client_001",
  "patientName": "John Doe",
  "patientDob": "1990-01-15",
  "callbackStatus": "success",
  "documentClassification": "Referral",
  "secondaryCategory": null,
  "patientFirstName": "John",
  "patientMiddleName": null,
  "patientLastName": "Doe",
  "insuranceMemberId": "MEM000000001",
  "referenceNumber": "REF-2025-001",
  "authorizationNumber": null,
  "codes": [{"code": "99213"}],
  "authDateRangeStart": null,
  "authDateRangeEnd": null,
  "denialReason": null
}
// Note: PDF file is also included as 'file' field
// AI extraction fields (documentClassification, patientName, etc.)
// are populated when that feature is enabled for your account

Payload Fields

FieldDescription
sendTokenUnique identifier for this fax
fromNumberSender's fax number
toNumberYour Medsender fax number that received it
callerNameCaller ID name from sender
faxStatusReception status: "success" or "failure"
sentAtWhen transmission started (ISO 8601)
completedAtWhen reception completed (ISO 8601)
numPagesNumber of pages received
isTestWhether this was a test fax
clientClient ID if the number is assigned to a client
callbackStatusWebhook delivery status

AI Extraction Fields (when enabled for your account):

documentClassificationDocument type: "Referral", "Lab Result", etc.
patientNameFull patient name extracted
patientFirstNamePatient first name
patientLastNamePatient last name
patientDobPatient date of birth
insuranceMemberIdInsurance member ID
codesArray of medical codes (CPT, ICD, etc.)

Example: AI Document

Triggered after AI processing completes. Form field: AiDocumentDetails. All keys, including those inside aiResult, are camelCase.

JSON
// Form field: AiDocumentDetails
// Content-Type: application/x-www-form-urlencoded
// All keys, including those inside aiResult, are camelCase
{
  "aiResult": {
    "aiToken": "32b08347-6001-4e16-877b-7228975af105",
    "documentClassification": "Referral",
    "patientName": "John Doe",
    "patientDob": "1990-01-15",
    "documentSummary": "Cardiology referral for routine evaluation.",
    "completedAt": "2025-01-15T11:00:00.000Z"
  }
}
// Form field: AiDocumentDetails
// Content-Type: application/x-www-form-urlencoded
// All keys, including those inside aiResult, are camelCase
{
  "aiResult": {
    "aiToken": "32b08347-6001-4e16-877b-7228975af105",
    "documentClassification": "Referral",
    "patientName": "John Doe",
    "patientDob": "1990-01-15",
    "documentSummary": "Cardiology referral for routine evaluation.",
    "completedAt": "2025-01-15T11:00:00.000Z"
  }
}

Payload Fields (inside aiResult)

FieldDescription
aiTokenUnique identifier for this AI document request
documentClassificationDocument type: "Referral", "Lab Result", etc.
documentSummaryAI-generated summary of the document
patientNameFull patient name extracted
patientDobPatient date of birth (YYYY-MM-DD)
patientPhoneNumberPatient phone number
patientGenderPatient gender
insuranceMemberIdInsurance member ID
icdCodesJsonArray of ICD codes
cptCodesJsonArray of CPT codes
completedAtWhen processing completed (ISO 8601)
errorDetailsError message if processing failed