Import / bulk
Ingest many products at once with row-isolated errors and a correction loop. Concept and lifecycle: Import jobs.
| POST | /api/v2/import/jobsCreate an import job |
| GET | /api/v2/import/jobs/{jobId}Import job status |
| GET | /api/v2/import/jobs/{jobId}/errorsRow-level errors |
| GET | /api/v2/import/jobs/{jobId}/correction-templateCorrection template |
| POST | /api/v2/import/jobs/{jobId}/confirmConfirm an import job |
| POST | /api/v2/ingest/productsMachine batch on-ramp |
| GET | /api/v2/ingest/{jobId}Machine on-ramp job status |
| POST | /api/v2/import/aasxAASX package import |
| POST | /api/v2/importSingle-shot CSV import |
| GET | /api/v2/import/templates/{regulationCode}Per-regulation template |
Multipart CSV (UTF-8, ≤ 10 MB, up to 10,000 rows, magic-byte validated) → 202 { jobId }. Row failures are isolated, one bad row does not fail the batch.
POST {{baseUrl}}/api/v2/import/jobs
Authorization: Bearer {{apiKey}}
Content-Type: application/jsoncurl -X POST "$BASE_URL/api/v2/import/jobs" \
-H "Authorization: Bearer $NORRUVA_API_KEY" \
-H "Content-Type: application/json"const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const res = await fetch(`${baseUrl}/api/v2/import/jobs`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": `application/json`
}
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
resp = requests.post(
f"{base_url}/api/v2/import/jobs",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": f"application/json"
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
url := fmt.Sprintf("%s/api/v2/import/jobs", baseURL)
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Authorization", "Bearer "+apiKey+"")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}202 Accepted → { jobId }. Re-submitting the same file is a no-op. A tenant may hold at most 3 ACTIVE (unconfirmed) jobs, the 4th is 400 "You have 3 active import jobs". Always call …/confirm; an unconfirmed job stays active, never expires, and wedges the next upload.
Status / preview for a job.
GET {{baseUrl}}/api/v2/import/jobs/{jobId}
Authorization: Bearer {{apiKey}}curl -X GET "$BASE_URL/api/v2/import/jobs/{jobId}" \
-H "Authorization: Bearer $NORRUVA_API_KEY"const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const jobId = "…";
const res = await fetch(`${baseUrl}/api/v2/import/jobs/${jobId}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${apiKey}`
}
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
job_id = "…"
resp = requests.get(
f"{base_url}/api/v2/import/jobs/{job_id}",
headers={
"Authorization": f"Bearer {api_key}"
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
jobId := "…"
url := fmt.Sprintf("%s/api/v2/import/jobs/%s", baseURL, jobId)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+apiKey+"")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}Row-level failures, isolated and queryable.
GET {{baseUrl}}/api/v2/import/jobs/{jobId}/errors
Authorization: Bearer {{apiKey}}curl -X GET "$BASE_URL/api/v2/import/jobs/{jobId}/errors" \
-H "Authorization: Bearer $NORRUVA_API_KEY"const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const jobId = "…";
const res = await fetch(`${baseUrl}/api/v2/import/jobs/${jobId}/errors`, {
method: "GET",
headers: {
"Authorization": `Bearer ${apiKey}`
}
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
job_id = "…"
resp = requests.get(
f"{base_url}/api/v2/import/jobs/{job_id}/errors",
headers={
"Authorization": f"Bearer {api_key}"
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
jobId := "…"
url := fmt.Sprintf("%s/api/v2/import/jobs/%s/errors", baseURL, jobId)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+apiKey+"")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}Fixable re-upload template generated from the job's failures. Two preconditions, both 400 when unmet: the job must HAVE failures (call it only when GET …/errors is non-empty, and row failures only exist after …/confirm has run), and the job must carry a regulationCode, which is set only if you passed that multipart field at upload time.
GET {{baseUrl}}/api/v2/import/jobs/{jobId}/correction-template
Authorization: Bearer {{apiKey}}curl -X GET "$BASE_URL/api/v2/import/jobs/{jobId}/correction-template" \
-H "Authorization: Bearer $NORRUVA_API_KEY"const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const jobId = "…";
const res = await fetch(`${baseUrl}/api/v2/import/jobs/${jobId}/correction-template`, {
method: "GET",
headers: {
"Authorization": `Bearer ${apiKey}`
}
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
job_id = "…"
resp = requests.get(
f"{base_url}/api/v2/import/jobs/{job_id}/correction-template",
headers={
"Authorization": f"Bearer {api_key}"
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
jobId := "…"
url := fmt.Sprintf("%s/api/v2/import/jobs/%s/correction-template", baseURL, jobId)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+apiKey+"")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}Apply the job, drives it terminal. Always call this.
POST {{baseUrl}}/api/v2/import/jobs/{jobId}/confirm
Authorization: Bearer {{apiKey}}
Content-Type: application/jsoncurl -X POST "$BASE_URL/api/v2/import/jobs/{jobId}/confirm" \
-H "Authorization: Bearer $NORRUVA_API_KEY" \
-H "Content-Type: application/json"const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const jobId = "…";
const res = await fetch(`${baseUrl}/api/v2/import/jobs/${jobId}/confirm`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": `application/json`
}
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
job_id = "…"
resp = requests.post(
f"{base_url}/api/v2/import/jobs/{job_id}/confirm",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": f"application/json"
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
jobId := "…"
url := fmt.Sprintf("%s/api/v2/import/jobs/%s/confirm", baseURL, jobId)
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Authorization", "Bearer "+apiKey+"")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}The JSON batch path ERP-style integrations use, no multipart. Strict ERP-flavoured schema (unknown keys rejected); referenceId is the per-row idempotency key, so re-submitting the same rows never duplicates products.
POST {{baseUrl}}/api/v2/ingest/products
Authorization: Bearer {{apiKey}}
Content-Type: application/json
{
"sourceSystem": "tsc-middleware",
"products": [{
"referenceId": "erp-4711",
"name": "EcoCell Battery Pack",
"description": "…", "gtin": "…",
"category": "Electronics",
"materials": [{ "name": "lithium", "percentage": 5.2 }],
"manufacturing": { "facility": "Gigafactory 1", "country": "DE" },
"customData": { "line": "A" }
}],
"callbackUrl": "https://integrator.example/hooks/ingest"
}curl -X POST "$BASE_URL/api/v2/ingest/products" \
-H "Authorization: Bearer $NORRUVA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sourceSystem": "tsc-middleware",
"products": [
{
"referenceId": "erp-4711",
"name": "EcoCell Battery Pack",
"description": "…",
"gtin": "…",
"category": "Electronics",
"materials": [
{
"name": "lithium",
"percentage": 5.2
}
],
"manufacturing": {
"facility": "Gigafactory 1",
"country": "DE"
},
"customData": {
"line": "A"
}
}
],
"callbackUrl": "https://integrator.example/hooks/ingest"
}'const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const res = await fetch(`${baseUrl}/api/v2/ingest/products`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": `application/json`
},
body: JSON.stringify({
"sourceSystem": "tsc-middleware",
"products": [
{
"referenceId": "erp-4711",
"name": "EcoCell Battery Pack",
"description": "…",
"gtin": "…",
"category": "Electronics",
"materials": [
{
"name": "lithium",
"percentage": 5.2
}
],
"manufacturing": {
"facility": "Gigafactory 1",
"country": "DE"
},
"customData": {
"line": "A"
}
}
],
"callbackUrl": "https://integrator.example/hooks/ingest"
})
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
resp = requests.post(
f"{base_url}/api/v2/ingest/products",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": f"application/json"
},
json={
"sourceSystem": "tsc-middleware",
"products": [
{
"referenceId": "erp-4711",
"name": "EcoCell Battery Pack",
"description": "…",
"gtin": "…",
"category": "Electronics",
"materials": [
{
"name": "lithium",
"percentage": 5.2
}
],
"manufacturing": {
"facility": "Gigafactory 1",
"country": "DE"
},
"customData": {
"line": "A"
}
}
],
"callbackUrl": "https://integrator.example/hooks/ingest"
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"bytes"
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
url := fmt.Sprintf("%s/api/v2/ingest/products", baseURL)
payload := []byte(`{
"sourceSystem": "tsc-middleware",
"products": [
{
"referenceId": "erp-4711",
"name": "EcoCell Battery Pack",
"description": "…",
"gtin": "…",
"category": "Electronics",
"materials": [
{
"name": "lithium",
"percentage": 5.2
}
],
"manufacturing": {
"facility": "Gigafactory 1",
"country": "DE"
},
"customData": {
"line": "A"
}
}
],
"callbackUrl": "https://integrator.example/hooks/ingest"
}`)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+apiKey+"")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}202 Accepted → { jobId }. Measured 2026-07-22: 10,000 rows terminal in 67.5 s, 25/25 induced failures isolated row-level, idempotent re-submit with zero duplicates.
Job status / row outcomes for the machine on-ramp.
GET {{baseUrl}}/api/v2/ingest/{jobId}
Authorization: Bearer {{apiKey}}curl -X GET "$BASE_URL/api/v2/ingest/{jobId}" \
-H "Authorization: Bearer $NORRUVA_API_KEY"const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const jobId = "…";
const res = await fetch(`${baseUrl}/api/v2/ingest/${jobId}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${apiKey}`
}
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
job_id = "…"
resp = requests.get(
f"{base_url}/api/v2/ingest/{job_id}",
headers={
"Authorization": f"Bearer {api_key}"
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
jobId := "…"
url := fmt.Sprintf("%s/api/v2/ingest/%s", baseURL, jobId)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+apiKey+"")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}Multipart .aasx (OPC/ZIP) package → Draft product via the canonical create contract. XML-only payloads are refused honestly.
POST {{baseUrl}}/api/v2/import/aasx
Authorization: Bearer {{apiKey}}
Content-Type: application/jsoncurl -X POST "$BASE_URL/api/v2/import/aasx" \
-H "Authorization: Bearer $NORRUVA_API_KEY" \
-H "Content-Type: application/json"const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const res = await fetch(`${baseUrl}/api/v2/import/aasx`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": `application/json`
}
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
resp = requests.post(
f"{base_url}/api/v2/import/aasx",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": f"application/json"
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
url := fmt.Sprintf("%s/api/v2/import/aasx", baseURL)
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Authorization", "Bearer "+apiKey+"")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}Single-shot CSV import (≤ 50 MB).
POST {{baseUrl}}/api/v2/import
Authorization: Bearer {{apiKey}}
Content-Type: application/jsoncurl -X POST "$BASE_URL/api/v2/import" \
-H "Authorization: Bearer $NORRUVA_API_KEY" \
-H "Content-Type: application/json"const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const res = await fetch(`${baseUrl}/api/v2/import`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": `application/json`
}
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
resp = requests.post(
f"{base_url}/api/v2/import",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": f"application/json"
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
url := fmt.Sprintf("%s/api/v2/import", baseURL)
req, _ := http.NewRequest("POST", url, nil)
req.Header.Set("Authorization", "Bearer "+apiKey+"")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}Per-regulation column template (same generator as category templates, keyed by regulation code). regulationCode is the FULL schema_registry code, not a short name, e.g. EU-BAT-2023-1542, EU-ESPR-2024-1781-ELEC, EU-ESPR-2024-1781-TEX. Guessing 'ESPR' returns 404. Send a key: the handler itself is declared public, but this path is NOT on the edge allowlist, so the edge middleware returns 401 to an anonymous caller before the handler runs, the same edge-gate deviation as the GS1 resolver. Any credential-shaped Bearer header passes the edge.
GET {{baseUrl}}/api/v2/import/templates/{regulationCode}
Authorization: Bearer {{apiKey}}curl -X GET "$BASE_URL/api/v2/import/templates/{regulationCode}" \
-H "Authorization: Bearer $NORRUVA_API_KEY"const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const regulationCode = "…";
const res = await fetch(`${baseUrl}/api/v2/import/templates/${regulationCode}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${apiKey}`
}
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
regulation_code = "…"
resp = requests.get(
f"{base_url}/api/v2/import/templates/{regulation_code}",
headers={
"Authorization": f"Bearer {api_key}"
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
regulationCode := "…"
url := fmt.Sprintf("%s/api/v2/import/templates/%s", baseURL, regulationCode)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+apiKey+"")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}