Callbacks

When operations complete (send fax, receive fax, 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 with a webhook signing secret (V2)

Use the whsec_ secret configured for this endpoint with X-Medsender-Signature-V2. V2 signs a fresh Unix timestamp and the exact transmitted payload string. Do not parse and reserialize the payload before verification.

  • Header format: t=<unix-seconds>,v2=<64 lowercase hex characters>.
  • For fax and secure-link callbacks, select the Test secret when isTest is true; otherwise select Live. Received-email and AI-document callbacks use Live. You may read isTest to select the candidate secret, but do not trust or act on any payload content until verification succeeds.
  • Every delivery attempt, including each retry, gets a fresh timestamp and must independently pass the five-minute check.
  • Reject timestamps more than five minutes in the past or future and malformed headers before constant-time comparison.
  • Freshness does not prevent replay. Deduplicate side effects with sendToken or aiToken while still accepting legitimate retries.
CallbackExact payload argumentFile digest argumentSecret
Received faxExact recordDetails stringLowercase SHA-256 of raw uploaded PDF bytesTest when isTest is true; otherwise Live
Received emailExact raw JSON request bodyNoneLive
Sent faxExact recordDetails stringNoneTest when isTest is true; otherwise Live
Sent secure linkExact recordDetails stringNoneTest when isTest is true; otherwise Live
AI documentExact AiDocumentDetails stringNoneLive
TypeScript
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';

export function sha256FileBytes(fileBytes: Buffer): string {
  return createHash('sha256').update(fileBytes).digest('hex');
}

export function verifyWebhookV2(secret: string, header: string | null | undefined, exactPayload: string, fileDigest?: string): boolean {
  if (!secret.startsWith('whsec_') || !header) return false;
  const match = /^t=(0|[1-9]\d*),v2=([a-f0-9]{64})$/.exec(header);
  if (!match) return false;
  const timestamp = Number(match[1]);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > 300) return false;

  const material = [timestamp, exactPayload, fileDigest].filter((value) => value !== undefined).join('.');
  const expected = createHmac('sha256', secret).update(material).digest();
  const received = Buffer.from(match[2], 'hex');
  return expected.length === received.length && timingSafeEqual(expected, received);
}
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';

export function sha256FileBytes(fileBytes: Buffer): string {
  return createHash('sha256').update(fileBytes).digest('hex');
}

export function verifyWebhookV2(secret: string, header: string | null | undefined, exactPayload: string, fileDigest?: string): boolean {
  if (!secret.startsWith('whsec_') || !header) return false;
  const match = /^t=(0|[1-9]\d*),v2=([a-f0-9]{64})$/.exec(header);
  if (!match) return false;
  const timestamp = Number(match[1]);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > 300) return false;

  const material = [timestamp, exactPayload, fileDigest].filter((value) => value !== undefined).join('.');
  const expected = createHmac('sha256', secret).update(material).digest();
  const received = Buffer.from(match[2], 'hex');
  return expected.length === received.length && timingSafeEqual(expected, received);
}

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

Most callbacks are sent as form data with a JSON string in a named field. Received-email callbacks instead use the raw JSON request body.

  • 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

Verify the V2 signature against the exact transport value before parsing, logging, saving files, or performing side effects. The examples below focus only on parsing after that verification succeeds.

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