Skip to main content
Ingestion is the one merchant-facing write surface in the API; everything else is storefront-only. It is how a merchant pushes their own external catalog into Galactic Core as JSON, XML, or CSV and keeps it in sync. Because it writes to the store, it carries the strictest authentication in the platform: the live import requires a secret key and an HMAC signature, both, every time. One continuous sync runs through this page: a sample feed to model yours after, a dry run over a feed carrying one good row and one bad one, the real import, then the catalog published back out for other systems to pull.
The /sample, /test, and public-feed reads need no key at all. Only the live import (ingestProducts) needs sk + HMAC. See Key Usage by Endpoint.

1. A sample feed to model yours after

A real, valid sample feed carries the exact field names and covers both a single-variant product and a multi-variant one, whose variants share a product_group. No key required.
Response
format: 'csv' or format: 'xml' returns the same sample as a CSV header row plus rows, or an XML <products> document. The three shapes are interchangeable, so the choice follows whatever your source system emits.

2. Dry-run validation, including a bad row

The validator parses every row and returns the same summary and errors a real import would, plus a normalized_preview of how Galactic Core interpreted each row — while writing nothing. It needs no key, so the format can be settled before any request is signed. A feed carrying one valid row and one invalid row (a negative price) shows the contract in full: the validator accepts the good row, rejects the bad one, and status becomes partial:
Response
Field by field:
  • statusvalid (no failures), partial (some rows rejected), or invalid (all rejected).
  • summary — the exact counts a real import would produce: rows_total / rows_created / rows_updated / rows_skipped / rows_failed.
  • errors[] — one entry per rejected row, naming the 1-based row, the offending sku, the field that failed, and a readable reason, here price must be a positive number.
  • normalized_preview[] — every row as Galactic Core interpreted it, with category and brand resolved and anything it could not infer left null, which is where the mapping is confirmed before signing.
A feed of only valid rows comes back status: valid, rows_failed: 0, and an empty errors array. Flagged rows can be fixed and re-run against the validator as many times as needed, since the live request is signed only once the counts are right.

3. Pushing the catalog (secret key + HMAC)

The real write is upsert-by-SKU: an existing sku is updated, a new one created. Rows are applied independently, so one bad row never fails the batch, and the response carries created / updated / skipped / failed counts plus an errors array for any rejections. Three signing headers are required, on the same scheme as orders and payments: an Idempotency-Key, where retrying with the same key returns the original result rather than re-importing; an X-Timestamp in unix seconds, within 5 minutes of server time; and an X-Signature, the base64 HMAC-SHA256 of `${timestamp}.${body}` signed with the store’s signing secret from Integrations → API Keys.
Both SKUs are new, so they’re created — rows_created: 2, errors: []:
Response
Re-signing and sending the same two SKUs recognizes them as existing — rows_updated: 2, nothing duplicated, which is what makes the import safe to run on a schedule. A batch mixing good and bad rows returns status: partial with the per-row errors array, as the dry run did, and the good rows are still written.
This is the only place in the API where sk and HMAC are both mandatory, and it has three failure responses:
  • A publishable key returns 403 (forbidden) — ingestProducts rejects pk.
  • A missing or invalid signature returns 401 (invalid_signature, message: “Ingestion requires X-Timestamp + X-Signature (HMAC-SHA256 over ${timestamp}.${body})”).
  • A feed where every row fails validation returns 422, writing nothing. The body is the same IngestResult shape with rows_failed === rows_total and the full errors array.
Four rules govern a first import:
  • A category must already exist. Categories are a fixed platform-level set, so a row whose category does not match one of the store’s enabled categories is rejected, never auto-created. The name must match exactly.
  • A subcategory is auto-created. Subcategories are the merchant’s to define freely, and an unknown one is created on the fly. > nests them, as in category: "Electronics", subcategory: "Audio > Headphones".
  • Variants group by product_group. Rows sharing a product_group become variants of one product, each still carrying its own unique sku.
  • CSV and XML behave identically. format: 'csv' (or 'xml') with a string body follows the same upsert and partial-success rules. A CSV body is the header row plus data rows as one string:

Carrying your own columns

A feed is not limited to the fields above. A column that matches the label of a custom field defined on products for the store is recorded on the product; anything else is ignored, so extra columns are safe to leave in.
The field has to exist first — a feed fills fields, it does not create them. They are defined in admin under Settings → Custom Fields, and an update refreshes them along with everything else, so the feed stays the source of truth for those values too.

4. Publishing the catalog back out

The inverse direction. Opting in under admin → Catalog Sync → “Publish my catalog” makes the catalog readable at a stable public URL with no key required, so any system can pull it — another store’s scheduled sync, a partner integration, a script. Only storefront-safe fields are exposed, never cost or margin. The SDK call below abstracts the host away; the dashboard shows the full URL to copy, and where the store belongs to an operated platform that URL carries the platform’s own host — see Publish your own catalog.
Response
subcategory, brand, and sale_price are nullable: the first product has subcategory: null and a sale_price of 129, being on sale, while the second has sale_price: null and is not. The feed shape matches what the ingestion endpoints accept, which makes a store-to-store sync a direct pull-and-ingest with no field mapping. server.ingestion.getStoreCatalogFeedXml({ store }) returns the same data as an XML document. A feed protected with a token takes it as { store, token: 'YOUR_FEED_TOKEN' }; a wrong or missing token, or a store that has not opted in, returns 404.

What’s next