> ## Documentation Index
> Fetch the complete documentation index at: https://docs.erstan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# NetSuite OCR Quote Intake

> Run an OCR quote-intake agent from a NetSuite scheduled script.

This walkthrough covers the common AR automation pattern:

1. A NetSuite scheduled script finds inbound email/custom-record rows that need processing.
2. NetSuite sends email metadata, body text, and optional file content to an Erstan OCR agent.
3. Erstan extracts and validates the quote/order request.
4. NetSuite polls for a structured result.
5. NetSuite creates a pending quote/estimate or alerts a user when required fields are missing.

## Recommended agent setup

Create or install a published OCR quote-intake agent.

Recommended form fields:

| Field key          | Type          | Required | Purpose                                                                   |
| ------------------ | ------------- | -------- | ------------------------------------------------------------------------- |
| `sourceRecordType` | text          | Yes      | NetSuite custom record type, such as `customrecord_email_plugin_message`. |
| `sourceRecordId`   | text          | Yes      | NetSuite internal ID of the source record.                                |
| `senderEmail`      | text          | No       | Email sender.                                                             |
| `emailSubject`     | text          | No       | Email subject.                                                            |
| `emailBody`        | long text     | No       | Plain text or HTML-stripped email body.                                   |
| `receivedAt`       | datetime/text | No       | Source email timestamp.                                                   |
| `customerHint`     | text          | No       | Customer name, email domain, or known NetSuite customer ID if available.  |
| `contactHint`      | text          | No       | Contact name or email if available.                                       |

The agent should be instructed to return JSON. A practical output contract is shown below.

## Discover the OCR lane

Use the agent detail endpoint to confirm the lane ID, input schema, and attachment support.

```bash theme={null}
curl "https://api.erstan.com/v1/public/agents/$OCR_AGENT_ID" \
  -H "Authorization: Bearer $ERSTAN_API_KEY"
```

If the response includes `defaultLaneId`, NetSuite can omit `laneId` when starting a run. If the agent has multiple lanes, send the selected lane ID in the run request.

## POST with email body only

Use this shape when there is no attachment, or when the email body contains enough detail.

```bash theme={null}
curl "https://api.erstan.com/v1/public/agents/$OCR_AGENT_ID/runs" \
  -X POST \
  -H "Authorization: Bearer $ERSTAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotencyKey": "netsuite-email-plugin-quote-98342",
    "input": {
      "sourceRecordType": "customrecord_email_plugin_message",
      "sourceRecordId": "98342",
      "senderEmail": "orders@examplecustomer.com",
      "emailSubject": "RE: Quote request - urgent",
      "emailBody": "Please quote 12 of competitor part ABC-100 and 6 green valves. Need delivery next week.",
      "receivedAt": "2026-05-26T10:15:00+10:00",
      "customerHint": "Example Customer",
      "contactHint": "orders@examplecustomer.com"
    },
    "metadata": {
      "sourceSystem": "netsuite",
      "automation": "ar_quote_intake"
    }
  }'
```

## POST with optional attachment

Use `attachments` when NetSuite has an attached PDF, image, spreadsheet, or email body export. Each attachment needs `name`, `type`, and `base64`.

```bash theme={null}
curl "https://api.erstan.com/v1/public/agents/$OCR_AGENT_ID/runs" \
  -X POST \
  -H "Authorization: Bearer $ERSTAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "laneId": "start_ocr_quote_intake",
    "idempotencyKey": "netsuite-email-plugin-quote-98342",
    "input": {
      "sourceRecordType": "customrecord_email_plugin_message",
      "sourceRecordId": "98342",
      "senderEmail": "orders@examplecustomer.com",
      "emailSubject": "PO request attached",
      "emailBody": "Hi, quote attached. The customer uses our old part codes.",
      "customerHint": "Example Customer",
      "contactHint": "orders@examplecustomer.com"
    },
    "attachments": [
      {
        "name": "customer-request.pdf",
        "type": "application/pdf",
        "base64": "JVBERi0xLjQK..."
      }
    ],
    "metadata": {
      "sourceSystem": "netsuite",
      "sourceRecordUrl": "https://ACCOUNT.app.netsuite.com/app/common/custom/custrecordentry.nl?rectype=123&id=98342"
    }
  }'
```

The public API request body limit is 25 MB. For larger files, store the file in a place the agent can access and pass a reference in `input` or `metadata`.

## Poll until complete

```bash theme={null}
curl "https://api.erstan.com/v1/public/runs/$RUN_ID" \
  -H "Authorization: Bearer $ERSTAN_API_KEY"
```

## Expected structured result

For automation, configure the OCR agent to return a single JSON object in `finalResponse`.

```json theme={null}
{
  "classification": {
    "is_quote_request": true,
    "confidence": "medium",
    "reason": "The email body and attached PDF include requested items and quantities."
  },
  "source": {
    "sourceRecordType": "customrecord_email_plugin_message",
    "sourceRecordId": "98342",
    "senderEmail": "orders@examplecustomer.com",
    "emailSubject": "PO request attached",
    "hasAttachments": true
  },
  "extractedQuote": {
    "customerName": "Example Customer",
    "customerEmail": "orders@examplecustomer.com",
    "customerPoNumber": null,
    "requestedShipDate": "2026-06-02",
    "currency": "AUD",
    "notes": "Customer used competitor part code ABC-100.",
    "lineItems": [
      {
        "lineNumber": 1,
        "inputSku": "ABC-100",
        "description": "competitor part ABC-100",
        "quantity": 12,
        "unitRate": null
      },
      {
        "lineNumber": 2,
        "inputSku": null,
        "description": "green valves",
        "quantity": 6,
        "unitRate": null
      }
    ]
  },
  "netsuiteMatches": {
    "customer": {
      "matched": false,
      "customerId": null,
      "confidence": "low",
      "reason": "Email domain did not map to exactly one active customer."
    },
    "contact": {
      "matched": false,
      "contactId": null,
      "action": "review_required"
    },
    "items": [
      {
        "lineNumber": 1,
        "matched": true,
        "itemId": "28322",
        "itemName": "Replacement Part ABC-100",
        "confidence": "high",
        "reason": "Competitor code mapped to internal replacement part."
      },
      {
        "lineNumber": 2,
        "matched": false,
        "itemId": null,
        "confidence": "low",
        "reason": "Multiple valve items matched the description."
      }
    ],
    "duplicate": {
      "status": "skipped",
      "matches": []
    }
  },
  "decision": {
    "readyToCreatePendingQuote": false,
    "recommendedAction": "alert_user",
    "blockingIssues": [
      "Customer could not be identified",
      "Contact could not be identified",
      "Line 2 item is ambiguous",
      "Customer PO number is missing"
    ],
    "warnings": [
      "Source may mention accounts rather than a quote; classification confidence is medium."
    ]
  }
}
```

## NetSuite scheduled script outline

Use `idempotencyKey` to make retries safe. A good key is the source custom record ID plus the attachment ID or last modified timestamp.

```js theme={null}
/**
 * SuiteScript 2.x outline only. Replace sourceRecord fields with your custom record fields.
 */
define(['N/https', 'N/runtime'], function (https, runtime) {
  const API_BASE = 'https://api.erstan.com';

  function apiKey() {
    return runtime.getCurrentScript().getParameter({ name: 'custscript_erstan_api_key' });
  }

  function postJson(url, payload) {
    const response = https.post({
      url,
      headers: {
        Authorization: `Bearer ${apiKey()}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(payload)
    });

    const body = response.body ? JSON.parse(response.body) : {};
    if (response.code < 200 || response.code >= 300) {
      throw new Error(body.error && body.error.message ? body.error.message : `Erstan API failed: ${response.code}`);
    }
    return body;
  }

  function getJson(url) {
    const response = https.get({
      url,
      headers: {
        Authorization: `Bearer ${apiKey()}`
      }
    });

    const body = response.body ? JSON.parse(response.body) : {};
    if (response.code < 200 || response.code >= 300) {
      throw new Error(body.error && body.error.message ? body.error.message : `Erstan API failed: ${response.code}`);
    }
    return body;
  }

  function submitQuoteIntake(sourceRecord) {
    const agentId = runtime.getCurrentScript().getParameter({ name: 'custscript_erstan_ocr_agent_id' });
    const laneId = runtime.getCurrentScript().getParameter({ name: 'custscript_erstan_ocr_lane_id' });

    const payload = {
      laneId: laneId || undefined,
      idempotencyKey: `netsuite-email-plugin-quote-${sourceRecord.id}`,
      input: {
        sourceRecordType: sourceRecord.type,
        sourceRecordId: String(sourceRecord.id),
        senderEmail: sourceRecord.senderEmail,
        emailSubject: sourceRecord.subject,
        emailBody: sourceRecord.bodyText,
        receivedAt: sourceRecord.receivedAt,
        customerHint: sourceRecord.customerHint,
        contactHint: sourceRecord.senderEmail
      },
      attachments: sourceRecord.attachmentBase64
        ? [
            {
              name: sourceRecord.attachmentName,
              type: sourceRecord.attachmentContentType,
              base64: sourceRecord.attachmentBase64
            }
          ]
        : [],
      metadata: {
        sourceSystem: 'netsuite',
        automation: 'ar_quote_intake'
      }
    };

    const startBody = postJson(`${API_BASE}/v1/public/agents/${agentId}/runs`, payload);
    return startBody.runId;
  }

  function pollRun(runId) {
    return getJson(`${API_BASE}/v1/public/runs/${runId}`);
  }

  return {
    submitQuoteIntake,
    pollRun
  };
});
```

After polling returns `completed`, NetSuite can:

| Result                                      | NetSuite action                                                    |
| ------------------------------------------- | ------------------------------------------------------------------ |
| `decision.readyToCreatePendingQuote = true` | Create a pending estimate/quote record and attach the source file. |
| Missing customer/contact/item/PO            | Alert the responsible user or route to a review queue.             |
| `classification.is_quote_request = false`   | Mark the source record as not actionable and store the reason.     |
| Duplicate found                             | Link to existing transaction and stop automated creation.          |

This keeps the risky decisions visible while still automating the expensive OCR, parsing, and matching work.
