Integración con Oracle NetSuite
Conecta Oracle NetSuite a Certyo usando RESTlets, User Event Scripts y la API REST de SuiteTalk. Ancla automáticamente datos de cumplimiento de artículos, movimientos de inventario y registros de órdenes de venta en la blockchain.
Requisitos previos
- Cuenta de Oracle NetSuite con SuiteCloud habilitado (rol de Administrador o Desarrollador)
- Token-Based Authentication (TBA) configurada en Setup > Company > Enable Features > SuiteCloud > Manage Authentication
- Una clave API de Certyo — consulta la Guía de autenticación
- Runtime SuiteScript 2.x (NetSuite 2020.1+)
Arquitectura
┌─────────────────────────┐
│ Oracle NetSuite │
│ │
│ Item Fulfillment │
│ record saved │
│ │ │
│ v │
│ User Event Script │
│ (afterSubmit) │
│ │ │
│ v │
│ RESTlet / N/https │──────────┐
│ module │ │
└─────────────────────────┘ │
│ HTTPS POST
│ X-API-Key header
v
┌─────────────────────┐
│ Certyo API │
│ POST /api/v1/records│
│ │
│ 202 Accepted │
└──────────┬───────────┘
│
v
┌─────────────────────┐
│ Pipeline │
│ Kafka -> Accumulate │
│ -> Merkle Tree │
│ -> IPFS -> Polygon │
└─────────────────────┘Configuración de autenticación
NetSuite usa Token-Based Authentication (TBA) para acceso servidor a servidor. Necesitas cuatro credenciales de Setup > Integration > Manage Integrations:
Consumer Key/Consumer Secret— Consumer Key y Consumer Secret (del registro de integración)Token ID/Token Secret— Token ID y Token Secret (del token de acceso asignado a un rol)
Para el lado de Certyo, almacena tu clave API como un parámetro de script o en un registro de configuración personalizado — nunca la codifiques directamente en archivos fuente de SuiteScript.
Mapeo de campos
| Campo de NetSuite | Campo de Certyo | Notas |
|---|---|---|
tranId (Item Fulfillment) | recordId | Número único de cumplimiento, por ejemplo IF-10042 |
subsidiary.name | tenantId | Se mapea a tu tenant de Certyo |
"netsuite" | database | Valor estático que identifica el sistema de origen |
"item_fulfillments" | collection | Tipo de transacción como nombre de colección |
tranDate | sourceTimestamp | Convertir a ISO 8601 (por ejemplo 2026-04-14T00:00:00Z) |
item[].itemId, quantity, serialNumbers | recordPayload | Detalles de las líneas de cumplimiento |
createdDate + tranId | idempotencyKey | Previene ingesta duplicada en reintentos |
"1" | recordVersion | Incrementar en actualizaciones posteriores |
"insert" | operationType | Usar "update" para cumplimientos modificados |
Implementación
Despliega este RESTlet para recibir datos de cumplimiento de sistemas externos o llamadas internas de SuiteScript, y reenviarlos a Certyo.
/**
* @NApiVersion 2.1
* @NScriptType Restlet
* @NModuleScope SameAccount
*
* Certyo Integration RESTlet
* Accepts item fulfillment data and ingests it into Certyo for
* blockchain anchoring.
*
* Deploy: Setup > Scripting > Scripts > New > RESTlet
* URL: /app/site/hosting/restlet.nl?script=<id>&deploy=1
*/
define(['N/https', 'N/log', 'N/runtime'], function (https, log, runtime) {
var CERTYO_API_URL = 'https://www.certyos.com/api/v1/records';
/**
* POST handler — accepts a fulfillment payload and sends it to Certyo.
* @param {Object} requestBody - The fulfillment data from the caller
* @returns {Object} Certyo API response or error details
*/
function post(requestBody) {
var scriptObj = runtime.getCurrentScript();
var apiKey = scriptObj.getParameter({ name: 'custscript_certyo_api_key' });
if (!apiKey) {
log.error('Configuration Error', 'custscript_certyo_api_key parameter is not set');
return { success: false, error: 'API key not configured' };
}
try {
var certRecord = {
tenantId: requestBody.subsidiary || 'default',
database: 'netsuite',
collection: 'item_fulfillments',
recordId: requestBody.tranId,
recordVersion: String(requestBody.version || '1'),
operationType: requestBody.operationType || 'insert',
recordPayload: {
tranId: requestBody.tranId,
entity: requestBody.entity,
entityName: requestBody.entityName,
tranDate: requestBody.tranDate,
shipMethod: requestBody.shipMethod,
trackingNumbers: requestBody.trackingNumbers || [],
items: requestBody.items || [],
memo: requestBody.memo || '',
location: requestBody.location,
createdFrom: requestBody.createdFrom
},
sourceTimestamp: requestBody.tranDate
? new Date(requestBody.tranDate).toISOString()
: new Date().toISOString(),
idempotencyKey: requestBody.tranId + '-v' +
(requestBody.version || '1') + '-' +
new Date().toISOString().substring(0, 10)
};
log.audit('Certyo Ingestion', 'Sending record: ' + certRecord.recordId);
var response = https.post({
url: CERTYO_API_URL,
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(certRecord)
});
var statusCode = response.code;
var responseBody = JSON.parse(response.body);
if (statusCode === 202) {
log.audit('Certyo Ingestion Success', JSON.stringify({
recordId: responseBody.recordId,
recordHash: responseBody.recordHash,
acceptedAt: responseBody.acceptedAt
}));
return {
success: true,
recordHash: responseBody.recordHash,
acceptedAt: responseBody.acceptedAt
};
} else {
log.error('Certyo Ingestion Failed', 'HTTP ' + statusCode +
': ' + response.body);
return {
success: false,
statusCode: statusCode,
error: responseBody.message || response.body
};
}
} catch (e) {
log.error('Certyo Ingestion Error', e.message + '\n' + e.stack);
return { success: false, error: e.message };
}
}
return { post: post };
});Este User Event Script se ejecuta en cada guardado de Item Fulfillment, extrae los datos de cumplimiento y llama a Certyo directamente vía el módulo N/https. Despliégalo en el tipo de registro Item Fulfillment con el punto de entrada After Submit.
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* @NModuleScope SameAccount
*
* Certyo User Event — Item Fulfillment
* Triggers on afterSubmit to ingest fulfillment records into Certyo.
*
* Deploy: Setup > Scripting > Script Deployments
* Record Type: Item Fulfillment
* Event Type: After Submit
* Status: Released
*/
define(['N/https', 'N/record', 'N/log', 'N/runtime', 'N/search'],
function (https, record, log, runtime, search) {
var CERTYO_API_URL = 'https://www.certyos.com/api/v1/records';
/**
* afterSubmit — runs after the Item Fulfillment is committed to the DB.
* @param {Object} context
* @param {string} context.type - create | edit | delete | xedit
* @param {Record} context.newRecord - The saved record
*/
function afterSubmit(context) {
// Only fire on create or edit
if (context.type !== context.UserEventType.CREATE &&
context.type !== context.UserEventType.EDIT) {
return;
}
var scriptObj = runtime.getCurrentScript();
var apiKey = scriptObj.getParameter({ name: 'custscript_certyo_api_key' });
if (!apiKey) {
log.error('Certyo Config', 'API key parameter not set — skipping ingestion');
return;
}
try {
var rec = context.newRecord;
var fulfillmentId = rec.getValue({ fieldId: 'tranid' });
// Extract subsidiary name for tenant mapping
var subsidiaryId = rec.getValue({ fieldId: 'subsidiary' });
var subsidiaryName = 'default';
if (subsidiaryId) {
var subLookup = search.lookupFields({
type: search.Type.SUBSIDIARY,
id: subsidiaryId,
columns: ['name']
});
subsidiaryName = subLookup.name || 'default';
}
// Gather line items
var lineCount = rec.getLineCount({ sublistId: 'item' });
var items = [];
for (var i = 0; i < lineCount; i++) {
items.push({
itemId: rec.getSublistValue({ sublistId: 'item', fieldId: 'item', line: i }),
itemName: rec.getSublistText({ sublistId: 'item', fieldId: 'item', line: i }),
quantity: rec.getSublistValue({ sublistId: 'item', fieldId: 'quantity', line: i }),
serialNumbers: rec.getSublistValue({ sublistId: 'item', fieldId: 'serialnumbers', line: i }) || '',
lotNumbers: rec.getSublistValue({ sublistId: 'item', fieldId: 'binitem', line: i }) || '',
location: rec.getSublistText({ sublistId: 'item', fieldId: 'location', line: i })
});
}
var certRecord = {
tenantId: subsidiaryName.toLowerCase().replace(/\s+/g, '-'),
database: 'netsuite',
collection: 'item_fulfillments',
recordId: fulfillmentId,
recordVersion: context.type === context.UserEventType.CREATE ? '1' : '2',
operationType: context.type === context.UserEventType.CREATE ? 'insert' : 'update',
recordPayload: {
fulfillmentId: fulfillmentId,
entity: rec.getValue({ fieldId: 'entity' }),
entityName: rec.getText({ fieldId: 'entity' }),
tranDate: rec.getValue({ fieldId: 'trandate' }),
status: rec.getValue({ fieldId: 'shipstatus' }),
shipMethod: rec.getText({ fieldId: 'shipmethod' }),
trackingNumbers: (rec.getValue({ fieldId: 'linkedtrackingnumbers' }) || '').split(','),
createdFrom: rec.getValue({ fieldId: 'createdfrom' }),
memo: rec.getValue({ fieldId: 'memo' }) || '',
items: items
},
sourceTimestamp: new Date(rec.getValue({ fieldId: 'trandate' })).toISOString(),
idempotencyKey: fulfillmentId + '-' +
(context.type === context.UserEventType.CREATE ? 'v1' : 'v2') + '-' +
new Date().toISOString().substring(0, 10)
};
log.audit('Certyo Ingestion', 'Sending ' + fulfillmentId + ' (' + items.length + ' lines)');
var response = https.post({
url: CERTYO_API_URL,
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(certRecord)
});
if (response.code === 202) {
var body = JSON.parse(response.body);
log.audit('Certyo Accepted', JSON.stringify({
recordId: body.recordId,
recordHash: body.recordHash
}));
// Store the record hash on a custom field for later verification
record.submitFields({
type: record.Type.ITEM_FULFILLMENT,
id: rec.id,
values: {
'custbody_certyo_record_hash': body.recordHash,
'custbody_certyo_status': 'Pending'
},
options: { enableSourcing: false, ignoreMandatoryFields: true }
});
} else {
log.error('Certyo Error', 'HTTP ' + response.code + ': ' + response.body);
}
} catch (e) {
// Log but don't throw — we don't want to block the fulfillment save
log.error('Certyo Integration Error', e.message + '\n' + e.stack);
}
}
return { afterSubmit: afterSubmit };
});Para carga inicial de datos o sincronización por lotes programada, usa un script Python externo que consulta NetSuite vía la API REST de SuiteTalk (o SuiteQL) y envía registros a Certyo de forma masiva.
"""
NetSuite -> Certyo batch synchronization script.
Uses NetSuite SuiteQL (REST API) to query recent Item Fulfillments,
then pushes them to Certyo's bulk ingestion endpoint.
Requirements:
pip install requests requests_oauthlib
Environment variables:
NETSUITE_ACCOUNT_ID - e.g. "1234567" or "1234567_SB1" for sandbox
NETSUITE_CONSUMER_KEY
NETSUITE_CONSUMER_SECRET
NETSUITE_TOKEN_ID
NETSUITE_TOKEN_SECRET
CERTYO_API_KEY
CERTYO_TENANT_ID
"""
import os
import json
import time
import logging
from datetime import datetime, timedelta, timezone
import requests
from requests_oauthlib import OAuth1
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
# --- NetSuite configuration ---
NS_ACCOUNT_ID = os.environ["NETSUITE_ACCOUNT_ID"]
NS_BASE_URL = f"https://{NS_ACCOUNT_ID}.suitetalk.api.netsuite.com/services/rest"
ns_auth = OAuth1(
client_key=os.environ["NETSUITE_CONSUMER_KEY"],
client_secret=os.environ["NETSUITE_CONSUMER_SECRET"],
resource_owner_key=os.environ["NETSUITE_TOKEN_ID"],
resource_owner_secret=os.environ["NETSUITE_TOKEN_SECRET"],
realm=NS_ACCOUNT_ID,
signature_method="HMAC-SHA256",
)
# --- Certyo configuration ---
CERTYO_API_URL = "https://www.certyos.com/api/v1/records/bulk"
CERTYO_API_KEY = os.environ["CERTYO_API_KEY"]
CERTYO_TENANT_ID = os.environ["CERTYO_TENANT_ID"]
def query_netsuite_fulfillments(since_hours: int = 24) -> list[dict]:
"""Query recent Item Fulfillments via SuiteQL."""
since = (datetime.now(timezone.utc) - timedelta(hours=since_hours)).strftime(
"%m/%d/%Y %H:%M:%S"
)
suiteql = f"""
SELECT
t.tranId,
t.tranDate,
t.entity,
BUILTIN.DF(t.entity) AS entityName,
BUILTIN.DF(t.shipMethod) AS shipMethodName,
t.linkedTrackingNumbers,
t.memo,
t.lastModifiedDate,
tl.item,
BUILTIN.DF(tl.item) AS itemName,
tl.quantity,
tl.serialNumbers
FROM
transaction t
JOIN transactionLine tl ON t.id = tl.transaction
WHERE
t.type = 'ItemShip'
AND t.lastModifiedDate >= TO_DATE('{since}', 'MM/DD/YYYY HH24:MI:SS')
ORDER BY
t.tranId, tl.lineSequenceNumber
"""
logger.info("Querying NetSuite fulfillments since %s", since)
response = requests.post(
f"{NS_BASE_URL}/query/v1/suiteql",
auth=ns_auth,
headers={
"Content-Type": "application/json",
"Prefer": "transient",
},
json={"q": suiteql.strip()},
)
response.raise_for_status()
data = response.json()
items = data.get("items", [])
logger.info("Retrieved %d fulfillment lines from NetSuite", len(items))
return items
def group_by_fulfillment(lines: list[dict]) -> dict[str, dict]:
"""Group line items by fulfillment tranId."""
fulfillments: dict[str, dict] = {}
for line in lines:
tran_id = line["tranId"]
if tran_id not in fulfillments:
fulfillments[tran_id] = {
"tranId": tran_id,
"tranDate": line["tranDate"],
"entity": line["entity"],
"entityName": line["entityName"],
"shipMethod": line.get("shipMethodName", ""),
"trackingNumbers": line.get("linkedTrackingNumbers", ""),
"memo": line.get("memo", ""),
"lastModified": line["lastModifiedDate"],
"items": [],
}
fulfillments[tran_id]["items"].append(
{
"itemId": str(line["item"]),
"itemName": line["itemName"],
"quantity": float(line["quantity"]),
"serialNumbers": line.get("serialNumbers", ""),
}
)
return fulfillments
def build_certyo_records(fulfillments: dict[str, dict]) -> list[dict]:
"""Transform NetSuite fulfillments into Certyo record format."""
records = []
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
for tran_id, ful in fulfillments.items():
records.append(
{
"database": "netsuite",
"collection": "item_fulfillments",
"recordId": tran_id,
"recordVersion": "1",
"operationType": "upsert",
"recordPayload": {
"tranId": ful["tranId"],
"entity": ful["entity"],
"entityName": ful["entityName"],
"tranDate": ful["tranDate"],
"shipMethod": ful["shipMethod"],
"trackingNumbers": ful["trackingNumbers"],
"memo": ful["memo"],
"items": ful["items"],
},
"sourceTimestamp": ful["lastModified"],
"idempotencyKey": f"{tran_id}-sync-{today}",
}
)
return records
def ingest_to_certyo(records: list[dict]) -> None:
"""Send records to Certyo in batches of up to 1000."""
batch_size = 1000
for i in range(0, len(records), batch_size):
batch = records[i : i + batch_size]
logger.info(
"Sending batch %d-%d (%d records) to Certyo",
i + 1,
i + len(batch),
len(batch),
)
response = requests.post(
CERTYO_API_URL,
headers={
"X-API-Key": CERTYO_API_KEY,
"Content-Type": "application/json",
},
json={
"tenantId": CERTYO_TENANT_ID,
"records": batch,
},
)
if response.status_code == 202:
result = response.json()
logger.info(
"Batch accepted: %d records ingested", len(batch)
)
else:
logger.error(
"Batch failed: HTTP %d — %s", response.status_code, response.text
)
response.raise_for_status()
# Respect rate limits between batches
if i + batch_size < len(records):
time.sleep(1)
def main():
lines = query_netsuite_fulfillments(since_hours=24)
if not lines:
logger.info("No new fulfillments found — nothing to sync")
return
fulfillments = group_by_fulfillment(lines)
logger.info("Grouped into %d unique fulfillments", len(fulfillments))
records = build_certyo_records(fulfillments)
ingest_to_certyo(records)
logger.info("Sync complete: %d records sent to Certyo", len(records))
if __name__ == "__main__":
main()Verificación: Script programado
Después de que los registros son ingestados, tardan aproximadamente 60-90 segundos en ser anclados en Polygon. Despliega un Scheduled Script que se ejecute cada 15 minutos para consultar el estado de anclaje en Certyo y actualizar un campo personalizado en el registro Item Fulfillment.
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
* @NModuleScope SameAccount
*
* Certyo Verification Poller
* Runs on a schedule to check anchoring status of pending records
* and update the Item Fulfillment with verification results.
*
* Deploy: Setup > Scripting > Script Deployments
* Schedule: Every 15 minutes
*/
define(['N/https', 'N/search', 'N/record', 'N/log', 'N/runtime'],
function (https, search, record, log, runtime) {
var CERTYO_VERIFY_URL = 'https://www.certyos.com/api/v1/verify/record';
var CERTYO_QUERY_URL = 'https://www.certyos.com/api/v1/records';
function execute(context) {
var scriptObj = runtime.getCurrentScript();
var apiKey = scriptObj.getParameter({ name: 'custscript_certyo_api_key' });
var tenantId = scriptObj.getParameter({ name: 'custscript_certyo_tenant_id' });
// Find fulfillments with Pending Certyo status
var pendingSearch = search.create({
type: search.Type.ITEM_FULFILLMENT,
filters: [
['custbody_certyo_status', 'is', 'Pending'],
'AND',
['custbody_certyo_record_hash', 'isnotempty', ''],
'AND',
['mainline', 'is', 'T']
],
columns: [
search.createColumn({ name: 'tranid' }),
search.createColumn({ name: 'custbody_certyo_record_hash' })
]
});
var results = [];
pendingSearch.run().each(function (result) {
results.push({
internalId: result.id,
tranId: result.getValue('tranid'),
recordHash: result.getValue('custbody_certyo_record_hash')
});
return results.length < 200; // Process up to 200 per run
});
log.audit('Certyo Verify', 'Found ' + results.length + ' pending records');
for (var i = 0; i < results.length; i++) {
if (runtime.getCurrentScript().getRemainingUsage() < 100) {
log.audit('Certyo Verify', 'Low governance — stopping at record ' + i);
break;
}
var item = results[i];
try {
// Query record status
var queryResp = https.get({
url: CERTYO_QUERY_URL +
'?tenantId=' + encodeURIComponent(tenantId) +
'&recordId=' + encodeURIComponent(item.tranId),
headers: { 'X-API-Key': apiKey }
});
if (queryResp.code !== 200) continue;
var queryData = JSON.parse(queryResp.body);
var snapshot = (queryData.items || [])[0];
if (!snapshot || snapshot.status !== 'Anchored') continue;
// Record is anchored — update the fulfillment
record.submitFields({
type: record.Type.ITEM_FULFILLMENT,
id: item.internalId,
values: {
'custbody_certyo_status': 'Anchored',
'custbody_certyo_polygon_tx': snapshot.polygonTxHash || '',
'custbody_certyo_verified_at': new Date().toISOString()
},
options: { enableSourcing: false, ignoreMandatoryFields: true }
});
log.audit('Certyo Verified', item.tranId + ' -> Anchored');
} catch (e) {
log.error('Certyo Verify Error', item.tranId + ': ' + e.message);
}
}
}
return { execute: execute };
});Configuración de campos personalizados
Crea estos campos personalizados en el tipo de registro Item Fulfillment vía Customization > Lists, Records, & Fields > Transaction Body Fields:
custbody_certyo_record_hash— Free-form Text, solo almacenamiento. Contiene el hash SHA-256 retornado por Certyo en la ingesta.custbody_certyo_status— List/Record (lista personalizada con valores: Pending, Anchored, Failed). Se muestra en el formulario de cumplimiento.custbody_certyo_polygon_tx— Free-form Text, solo almacenamiento. El hash de transacción en Polygon una vez anclado.custbody_certyo_verified_at— Date/Time, solo almacenamiento. Marca de tiempo de la última verificación exitosa.
Parámetros de script
Agrega un parámetro de script a cada despliegue de SuiteScript para almacenar de forma segura la clave API de Certyo:
- ID:
custscript_certyo_api_key - Type: Password (o Free-Form Text si Password no está disponible)
- Description: Clave API de Certyo para anclaje de registros en blockchain
Para el Scheduled Script, también agrega:
- ID:
custscript_certyo_tenant_id - Type: Free-Form Text
- Description: Identificador de tenant de Certyo para consultas al API
AI Integration Skill
Download a skill file that enables AI agents to generate working Oracle NetSuite + Certyo integration code for any language or framework.
What's inside
- Authentication — Token-Based Auth (TBA) y OAuth 2.0 para acceso al API de NetSuite
- Architecture — afterSubmit de Item Fulfillment → módulo N/https → API de Certyo
- Field mapping — Campos de tranid, trandate y artículo de cumplimiento al esquema de registro Certyo
- Code examples — RESTlet SuiteScript 2.x, User Event Script, sincronización por lotes en Python con SuiteQL
- Verification — Scheduled Script con polling y procesamiento consciente de gobernanza
- Concurrency — Límite de RESTlet de 10 y patrones Map/Reduce para operaciones masivas
How to use
Claude Code
Place the file in your project's .claude/commands/ directory, then use it as a slash command:
# Download the skill file
mkdir -p .claude/commands
curl -o .claude/commands/certyo-netsuite.md \
https://www.certyos.com/developers/skills/certyo-netsuite-skill.md
# Use it in Claude Code
/certyo-netsuite "Genera un User Event de SuiteScript que envíe artículos cumplidos a Certyo"Cursor / Copilot / Any AI Agent
Add the file to your project root or attach it to a conversation. The AI agent will use the Oracle NetSuite-specific patterns, field mappings, and code examples to generate correct integration code.
# Add to your project
curl -o CERTYO_NETSUITE.md \
https://www.certyos.com/developers/skills/certyo-netsuite-skill.md
# Then in your AI agent:
"Using the Certyo Oracle NetSuite spec in CERTYO_NETSUITE.md,
genera un user event de suitescript que envíe artículos cumplidos a certyo"CLAUDE.md Context File
Append the skill file to your project's CLAUDE.md so every Claude conversation has Oracle NetSuite + Certyo context automatically.
# Append to your project's CLAUDE.md
echo "" >> CLAUDE.md
echo "## Certyo Oracle NetSuite Integration" >> CLAUDE.md
cat CERTYO_NETSUITE.md >> CLAUDE.md