Develop

API reference

Every endpoint, every parameter, and every response shape — typed and versioned.

What's live

Everything on this page is implemented and callable today with a key from the dashboard: account, models, datasets and shards, listings, uploads and downloads, purchases, payouts, balance transactions, webhooks, AI Factory, and inference. Where something is not built, this page says so in place rather than describing it as if it were.

Base URL

text
https://www.nodedata.ai/api/v1

All endpoints accept JSON bodies and return JSON responses. Errors follow the same shape across the API.

Use the www host

The bare nodedata.ai domain redirects to www.nodedata.ai, and fetch strips the Authorization header across that cross-origin redirect — the symptom is missing_credentials even though you sent a key. Call the www host directly.

Versioning

Breaking changes ship under a new path prefix (/v1 /v2). Additive changes (new fields, new optional parameters) ship in place without bumping the version. Removed or renamed fields are announced in the changelog at least 90 days before removal.

Error format

json
{
  "error": {
    "code": "price_too_low",
    "message": "Minimum paid price is $1.00.",
    "request_id": "req_9f2c1ab74e05d3819c6a7b40"
  }
}

code is stable — branch on it rather than parsing messages. Every error response also repeats its request_id in an x-request-id header. Log it: quoting it in a support request turns “a call failed yesterday” into one log line.

Pagination

List endpoints return a cursor in next_cursor when more results exist. Pass it back as ?cursor= on the next call; the cursor is the id of the last item on the page.

json
{
  "items": [/* ... */],
  "next_cursor": "cm5x9q2ab0001abcdefghij",
  "has_more": true
}

Cursors beat ?page= on a collection that changes underneath you: publishing one listing between calls would shift every offset and make you skip or repeat a record.

Rate limits

There are no per-key rate limits today, and the API does not return X-RateLimit-* headers — don't build a client that depends on reading them. Be a good citizen anyway: retry 429 and 5xx with exponential backoff and jitter, honour Retry-After if it appears, and avoid unbounded concurrency against list endpoints. Limits will be introduced with notice in the changelog.


Account

Get the authenticated account

http
GET /v1/me
Authorization: Bearer <key>

Response — 200
{
  "id": "cm5x9q2ab0000abcdefghij",
  "slug": "casper-white-2",
  "name": "Casper White",
  "email": "team@example.com",
  "created_at": "2026-06-24T18:03:11.000Z",
  "key": { "mode": "live", "scopes": ["models:read"] }
}

Smallest possible call to verify a key. Returns the account that owns the key plus the key's mode and scopes. An empty scopes array means the key is unrestricted.

Inference usage

http
GET /v1/usage?days=30
Authorization: Bearer <key>

Response — 200
{
  "window_days": 30,
  "summary": { "calls": 1841, "total_tokens": 4192883, "cost_micros": 2891422, "cost_usd": 2.891422 },
  "by_model": [{ "model": "node-reason-70b", "calls": 412, "total_tokens": 2222232, "cost_usd": 2.413 }]
}

Models

List models

http
GET /v1/models
Authorization: Bearer <key>

Query parameters
  q          string   Full-text search over title, description, and tags
  type       string   Asset type — dataset | onnx-model | rl-policy | ros-package | …
  category   string   Category slug — manipulation | navigation | vision | slam | …
  cursor     string   Pagination cursor from the previous response's next_cursor
  limit      int      1–100 (default 20)

Requires models:read. Returns published listings that have passed validation.

Model object

json
{
  "id": "cm5x9q2ab0001abcdefghij",
  "slug": "bin-picking-policy-v2",
  "title": "Bin-Picking Policy v2",
  "description": "Diffusion policy trained on 40k bimanual grasps.",
  "type": "rl-policy",
  "category": "manipulation",
  "license": "Apache-2.0",
  "version": "2.0.0",
  "price": { "amount": 4900, "currency": "usd" },
  "tags": ["grasping", "bimanual"],
  "frameworks": ["pytorch", "ros2"],
  "hardware": ["jetson-orin"],
  "sensors": ["realsense-d435"],
  "ros_compatible": true,
  "jetson_compatible": true,
  "isaac_sim_compatible": false,
  "file": { "name": "policy.zip", "size": 184320042, "extension": "zip" },
  "downloads": 312,
  "featured": false,
  "author": { "slug": "casper-white-2", "name": "Casper White" },
  "created_at": "2026-07-02T09:14:00.000Z",
  "updated_at": "2026-07-30T11:00:00.000Z"
}

price.amount is integer cents — 4900 is $49.00. Ids are opaque strings; don't parse them. Timestamps are ISO 8601 UTC.

Retrieve a model

http
GET /v1/models/{id_or_slug}

Accepts either the id or the URL slug. Unpublished and failed-validation listings are not addressable.

Revisions are not addressable

A listing carries a single version string and previous versions are not retained, so there is no /revisions/{revision} endpoint. To keep versions separately downloadable today, publish each as its own listing — see model versioning.

Publish a model

http
POST /v1/models
Authorization: Bearer <key>
{
  "title": "Bin-Picking Policy v2",
  "description": "Diffusion policy trained on 40k bimanual grasps.",
  "type": "rl-policy",
  "category": "manipulation",
  "license": "Apache-2.0",
  "version": "2.0.0",
  "price_cents": 4900,
  "frameworks": ["pytorch", "ros2"],
  "storage_path": "user/<your-id>/1754390000-policy.zip",
  "file_name": "policy.zip",
  "file_size": 184320042,
  "file_extension": "zip"
}

Response — 201 Created  (the model object)

Requires models:upload. price_cents is 0 for free or at least 100 for paid; anything between 1 and 99 is rejected with price_too_low. The slug is derived from the title and de-duplicated for you.

Update or unpublish

http
PATCH  /v1/models/{id_or_slug}    { "price_cents": 3900, "version": "2.0.1" }
DELETE /v1/models/{id_or_slug}    # unpublish — purchase history is preserved

Both require listings:write. Another account's listing reads as 404, not 403, so the API can't be used to probe what exists.

Download a model

http
POST /v1/models/{id_or_slug}/download
Authorization: Bearer <key>

Response — 200
{
  "url": "https://<storage>/object/sign/assets/...?token=...",
  "expires_in": 120,
  "file_name": "weights.safetensors",
  "file_size": 184320042
}

Returns a signed URL rather than redirecting, so a client can hand the transfer to whatever downloader it likes. Needs models:read or datasets:read. Paid assets you haven't bought return 402 purchase_required with the price attached.

Links are short-lived

Signed download URLs expire after 120 seconds — mint one immediately before the transfer rather than caching it, and call the endpoint again to refresh.

Upload a file

http
POST /v1/uploads
Authorization: Bearer <key>
{ "file_name": "policy.onnx", "file_size": 4194304 }

Response — 200
{
  "storage_path": "user/<your-id>/1754390000-policy.onnx",
  "upload_url": "https://<storage>/object/upload/sign/assets/...?token=...",
  "token": "...",
  "file_name": "policy.onnx",
  "file_size": 4194304,
  "file_extension": ".onnx"
}

PUT <upload_url>            # the bytes go straight to storage
PUT /v1/uploads             # optional: { "storage_path": ... } → { exists, size }
GET /v1/uploads             # limits: { max_bytes, extensions }

Then pass storage_path, file_name, file_size and file_extension to POST /v1/models. Requires models:upload or datasets:upload. The create call re-checks that the path is yours and that the object exists, so a signed URL on its own publishes nothing.


Datasets

Datasets mirror models across the data-bearing types (dataset, telemetry, calibration) and are gated behind the dataset scopes, so a key can be granted data access without model access.

  • GET /v1/datasets — list, same parameters as models
  • POST /v1/datasets — create (type defaults to dataset)
  • GET /v1/datasets/{id_or_slug}
  • PATCH /v1/datasets/{id_or_slug}
  • DELETE /v1/datasets/{id_or_slug} — unpublish
  • POST /v1/datasets/{id_or_slug}/publish

Shards

A large dataset uploads as many objects under one prefix rather than a single file. That keeps each transfer inside the per-object size limit and makes an interrupted upload restartable at shard granularity — you re-upload what's missing, not the whole set.

http
# 1. Create the dataset (publish it once the data is complete)
POST /v1/datasets
{ "title": "Warehouse Depth 40k", "description": "…", "price_cents": 0 }

# 2. Mint an upload URL per shard, then PUT the bytes straight to storage
POST /v1/datasets/{id}/shards
{ "name": "shard-0001.npz", "size": 41943040 }
→ { "name": "shard-0001.npz", "storage_path": "…", "upload_url": "…", "token": "…" }

# 3. Check what actually landed — read from storage, not a database guess
GET /v1/datasets/{id}/shards
→ { "items": [{ "name": "shard-0001.npz", "size": 41943040, … }], "count": 1, "total_bytes": 41943040 }

# 4. Fetch one shard back
GET /v1/datasets/{id}/shards/{name}
→ { "name": "…", "size": …, "url": "…", "expires_in": 120 }

# 5. Publish once the set is complete
POST /v1/datasets/{id}/publish

Restartable, not byte-resumable

Restart granularity is one shard: it either landed or it didn't. There is no byte-range resume within a single shard, so size shards for the retry you're willing to repeat.

Listings

The marketplace-facing view of the same rows, addressed by what a seller manages. The split exists so listings:write can let a key reprice and publish what already exists without also being able to upload new artifacts.

  • GET /v1/listings — every published listing, any type
  • POST /v1/listings — create a storefront entry for an asset
  • GET /v1/listings/{id_or_slug}
  • PATCH /v1/listings/{id_or_slug} — pricing, title, metadata
  • POST /v1/listings/{id_or_slug}/publish — idempotent
  • POST /v1/listings/{id_or_slug}/unpublish — idempotent

Purchases

http
POST /v1/purchases
Authorization: Bearer <key>
Content-Type: application/json

{ "asset": "bin-picking-policy-v2" }

Response — 201 Created
{
  "object": "checkout_session",
  "id": "cs_test_a1b2...",
  "status": "requires_payment",
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_a1b2...",
  "expires_at": 1785934800,
  "amount_cents": 4900,
  "currency": "usd",
  "asset": { "id": "cm5x9...", "slug": "bin-picking-policy-v2", "title": "Bin-Picking Policy v2" }
}

Node Data is not PCI-scoped and never touches card data, so a keyed integration can't submit a payment method directly. Creating a purchase returns a Stripe Checkout session for the buyer to complete; the Purchase row is written by the Stripe webhook at fulfilment — the same path the website uses, so an API caller and the site can never disagree about what a completed sale is. Pass success_url and cancel_url to return the buyer to your own app.

GET /v1/purchases lists what this account has bought — check it before prompting someone to buy something they already own.


Payouts & balance

GET /v1/payouts answers “what have I sold and what am I owed”. GET /v1/balance_transactions is the ledger behind that number — every individual movement, so your books reconcile line by line. Both require payouts:read.

http
GET /v1/balance_transactions
Authorization: Bearer <key>

Query parameters
  type    string   sale | refund | payout
  limit   int      1–200 (default 50)

Response — 200
{
  "items": [
    {
      "id": "bt_sale_cm5x9...",
      "type": "sale",
      "amount_cents": 4900,
      "fee_cents": 1225,
      "net_cents": 3675,
      "currency": "usd",
      "status": "pending",
      "created_at": "2026-08-01T22:10:00.000Z",
      "source": { "purchase_id": "cm5x9...", "asset": { "slug": "…", "title": "…" } },
      "description": "Sale of Bin-Picking Policy v2"
    }
  ],
  "summary": {
    "currency": "usd",
    "gross_cents": 129400,
    "platform_fee_cents": 32350,
    "earned_cents": 97050,
    "refunded_cents": 0,
    "paid_out_cents": 0,
    "balance_cents": 97050,
    "sale_count": 26,
    "refund_count": 0
  },
  "payouts_paused": false
}

Entries are derived from sales rather than stored separately, so they can't drift from the purchases they describe. A completed sale is a sale credit, a refund is a matching debit, and a settled creator transfer is a payout debit. Sum net_cents and you get the accruing balance.


AI Factory

Describe what you want and Factory drafts it for review. Requires the factory:run scope, and each generation spends a Factory credit — the same accounting as the web flow, so scripts can't run the pipeline for free.

http
POST /v1/factory/jobs
Authorization: Bearer <key>
{ "prompt": "Synthetic defect images for manufacturing QC." }

# Poll the pipeline
GET /v1/factory/jobs/{id}

# queued → analyzing → awaiting_clarification → planning →
# awaiting_approval → generating → review → published

POST /v1/factory/jobs/{id}/clarify      { "answers": [{ "id": "…", "answer": "…" }] }
POST /v1/factory/jobs/{id}/approve
POST /v1/factory/jobs/{id}/regenerate?stage=plan|build
POST /v1/factory/jobs/{id}/publish      { "price_cents": 4900 }

See AI Factory for the full pipeline.


Inference

OpenAI-compatible completions, so any OpenAI client pointed at this base URL works. Requires a premium key with inference:run.

http
GET  /v1/inference/models       # catalog with live per-token pricing
POST /v1/chat/completions       # set "stream": true for SSE chunks

Webhooks

  • GET /v1/webhook_endpoints
  • POST /v1/webhook_endpoints — returns the signing secret once
  • GET /v1/webhook_endpoints/{id}
  • PATCH /v1/webhook_endpoints/{id}
  • DELETE /v1/webhook_endpoints/{id}
  • GET /v1/webhook_endpoints/{id}/events — delivery log

See Authentication for signature verification.


Deployments

Not available

Hosted inference endpoints — creating a managed deployment from a model, invoking it, reading its logs — are not built. There is no /v1/deployments resource and no deployment host. This section previously documented that interface as though it existed.

What works today: download the artifact with POST /v1/models/{id}/download and run it on your own hardware, or use POST /v1/chat/completions for hosted text inference on the Node model catalog. See deploying models for the surfaces that are real.