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 TSC-style integrations use (PRD F2) — 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 }. Driven green live 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}}
}