---
name: Certyo + NetSuite Integration
version: 1.0.0
description: Generate Oracle NetSuite to Certyo integration code with field mappings, auth, and working examples
api_base: https://www.certyos.com
auth: X-API-Key header
last_updated: 2026-04-14
---

# Certyo + NetSuite Integration Skill

This skill enables AI agents to generate production-ready integration code that sends product authenticity and fulfillment records from Oracle NetSuite to the Certyo blockchain anchoring platform. It covers SuiteScript 2.x User Event Scripts, RESTlets, Map/Reduce scripts for bulk operations, and Python-based external synchronization.

## Certyo API Reference

### Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/v1/records` | Ingest a single record (returns 202 Accepted) |
| `POST` | `/api/v1/records/bulk` | Ingest up to 1,000 records in one call |
| `POST` | `/api/v1/verify/record` | Verify a record's blockchain anchoring status |
| `GET` | `/api/v1/records` | Query records by tenant, database, collection, or recordId |

### Authentication

All Certyo API calls require an `X-API-Key` header.

### Record Payload Schema

```json
{
  "tenantId": "string (required)",
  "database": "string (required)",
  "collection": "string (required)",
  "recordId": "string (required)",
  "recordPayload": { "...any JSON (required)" },
  "clientId": "string (optional)",
  "recordVersion": "string (optional, default '1')",
  "operationType": "upsert|insert|update|delete (optional)",
  "sourceTimestamp": "ISO 8601 (optional)",
  "idempotencyKey": "string (optional)"
}
```

### Successful Response (202 Accepted)

```json
{
  "recordId": "string",
  "recordHash": "string (SHA-256)",
  "tenantId": "string",
  "acceptedAt": "ISO 8601",
  "idempotencyReplayed": false
}
```

### Pipeline

Records flow through: Kafka accumulation (1,000 records or 60 seconds) then Merkle tree computation then IPFS pinning then Polygon blockchain anchor. Typical end-to-end anchoring latency is 60-90 seconds.

## NetSuite Integration Pattern

### Architecture

```
NetSuite
  |
  | User Event Script (afterSubmit) on Item Fulfillment
  | or Scheduled Script / Map-Reduce for bulk
  v
N/https module --> POST /api/v1/records
  |
  v
Certyo API (202 Accepted)
  |
  | (external poller or Scheduled Script checks status)
  v
N/record.submitFields --> update custom fields on fulfillment
```

### When to Use This Pattern

- You are running Oracle NetSuite (any edition: SuiteCommerce, OneWorld, mid-market).
- You need to anchor item fulfillments, sales orders, purchase receipts, or inventory adjustments.
- For real-time, event-driven ingestion, use a User Event Script on `afterSubmit`.
- For bulk historical sync or high-volume scenarios, use a Map/Reduce script.
- For external systems that sync with NetSuite, use the Python REST/SuiteTalk approach.

### Concurrency Limits

- RESTlet concurrent execution limit: 10 per account.
- Scheduled Script: 1 deployment runs at a time per script.
- Map/Reduce: up to 5 parallel reduce stages.
- SuiteScript governance: 5,000 units for User Event, 10,000 for Scheduled, 10,000 per Map/Reduce stage.

## Authentication

### NetSuite Side (Token-Based Authentication)

NetSuite TBA requires four credentials: consumer key, consumer secret, token ID, and token secret.

```javascript
/**
 * TBA credentials are stored as script parameters or in a custom record.
 * Never hardcode credentials in SuiteScript files.
 *
 * Setup:
 * 1. Enable Token-Based Authentication (Setup > Company > Enable Features > SuiteCloud > Manage Authentication)
 * 2. Create an Integration record (consumer key/secret)
 * 3. Create a Token for the integration + role
 */
```

For OAuth 2.0 (NetSuite 2021.2+), use the Machine-to-Machine (M2M) flow:

```python
import requests
import jwt
import time

def get_netsuite_oauth2_token(client_id: str, certificate_id: str, private_key: str, account_id: str) -> str:
    now = int(time.time())
    payload = {
        "iss": client_id,
        "scope": "restlets,rest_webservices",
        "aud": f"https://{account_id}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token",
        "iat": now,
        "exp": now + 3600,
    }
    assertion = jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": certificate_id})

    resp = requests.post(
        f"https://{account_id}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token",
        data={
            "grant_type": "client_credentials",
            "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
            "client_assertion": assertion,
        },
    )
    resp.raise_for_status()
    return resp.json()["access_token"]
```

### Certyo Side

Store the Certyo API key in a NetSuite custom record (`customrecord_certyo_config`) with restricted access, or pass it as a Script Parameter on the deployment record.

```javascript
// Access Certyo API key from script parameter
var certYoApiKey = runtime.getCurrentScript().getParameter({
  name: "custscript_certyo_api_key",
});
```

## Field Mapping

| NetSuite Field | Certyo Field | Notes |
|---|---|---|
| `ItemFulfillment.tranid` | `recordId` | Fulfillment document number (e.g., "IF-12345") |
| `ItemFulfillment.trandate` | `sourceTimestamp` | Convert NetSuite Date to ISO 8601 |
| `"netsuite"` (constant) | `database` | Identifies the source system |
| `"item-fulfillment"` (constant) | `collection` | Or `"sales-order"`, `"purchase-receipt"`, etc. |
| `item.itemid` | `recordPayload.sku` | Item internal ID or name/number |
| `item.displayname` | `recordPayload.itemName` | Human-readable item name |
| `line.quantity` | `recordPayload.quantity` | Fulfilled quantity |
| `line.inventorynumber` | `recordPayload.serialNumber` | Serial/lot for inventory detail |
| `ItemFulfillment.entity` | `recordPayload.customerInternalId` | Customer internal ID |
| `ItemFulfillment.createdfrom` | `recordPayload.salesOrderId` | Link to source sales order |
| `ItemFulfillment.shipmethod` | `recordPayload.shippingMethod` | Carrier/shipping method |
| `ItemFulfillment.shipstatus` | `recordPayload.shipStatus` | A = Picked, B = Packed, C = Shipped |
| NetSuite account ID | `tenantId` | Your Certyo tenant identifier |
| `tranid + "-v" + version` | `idempotencyKey` | Prevents duplicate ingestion |

## Code Examples

### Example 1: SuiteScript 2.x User Event Script (afterSubmit)

This script fires after an Item Fulfillment is saved and sends the record to Certyo in real time.

```javascript
/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 * @NModuleScope SameAccount
 * @NAmdConfig ./certyo_config.json
 */
define(["N/https", "N/record", "N/runtime", "N/log", "N/format"], (
  https,
  record,
  runtime,
  log,
  format
) => {
  const CERTYO_BASE = "https://www.certyos.com";

  const afterSubmit = (context) => {
    if (
      context.type !== context.UserEventType.CREATE &&
      context.type !== context.UserEventType.EDIT
    ) {
      return;
    }

    const fulfillment = context.newRecord;
    const certYoApiKey = runtime.getCurrentScript().getParameter({
      name: "custscript_certyo_api_key",
    });
    const certYoTenantId = runtime.getCurrentScript().getParameter({
      name: "custscript_certyo_tenant_id",
    });

    if (!certYoApiKey || !certYoTenantId) {
      log.error({ title: "Certyo", details: "Missing Certyo credentials." });
      return;
    }

    const tranId = fulfillment.getValue({ fieldId: "tranid" });
    const tranDate = fulfillment.getValue({ fieldId: "trandate" });
    const entityId = fulfillment.getValue({ fieldId: "entity" });
    const createdFrom = fulfillment.getValue({ fieldId: "createdfrom" });
    const shipStatus = fulfillment.getValue({ fieldId: "shipstatus" });
    const shipMethod = fulfillment.getText({ fieldId: "shipmethod" });

    // Format date to ISO 8601
    const sourceTimestamp = tranDate
      ? format
          .format({ value: tranDate, type: format.Type.DATETIME })
          .replace(" ", "T") + "Z"
      : new Date().toISOString();

    // Collect line items
    const lineCount = fulfillment.getLineCount({ sublistId: "item" });
    const items = [];

    for (let i = 0; i < lineCount; i++) {
      items.push({
        sku: fulfillment.getSublistValue({
          sublistId: "item",
          fieldId: "itemid",
          line: i,
        }),
        itemName: fulfillment.getSublistText({
          sublistId: "item",
          fieldId: "item",
          line: i,
        }),
        quantity: fulfillment.getSublistValue({
          sublistId: "item",
          fieldId: "quantity",
          line: i,
        }),
        serialNumbers:
          fulfillment.getSublistValue({
            sublistId: "item",
            fieldId: "serialnumbers",
            line: i,
          }) || "",
        lotNumber:
          fulfillment.getSublistValue({
            sublistId: "item",
            fieldId: "inventorynumber",
            line: i,
          }) || "",
      });
    }

    const operationType =
      context.type === context.UserEventType.CREATE ? "insert" : "update";
    const recordVersion =
      context.type === context.UserEventType.CREATE ? "1" : "2";

    const certYoPayload = {
      tenantId: certYoTenantId,
      database: "netsuite",
      collection: "item-fulfillment",
      recordId: tranId,
      operationType: operationType,
      sourceTimestamp: sourceTimestamp,
      recordVersion: recordVersion,
      idempotencyKey: tranId + "-v" + recordVersion,
      recordPayload: {
        fulfillmentNumber: tranId,
        customerInternalId: entityId,
        salesOrderId: createdFrom,
        shipStatus: shipStatus,
        shippingMethod: shipMethod,
        items: items,
        tranDate: sourceTimestamp,
      },
    };

    try {
      const response = https.post({
        url: CERTYO_BASE + "/api/v1/records",
        headers: {
          "Content-Type": "application/json",
          "X-API-Key": certYoApiKey,
        },
        body: JSON.stringify(certYoPayload),
      });

      if (response.code === 202) {
        const body = JSON.parse(response.body);
        log.audit({
          title: "Certyo Ingested",
          details:
            "Record " +
            tranId +
            " accepted. Hash: " +
            body.recordHash,
        });

        // Write hash back to fulfillment custom field
        record.submitFields({
          type: record.Type.ITEM_FULFILLMENT,
          id: fulfillment.id,
          values: {
            custbody_certyo_record_hash: body.recordHash,
            custbody_certyo_status: "Ingested",
          },
          options: { enableSourcing: false, ignoreMandatoryFields: true },
        });
      } else {
        log.error({
          title: "Certyo Error",
          details: "HTTP " + response.code + ": " + response.body,
        });
      }
    } catch (e) {
      log.error({
        title: "Certyo Exception",
        details: e.message,
      });
      // Do not throw - allow the fulfillment save to complete.
      // Failed records will be retried by the scheduled reconciliation script.
    }
  };

  return { afterSubmit };
});
```

### Example 2: SuiteScript 2.x RESTlet for External Trigger

A RESTlet that external systems (e.g., a warehouse management system) can call to trigger Certyo ingestion for a specific fulfillment.

```javascript
/**
 * @NApiVersion 2.1
 * @NScriptType Restlet
 * @NModuleScope SameAccount
 */
define(["N/https", "N/record", "N/runtime", "N/log", "N/search"], (
  https,
  record,
  runtime,
  log,
  search
) => {
  const CERTYO_BASE = "https://www.certyos.com";

  const post = (requestBody) => {
    const { fulfillmentId } = requestBody;

    if (!fulfillmentId) {
      return { error: "fulfillmentId is required" };
    }

    const certYoApiKey = runtime.getCurrentScript().getParameter({
      name: "custscript_certyo_api_key",
    });
    const certYoTenantId = runtime.getCurrentScript().getParameter({
      name: "custscript_certyo_tenant_id",
    });

    // Load the fulfillment record
    const fulfillment = record.load({
      type: record.Type.ITEM_FULFILLMENT,
      id: fulfillmentId,
      isDynamic: false,
    });

    const tranId = fulfillment.getValue({ fieldId: "tranid" });
    const tranDate = fulfillment.getValue({ fieldId: "trandate" });

    // Collect items
    const lineCount = fulfillment.getLineCount({ sublistId: "item" });
    const items = [];
    for (let i = 0; i < lineCount; i++) {
      items.push({
        sku: fulfillment.getSublistValue({
          sublistId: "item",
          fieldId: "itemid",
          line: i,
        }),
        quantity: fulfillment.getSublistValue({
          sublistId: "item",
          fieldId: "quantity",
          line: i,
        }),
      });
    }

    const payload = {
      tenantId: certYoTenantId,
      database: "netsuite",
      collection: "item-fulfillment",
      recordId: tranId,
      operationType: "insert",
      sourceTimestamp: tranDate ? tranDate.toISOString() : new Date().toISOString(),
      idempotencyKey: tranId + "-v1",
      recordPayload: {
        fulfillmentNumber: tranId,
        items: items,
      },
    };

    const response = https.post({
      url: CERTYO_BASE + "/api/v1/records",
      headers: {
        "Content-Type": "application/json",
        "X-API-Key": certYoApiKey,
      },
      body: JSON.stringify(payload),
    });

    if (response.code === 202) {
      const body = JSON.parse(response.body);

      record.submitFields({
        type: record.Type.ITEM_FULFILLMENT,
        id: fulfillmentId,
        values: {
          custbody_certyo_record_hash: body.recordHash,
          custbody_certyo_status: "Ingested",
        },
        options: { enableSourcing: false, ignoreMandatoryFields: true },
      });

      return {
        success: true,
        recordId: body.recordId,
        recordHash: body.recordHash,
      };
    }

    return { success: false, httpCode: response.code, body: response.body };
  };

  return { post };
});
```

### Example 3: Map/Reduce Script for Bulk Historical Sync

Use this to backfill historical item fulfillments into Certyo in bulk.

```javascript
/**
 * @NApiVersion 2.1
 * @NScriptType MapReduceScript
 * @NModuleScope SameAccount
 */
define(["N/search", "N/https", "N/record", "N/runtime", "N/log"], (
  search,
  https,
  record,
  runtime,
  log
) => {
  const CERTYO_BASE = "https://www.certyos.com";
  const BATCH_SIZE = 200;

  const getInputData = () => {
    // Find all item fulfillments not yet sent to Certyo
    return search.create({
      type: search.Type.ITEM_FULFILLMENT,
      filters: [
        ["custbody_certyo_status", "isempty", ""],
        "AND",
        ["mainline", "is", "T"],
        "AND",
        ["status", "anyof", "C"], // Shipped
      ],
      columns: [
        search.createColumn({ name: "tranid" }),
        search.createColumn({ name: "trandate" }),
        search.createColumn({ name: "entity" }),
        search.createColumn({ name: "createdfrom" }),
        search.createColumn({ name: "shipstatus" }),
      ],
    });
  };

  const map = (context) => {
    const result = JSON.parse(context.value);
    const internalId = result.id;
    const tranId = result.values.tranid;

    // Load record to get line items
    const fulfillment = record.load({
      type: record.Type.ITEM_FULFILLMENT,
      id: internalId,
      isDynamic: false,
    });

    const lineCount = fulfillment.getLineCount({ sublistId: "item" });
    const items = [];
    for (let i = 0; i < lineCount; i++) {
      items.push({
        sku: fulfillment.getSublistValue({
          sublistId: "item",
          fieldId: "itemid",
          line: i,
        }),
        quantity: fulfillment.getSublistValue({
          sublistId: "item",
          fieldId: "quantity",
          line: i,
        }),
      });
    }

    const tranDate = result.values.trandate;

    const certYoRecord = {
      tenantId: runtime.getCurrentScript().getParameter({
        name: "custscript_certyo_tenant_id",
      }),
      database: "netsuite",
      collection: "item-fulfillment",
      recordId: tranId,
      operationType: "insert",
      sourceTimestamp: tranDate
        ? new Date(tranDate).toISOString()
        : new Date().toISOString(),
      idempotencyKey: tranId + "-v1",
      recordPayload: {
        fulfillmentNumber: tranId,
        internalId: internalId,
        customerInternalId: result.values.entity
          ? result.values.entity.value
          : "",
        salesOrderId: result.values.createdfrom
          ? result.values.createdfrom.value
          : "",
        items: items,
      },
    };

    // Group by batch key for reduce stage
    const batchKey = "batch-" + Math.floor(Math.random() * 5);
    context.write({ key: batchKey, value: JSON.stringify(certYoRecord) });
  };

  const reduce = (context) => {
    const certYoApiKey = runtime.getCurrentScript().getParameter({
      name: "custscript_certyo_api_key",
    });

    const records = context.values.map((v) => JSON.parse(v));

    // Send in chunks of 1000 via bulk endpoint
    for (let i = 0; i < records.length; i += 1000) {
      const batch = records.slice(i, i + 1000);

      const response = https.post({
        url: CERTYO_BASE + "/api/v1/records/bulk",
        headers: {
          "Content-Type": "application/json",
          "X-API-Key": certYoApiKey,
        },
        body: JSON.stringify(batch),
      });

      if (response.code === 202) {
        log.audit({
          title: "Certyo Bulk",
          details: "Ingested " + batch.length + " records.",
        });

        // Mark records as ingested
        batch.forEach((rec) => {
          try {
            record.submitFields({
              type: record.Type.ITEM_FULFILLMENT,
              id: rec.recordPayload.internalId,
              values: { custbody_certyo_status: "Ingested" },
              options: {
                enableSourcing: false,
                ignoreMandatoryFields: true,
              },
            });
          } catch (e) {
            log.error({
              title: "Write-back Failed",
              details: rec.recordPayload.internalId + ": " + e.message,
            });
          }
        });
      } else {
        log.error({
          title: "Certyo Bulk Error",
          details: "HTTP " + response.code,
        });
      }
    }
  };

  const summarize = (summary) => {
    log.audit({
      title: "Certyo Bulk Sync Complete",
      details:
        "Input: " +
        summary.inputSummary.error +
        ", Map errors: " +
        summary.mapSummary.errors.length +
        ", Reduce errors: " +
        summary.reduceSummary.errors.length,
    });
  };

  return { getInputData, map, reduce, summarize };
});
```

### Example 4: Python External Batch Sync

For external middleware or ETL processes that read from NetSuite and push to Certyo.

```python
"""
NetSuite to Certyo External Batch Sync
Uses NetSuite REST Web Services (SuiteQL) for querying and Certyo bulk API for ingestion.
"""

import os
import hmac
import hashlib
import base64
import time
import logging
from urllib.parse import quote

import requests
from requests_oauthlib import OAuth1

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# NetSuite TBA credentials
NS_ACCOUNT_ID = os.environ["NS_ACCOUNT_ID"]
NS_CONSUMER_KEY = os.environ["NS_CONSUMER_KEY"]
NS_CONSUMER_SECRET = os.environ["NS_CONSUMER_SECRET"]
NS_TOKEN_ID = os.environ["NS_TOKEN_ID"]
NS_TOKEN_SECRET = os.environ["NS_TOKEN_SECRET"]

# Certyo credentials
CERTYO_BASE = "https://www.certyos.com"
CERTYO_API_KEY = os.environ["CERTYO_API_KEY"]
CERTYO_TENANT_ID = os.environ["CERTYO_TENANT_ID"]

NS_BASE = f"https://{NS_ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest"


def get_netsuite_auth() -> OAuth1:
    return OAuth1(
        client_key=NS_CONSUMER_KEY,
        client_secret=NS_CONSUMER_SECRET,
        resource_owner_key=NS_TOKEN_ID,
        resource_owner_secret=NS_TOKEN_SECRET,
        realm=NS_ACCOUNT_ID,
        signature_method="HMAC-SHA256",
    )


def query_unfulfilled(last_date: str) -> list:
    """Query NetSuite for item fulfillments not yet sent to Certyo using SuiteQL."""
    query = f"""
        SELECT
            t.id AS internalId,
            t.tranid,
            t.trandate,
            t.entity AS customerInternalId,
            t.createdfrom AS salesOrderInternalId,
            t.shipstatus
        FROM transaction t
        WHERE t.type = 'ItemShip'
          AND t.trandate >= '{last_date}'
          AND (t.custbody_certyo_status IS NULL OR t.custbody_certyo_status = '')
        ORDER BY t.trandate ASC
        FETCH FIRST 1000 ROWS ONLY
    """

    resp = requests.post(
        f"{NS_BASE}/query/v1/suiteql",
        json={"q": query},
        headers={"Content-Type": "application/json", "Prefer": "transient"},
        auth=get_netsuite_auth(),
    )
    resp.raise_for_status()
    return resp.json().get("items", [])


def send_to_certyo(records: list) -> None:
    headers = {"Content-Type": "application/json", "X-API-Key": CERTYO_API_KEY}

    for i in range(0, len(records), 1000):
        batch = records[i: i + 1000]
        endpoint = "/api/v1/records/bulk" if len(batch) > 1 else "/api/v1/records"
        payload = batch if len(batch) > 1 else batch[0]

        resp = requests.post(
            f"{CERTYO_BASE}{endpoint}",
            json=payload,
            headers=headers,
            timeout=30,
        )
        resp.raise_for_status()
        logger.info("Ingested %d records into Certyo.", len(batch))


def main():
    last_date = "2026-01-01"  # Start date, advance as processed

    fulfillments = query_unfulfilled(last_date)
    logger.info("Found %d fulfillments to sync.", len(fulfillments))

    certyo_records = []
    for f in fulfillments:
        certyo_records.append({
            "tenantId": CERTYO_TENANT_ID,
            "database": "netsuite",
            "collection": "item-fulfillment",
            "recordId": f["tranid"],
            "operationType": "insert",
            "sourceTimestamp": f"{f['trandate']}T00:00:00Z",
            "idempotencyKey": f"{f['tranid']}-v1",
            "recordPayload": {
                "fulfillmentNumber": f["tranid"],
                "internalId": f["internalId"],
                "customerInternalId": f.get("customerInternalId", ""),
                "salesOrderInternalId": f.get("salesOrderInternalId", ""),
                "shipStatus": f.get("shipstatus", ""),
            },
        })

    send_to_certyo(certyo_records)


if __name__ == "__main__":
    main()
```

## Verification and Write-back

After Certyo anchors records (typically 60-90 seconds), poll for status and write results back to NetSuite custom fields.

### Custom Fields Required

Create the following custom body fields on the Item Fulfillment record:

| Field ID | Label | Type |
|---|---|---|
| `custbody_certyo_record_hash` | Certyo Record Hash | Free-Form Text (64 chars) |
| `custbody_certyo_status` | Certyo Status | Free-Form Text (20 chars) |
| `custbody_certyo_anchored_at` | Certyo Anchored At | Date/Time |

### Scheduled Script for Verification Polling

```javascript
/**
 * @NApiVersion 2.1
 * @NScriptType ScheduledScript
 * @NModuleScope SameAccount
 */
define(["N/search", "N/https", "N/record", "N/runtime", "N/log"], (
  search,
  https,
  record,
  runtime,
  log
) => {
  const CERTYO_BASE = "https://www.certyos.com";

  const execute = (context) => {
    const certYoApiKey = runtime.getCurrentScript().getParameter({
      name: "custscript_certyo_api_key",
    });
    const certYoTenantId = runtime.getCurrentScript().getParameter({
      name: "custscript_certyo_tenant_id",
    });

    // Find fulfillments that are "Ingested" but not yet "Anchored"
    const results = search
      .create({
        type: search.Type.ITEM_FULFILLMENT,
        filters: [
          ["custbody_certyo_status", "is", "Ingested"],
          "AND",
          ["mainline", "is", "T"],
        ],
        columns: [
          search.createColumn({ name: "tranid" }),
          search.createColumn({ name: "custbody_certyo_record_hash" }),
        ],
      })
      .run()
      .getRange({ start: 0, end: 100 });

    results.forEach((result) => {
      const tranId = result.getValue({ name: "tranid" });

      // Verify with Certyo
      const verifyResp = https.post({
        url: CERTYO_BASE + "/api/v1/verify/record",
        headers: {
          "Content-Type": "application/json",
          "X-API-Key": certYoApiKey,
        },
        body: JSON.stringify({
          tenantId: certYoTenantId,
          database: "netsuite",
          collection: "item-fulfillment",
          recordId: tranId,
        }),
      });

      if (verifyResp.code === 200) {
        const verifyBody = JSON.parse(verifyResp.body);

        if (verifyBody.verified) {
          record.submitFields({
            type: record.Type.ITEM_FULFILLMENT,
            id: result.id,
            values: {
              custbody_certyo_status: "Anchored",
              custbody_certyo_anchored_at: new Date(),
            },
            options: { enableSourcing: false, ignoreMandatoryFields: true },
          });

          log.audit({
            title: "Certyo Verified",
            details: tranId + " is anchored on-chain.",
          });
        }
      }
    });
  };

  return { execute };
});
```

## Code Generation Rules

1. **Use `afterSubmit`, never `beforeSubmit`.** The `beforeSubmit` event runs before the record is committed to the database. If Certyo ingestion fails in `beforeSubmit`, it blocks the transaction save. Always use `afterSubmit` and handle failures gracefully without throwing.

2. **Be governance-aware.** Every `N/https.post`, `N/record.load`, `N/search` call, and `N/record.submitFields` call consumes governance units. User Event scripts have 5,000 units. Check `runtime.getCurrentScript().getRemainingUsage()` in loops and exit gracefully when low.

3. **Use SuiteQL over saved searches for external queries.** SuiteQL (via `/services/rest/query/v1/suiteql`) is more flexible and performant than saved searches for bulk data extraction. It supports SQL-like syntax and pagination.

4. **Always set `idempotencyKey` to `tranid + "-v" + version`.** NetSuite can trigger `afterSubmit` multiple times (e.g., on edit). The idempotency key prevents duplicate Certyo records. Increment the version on edits.

5. **Use Map/Reduce for bulk operations.** The RESTlet concurrent execution limit is 10 per account. For backfilling or large batch operations, use Map/Reduce scripts which support parallel processing across up to 5 reduce stages.

6. **Store credentials in script parameters or custom records.** Never hardcode the Certyo API key in script files. Use Script Parameter fields on the deployment record, or a custom record with restricted access (role-based).

7. **Use the bulk endpoint when processing more than 10 records.** In Map/Reduce reduce stages or external batch syncs, accumulate records and call `POST /api/v1/records/bulk` with up to 1,000 records per call.

8. **Handle NetSuite date formats carefully.** `N/record.getValue` returns JavaScript Date objects for date fields. Convert them with `.toISOString()` for the Certyo `sourceTimestamp`. In SuiteQL, dates come as `YYYY-MM-DD` strings; append `T00:00:00Z`.

9. **Write back immediately on single-record ingestion.** In the `afterSubmit` User Event Script, call `record.submitFields` right after a successful 202 response to store the `recordHash`. For bulk, defer write-back to a separate scheduled script.

10. **Do not call Certyo from client scripts.** SuiteScript Client Scripts run in the browser and cannot make cross-origin HTTP calls to Certyo. All Certyo integration must happen server-side (User Event, Scheduled, Map/Reduce, or RESTlet).
