# 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 type | Form field | Token | Timestamp | Signing key |
| --- | --- | --- | --- | --- |
| Sent Fax | recordDetails | sendToken | completedAt | Test key when `isTest` is true, otherwise live key |
| Received Fax | recordDetails | sendToken | completedAt | Test key when `isTest` is true, otherwise live key |
| AI Document | AiDocumentDetails | aiResult.aiToken | aiResult.completedAt | Always 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.

#### cURL

```bash
# Debugging only: recompute the signature for a callback you received.
# NOTE: shell string comparison is not constant-time — verify signatures in
# your server-side handler (see the other language tabs) in production.

TOKEN='887183cc2a5f'                     # sendToken (or aiResult.aiToken for AI callbacks)
TIMESTAMP='2025-01-15T09:16:00.000Z'     # completedAt exactly as it appears in the payload
API_KEY='test_0000000000000000000000'    # fax: test key when isTest is true, else live key; AI: always live key

# Use printf, never echo — echo appends a newline and changes the HMAC.
# openssl prefixes its output, so extract the bare hex digest with awk.
printf '%s' "${TOKEN}${TIMESTAMP}" \
  | openssl dgst -sha256 -hmac "${API_KEY}" \
  | awk '{print $NF}'

# Compare the output with the X-Medsender-Signature header (bare hex, no prefix).
# If completedAt is JSON null, use the literal string: TIMESTAMP='null'
```

#### Node

```javascript
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');
});
```

#### TypeScript

```typescript
import crypto from 'crypto';
import express, { Request, Response } 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 as string;
const LIVE_API_KEY = process.env.MEDSENDER_LIVE_API_KEY as string;

function verifySignature(token: string, timestamp: string | null, header: string | undefined, apiKey: string): boolean {
  // 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: Request, res: Response) => {
  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: Request, res: Response) => {
  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');
});
```

#### Python

```python
import hashlib
import hmac
import json
import os

from flask import Flask, request

app = Flask(__name__)
TEST_API_KEY = os.environ['MEDSENDER_TEST_API_KEY']
LIVE_API_KEY = os.environ['MEDSENDER_LIVE_API_KEY']

def verify_signature(token, timestamp, header, api_key):
    # Use the exact strings from the payload; a null timestamp signs the literal string "null"
    base = token + ('null' if timestamp is None else timestamp)
    expected = hmac.new(api_key.encode(), base.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or '')

@app.route('/webhook/fax', methods=['POST'])
def handle_fax():
    # Fax callbacks: signature covers sendToken + completedAt
    data = json.loads(request.form['recordDetails'])
    api_key = TEST_API_KEY if data['isTest'] else LIVE_API_KEY  # fax signatures are keyed by isTest
    if not verify_signature(data['sendToken'], data['completedAt'],
                            request.headers.get('X-Medsender-Signature'), api_key):
        return 'invalid signature', 401  # fax callbacks are retried
    return 'OK', 200

@app.route('/webhook/ai', methods=['POST'])
def handle_ai():
    # AI callbacks: signature covers aiResult.aiToken + aiResult.completedAt (always the live key)
    ai = json.loads(request.form['AiDocumentDetails'])['aiResult']
    if not verify_signature(ai['aiToken'], ai['completedAt'],
                            request.headers.get('X-Medsender-Signature'), LIVE_API_KEY):
        return 'invalid signature', 401  # AI callbacks are NOT redelivered
    return 'OK', 200
```

#### Ruby

```ruby
require 'json'
require 'openssl'
require 'rack/utils'
require 'sinatra'

TEST_API_KEY = ENV.fetch('MEDSENDER_TEST_API_KEY')
LIVE_API_KEY = ENV.fetch('MEDSENDER_LIVE_API_KEY')

def verify_signature(token, timestamp, header, api_key)
  # Use the exact strings from the payload; a null timestamp signs the literal string "null"
  base = token + (timestamp.nil? ? 'null' : timestamp)
  expected = OpenSSL::HMAC.hexdigest('SHA256', api_key, base)
  Rack::Utils.secure_compare(expected, header.to_s)
end

# Fax callbacks: signature covers sendToken + completedAt
post '/webhook/fax' do
  data = JSON.parse(params['recordDetails'])
  api_key = data['isTest'] ? TEST_API_KEY : LIVE_API_KEY # fax signatures are keyed by isTest
  unless verify_signature(data['sendToken'], data['completedAt'],
                          request.env['HTTP_X_MEDSENDER_SIGNATURE'], api_key)
    halt 401, 'invalid signature' # fax callbacks are retried
  end
  status 200
  'OK'
end

# AI callbacks: signature covers aiResult.aiToken + aiResult.completedAt (always the live key)
post '/webhook/ai' do
  ai = JSON.parse(params['AiDocumentDetails'])['aiResult']
  unless verify_signature(ai['aiToken'], ai['completedAt'],
                          request.env['HTTP_X_MEDSENDER_SIGNATURE'], LIVE_API_KEY)
    halt 401, 'invalid signature' # AI callbacks are NOT redelivered
  end
  status 200
  'OK'
end
```

#### PHP

```php
<?php
$testApiKey = getenv('MEDSENDER_TEST_API_KEY');
$liveApiKey = getenv('MEDSENDER_LIVE_API_KEY');
$header = $_SERVER['HTTP_X_MEDSENDER_SIGNATURE'] ?? '';

function verifySignature($token, $timestamp, $header, $apiKey) {
    // Use the exact strings from the payload; a null timestamp signs the literal string "null"
    $base = $token . ($timestamp === null ? 'null' : $timestamp);
    $expected = hash_hmac('sha256', $base, $apiKey);
    return hash_equals($expected, $header);
}

if (isset($_POST['recordDetails'])) {
    // Fax callbacks: signature covers sendToken + completedAt
    $data = json_decode($_POST['recordDetails'], true);
    $apiKey = $data['isTest'] ? $testApiKey : $liveApiKey; // fax signatures are keyed by isTest
    if (!verifySignature($data['sendToken'], $data['completedAt'], $header, $apiKey)) {
        http_response_code(401); // fax callbacks are retried
        exit('invalid signature');
    }
}

if (isset($_POST['AiDocumentDetails'])) {
    // AI callbacks: signature covers aiResult.aiToken + aiResult.completedAt (always the live key)
    $ai = json_decode($_POST['AiDocumentDetails'], true)['aiResult'];
    if (!verifySignature($ai['aiToken'], $ai['completedAt'], $header, $liveApiKey)) {
        http_response_code(401); // AI callbacks are NOT redelivered
        exit('invalid signature');
    }
}

http_response_code(200);
echo 'OK';
```

#### Java

```java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class MedsenderSignature {
    // Use the exact strings from the payload; a null timestamp signs the literal string "null"
    public static boolean verify(String token, String timestamp, String header, String apiKey)
            throws Exception {
        String base = token + (timestamp == null ? "null" : timestamp);
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(apiKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] digest = mac.doFinal(base.getBytes(StandardCharsets.UTF_8));
        StringBuilder hex = new StringBuilder();
        for (byte b : digest) {
            hex.append(String.format("%02x", b));
        }
        return MessageDigest.isEqual(
            hex.toString().getBytes(StandardCharsets.UTF_8),
            (header == null ? "" : header).getBytes(StandardCharsets.UTF_8));
    }
}

// Fax callbacks:  verify(data.sendToken, data.completedAt, signatureHeader, isTest ? testApiKey : liveApiKey)
// AI callbacks:   verify(aiResult.aiToken, aiResult.completedAt, signatureHeader, liveApiKey)
//                 (AI callbacks always use the live key and are NOT redelivered on rejection)
```

#### C#

```csharp
using System.Security.Cryptography;
using System.Text;

public static class MedsenderSignature
{
    // Use the exact strings from the payload; a null timestamp signs the literal string "null"
    public static bool Verify(string token, string? timestamp, string? header, string apiKey)
    {
        var baseString = token + (timestamp ?? "null");
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiKey));
        var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(baseString));
        var expected = Convert.ToHexString(digest).ToLowerInvariant();
        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected),
            Encoding.UTF8.GetBytes(header ?? ""));
    }
}

// Fax callbacks:  Verify(data.sendToken, data.completedAt, signatureHeader, isTest ? testApiKey : liveApiKey)
// AI callbacks:   Verify(aiResult.aiToken, aiResult.completedAt, signatureHeader, liveApiKey)
//                 (AI callbacks always use the live key and are NOT redelivered on rejection)
```

## 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.

#### cURL

```bash
# Example of what Medsender sends to your webhook:
# Content-Type: application/x-www-form-urlencoded
# Body: recordDetails={"fromNumber":"+14155550100","toNumber":"+13125550123",...}

# To test your webhook locally:
curl -X POST http://localhost:3000/webhook/fax \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'recordDetails={"fromNumber":"+14155550100","sendToken":"abc123","sentStatus":"success"}'
```

#### Node

```javascript
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');
});
```

#### TypeScript

```typescript
import express, { Request, Response } from 'express';
const app = express();
app.use(express.urlencoded({ extended: true }));

interface FaxCallback {
  fromNumber: string;
  toNumber: string;
  sendToken: string;
  sentStatus: string;
  secureLink: string;
}

app.post('/webhook/fax', (req: Request, res: Response) => {
  const data: FaxCallback = JSON.parse(req.body.recordDetails);
  console.log('Fax status:', data.sentStatus);
  res.status(200).send('OK');
});
```

#### Python

```python
from flask import Flask, request
import json

app = Flask(__name__)

@app.route('/webhook/sent-fax', methods=['POST'])
def handle_sent_fax():
    # Sent fax: form-urlencoded with recordDetails
    data = json.loads(request.form['recordDetails'])
    print(f"Fax status: {data['sentStatus']}")
    return 'OK', 200

@app.route('/webhook/received-fax', methods=['POST'])
def handle_received_fax():
    # Received fax: multipart with recordDetails + file
    data = json.loads(request.form['recordDetails'])
    pdf_file = request.files.get('file')
    if pdf_file:
        pdf_file.save(f"uploads/{pdf_file.filename}")
    print(f"From: {data['fromNumber']}")
    return 'OK', 200

@app.route('/webhook/ai', methods=['POST'])
def handle_ai():
    # AI: form-urlencoded with AiDocumentDetails
    data = json.loads(request.form['AiDocumentDetails'])
    print(f"Classification: {data['aiResult']['documentClassification']}")
    return 'OK', 200
```

#### Ruby

```ruby
require 'sinatra'
require 'json'

# Sent fax callback (form-urlencoded)
post '/webhook/sent-fax' do
  data = JSON.parse(params['recordDetails'])
  puts "Fax status: #{data['sentStatus']}"
  status 200
  'OK'
end

# Received fax callback (multipart with file)
post '/webhook/received-fax' do
  data = JSON.parse(params['recordDetails'])
  if params['file']
    File.open("uploads/#{params['file'][:filename]}", 'wb') do |f|
      f.write(params['file'][:tempfile].read)
    end
  end
  puts "From: #{data['fromNumber']}"
  status 200
  'OK'
end

# AI document callback (form-urlencoded)
post '/webhook/ai' do
  data = JSON.parse(params['AiDocumentDetails'])
  puts "Classification: #{data['aiResult']['documentClassification']}"
  status 200
  'OK'
end
```

#### PHP

```php
<?php
// Handle different callback types based on which field is present

if (isset($_POST['recordDetails'])) {
    // Sent or Received fax callback
    $data = json_decode($_POST['recordDetails'], true);
    error_log("Send token: " . $data['sendToken']);

    // Received fax includes a file upload
    if (isset($_FILES['file'])) {
        $uploadDir = 'uploads/';
        move_uploaded_file(
            $_FILES['file']['tmp_name'],
            $uploadDir . $_FILES['file']['name']
        );
        error_log("PDF saved: " . $_FILES['file']['name']);
    }
}

if (isset($_POST['AiDocumentDetails'])) {
    // AI document callback
    $data = json_decode($_POST['AiDocumentDetails'], true);
    $classification = $data['aiResult']['documentClassification'];
    error_log("Classification: " . $classification);
}

http_response_code(200);
echo 'OK';
```

#### Java

```java
import javax.servlet.http.*;
import com.google.gson.*;

@WebServlet("/webhook/fax")
public class FaxWebhook extends HttpServlet {
    protected void doPost(HttpServletRequest req, HttpServletResponse res) {
        // Parse JSON from form field
        String json = req.getParameter("recordDetails");
        JsonObject data = JsonParser.parseString(json).getAsJsonObject();

        System.out.println("Fax status: " + data.get("sentStatus").getAsString());
        System.out.println("Send token: " + data.get("sendToken").getAsString());

        res.setStatus(200);
        res.getWriter().write("OK");
    }
}
```

#### C#

```csharp
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;

[ApiController]
public class WebhookController : ControllerBase
{
    [HttpPost("/webhook/fax")]
    public IActionResult HandleFax([FromForm] string recordDetails)
    {
        // Parse JSON from form field
        var data = JsonSerializer.Deserialize<JsonElement>(recordDetails);
        Console.WriteLine($"Fax status: {data.GetProperty("sentStatus")}");
        Console.WriteLine($"Send token: {data.GetProperty("sendToken")}");
        return Ok("OK");
    }

    [HttpPost("/webhook/ai")]
    public IActionResult HandleAi([FromForm] string AiDocumentDetails)
    {
        var data = JsonSerializer.Deserialize<JsonElement>(AiDocumentDetails);
        var aiResult = data.GetProperty("aiResult");
        Console.WriteLine($"Classification: {aiResult.GetProperty("documentClassification")}");
        return Ok("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/..."
}
```

Payload Fields

| Field | Description |
| --- | --- |
| sendToken | Unique identifier for this fax |
| fromNumber | Your Medsender fax number that sent this fax |
| toNumber | Destination fax number |
| sentStatus | Delivery status: "success" or "failure" |
| sentAt | When the fax was queued (ISO 8601) |
| completedAt | When the fax finished sending (ISO 8601) |
| numPages | Number of pages sent |
| errorDetails | Error message if sentStatus is "failure" |
| isTest | Whether this was a test fax |
| secureLink | URL 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
```

Payload Fields

| Field | Description |
| --- | --- |
| sendToken | Unique identifier for this fax |
| fromNumber | Sender's fax number |
| toNumber | Your Medsender fax number that received it |
| callerName | Caller ID name from sender |
| faxStatus | Reception status: "success" or "failure" |
| sentAt | When transmission started (ISO 8601) |
| completedAt | When reception completed (ISO 8601) |
| numPages | Number of pages received |
| isTest | Whether this was a test fax |
| client | Client ID if the number is assigned to a client |
| callbackStatus | Webhook delivery status |

**AI Extraction Fields** (when enabled for your account):

| Field | Description |
| --- | --- |
| documentClassification | Document type: "Referral", "Lab Result", etc. |
| patientName | Full patient name extracted |
| patientFirstName | Patient first name |
| patientLastName | Patient last name |
| patientDob | Patient date of birth |
| insuranceMemberId | Insurance member ID |
| codes | Array 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"
  }
}
```

Payload Fields (inside `aiResult`)

| Field | Description |
| --- | --- |
| aiToken | Unique identifier for this AI document request |
| documentClassification | Document type: "Referral", "Lab Result", etc. |
| documentSummary | AI-generated summary of the document |
| patientName | Full patient name extracted |
| patientDob | Patient date of birth (YYYY-MM-DD) |
| patientPhoneNumber | Patient phone number |
| patientGender | Patient gender |
| insuranceMemberId | Insurance member ID |
| icdCodesJson | Array of ICD codes |
| cptCodesJson | Array of CPT codes |
| completedAt | When processing completed (ISO 8601) |
| errorDetails | Error message if processing failed |
