Orders API
When to use it
The hosted buy button and Shopify cover most stores with no integration. Reach for the Orders API when you already run your own checkout — Stripe or otherwise — and want to own the entire buying experience. You charge the customer, then POST the order; Snowcone prints and ships behind it.
Authenticate
Orders use a server-side API key — an sk_-prefixed secret, never the public Shop ID. Pass it in the x-api-key header (an Authorization: Bearer token works too). Writing orders needs the orders:add scope; reading them needs orders:read.
orders:add key can place orders against your account.Create an order
POST to /orders. Each item names the Snowcone sku (product id) and variantId from the catalog, a quantity, and the print-ready artworkUrl. shipTo is where the whole order goes (country codes are ISO 3166-1 alpha-2, like GB or US; state only where countries have them), and shippingMethod is economy (the default and the free tier), standard, express, or overnight. That's the entire request for most integrations.
curl https://api.snowcone.app/orders \
-X POST \
-H "x-api-key: sk_your_orders_add_key" \
-H "Content-Type: application/json" \
-d '{
"externalId": "shop-order-1001",
"items": [
{
"sku": "BEEB77",
"variantId": "e92f40",
"quantity": 1,
"artworkUrl": "https://cdn.example.com/print.png"
}
],
"shipTo": {
"name": "Ada Lovelace",
"line1": "5 Bell Yard",
"city": "London",
"postalCode": "WC2A 2JR",
"country": "GB"
},
"shippingMethod": "standard"
}'// Server-side only — the sk_ key never reaches the browser.
const res = await fetch("https://api.snowcone.app/orders", {
method: "POST",
headers: {
"x-api-key": process.env.SNOWCONE_ORDERS_KEY, // sk_… scoped orders:add
"Content-Type": "application/json",
},
body: JSON.stringify({
// Your platform's order id (e.g. a Shopify order id). Retries with the
// same externalId return the existing order — never a duplicate.
externalId: "shop-order-1001",
items: [
{
sku: "BEEB77", // Snowcone product id
variantId: "e92f40", // variant (gvid) from the catalog
quantity: 1,
artworkUrl: "https://cdn.example.com/print.png",
},
],
shipTo: {
name: "Ada Lovelace",
line1: "5 Bell Yard",
city: "London",
postalCode: "WC2A 2JR",
country: "GB", // state only where countries have them (US/CA/AU)
},
shippingMethod: "express", // economy (default, free) | standard | express | overnight — quote first: not every supplier offers every speed
}),
});
const { order } = await res.json();
// order: { id, orderNumber, externalId, fulfillmentStatus, json, totalPrice, createdAt, updatedAt }
// order.json is the canonical order we normalized your request into.externalId to your own platform's order id: creating twice with the same externalId returns the existing order instead of duplicating it. Webhook-driven integrations (Shopify redelivers webhooks, for example) get exactly-one-order semantics for free. externalId is optional (any string works, e.g. a bare Shopify id like "5512345") — but without it a retried create makes a second order, so always send one.Advanced: placements & split shipments
artworkUrl works when the variant has one printable placement — the common case. Products with several (front, back, sleeve) take imagePlacements with the placement labels the catalog reports for that variant. Mix freely per item; each item independently uses whichever form fits:
# Per-placement artwork stays in the same top-level form: give any
# item imagePlacements instead of artworkUrl. Mixing per item is fine —
# each item independently uses whichever fits.
curl https://api.snowcone.app/orders \
-X POST \
-H "x-api-key: sk_your_orders_add_key" \
-H "Content-Type: application/json" \
-d '{
"externalId": "shop-order-1002",
"items": [
{
"sku": "BEEB77",
"variantId": "ab12cd",
"quantity": 1,
"imagePlacements": [
{ "label": "Front", "imageUrl": "https://cdn.example.com/front.png" },
{ "label": "Back", "imageUrl": "https://cdn.example.com/back.png" }
]
},
{ "sku": "AR2P3G", "variantId": "zz9999", "quantity": 2,
"artworkUrl": "https://cdn.example.com/tote.png" }
],
"shipTo": {
"name": "Grace Hopper",
"line1": "123 Main St",
"city": "Austin",
"state": "TX",
"postalCode": "78701",
"country": "US"
},
"shippingMethod": "express"
}'Orders that split across addresses or speeds replace shipTo with explicit shipments under the json field:
# Split shipments: replace shipTo with explicit "shipments" under
# "json". Number lineItemIds and reference them from each shipment;
# every shipment carries its own address and speed. Items behave
# exactly as above (artworkUrl or imagePlacements per item).
curl https://api.snowcone.app/orders \
-X POST \
-H "x-api-key: sk_your_orders_add_key" \
-H "Content-Type: application/json" \
-d '{
"externalId": "shop-order-1003",
"json": {
"items": [
{ "lineItemId": 1, "sku": "BEEB77", "variantId": "e92f40", "quantity": 1,
"artworkUrl": "https://cdn.example.com/a.png" },
{ "lineItemId": 2, "sku": "BEEB77", "variantId": "e92f40", "quantity": 1,
"artworkUrl": "https://cdn.example.com/b.png" }
],
"shipments": [
{ "shippingAddress": { "name": "Grace Hopper", "line1": "123 Main St", "city": "Austin",
"state": "TX", "postalCode": "78701", "country": "US" },
"shippingMethod": "overnight", "lineItems": [1] },
{ "shippingAddress": { "name": "Ada Lovelace", "line1": "5 Bell Yard", "city": "London",
"postalCode": "WC2A 2JR", "country": "GB" },
"shippingMethod": "economy", "lineItems": [2] }
]
}
}'Dry runs
Add "dryRun": true to any create request to run the identical validation, normalization, and pricing path without creating anything. Perfect for agents and CI: assert the canonical order in the response, then send the same body without dryRun to commit.
# Same request + "dryRun": true — full validation, normalization and
# pricing, but nothing is created. The response echoes the canonical
# order the call WOULD create (order.id is "dry_run"). Assert it, then
# send the same body without dryRun to commit.
curl https://api.snowcone.app/orders \
-X POST \
-H "x-api-key: sk_your_orders_add_key" \
-H "Content-Type: application/json" \
-d '{ "dryRun": true, "externalId": "shop-order-1001", "items": [ … ], "shipTo": { … } }'Price shipping for a cart
Before you charge your customer, POST the cart to /orders/quotes — items plus a destination (country as ISO 3166-1 alpha-2 and postal; state where the country has them). A cart of mixed products can leave more than one facility, so the quote comes back as package groups: each group names the items in it (itemIndices point into your items array) and prices its own speeds. Economy is always free, and priceCents is flat per group — it does not scale with quantity. Add up the speeds you pick across groups and that's the cart's shipping total. (state is optional here even for US destinations — quoting needs less than shipping does.)
# Price shipping for a whole cart — mixed products, one call.
# Works with any orders key (orders:read or orders:add).
curl https://api.snowcone.app/orders/quotes \
-X POST \
-H "x-api-key: sk_your_orders_key" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "sku": "BEEB77", "variantId": "e92f40", "quantity": 1 },
{ "sku": "AR2P3G", "variantId": "zz9999", "quantity": 2 }
],
"destination": { "country": "US", "postal": "78701" }
}'
# The cart ships as one or more package groups. Each group lists its
# speeds cheapest first; priceCents is the shipping price at that speed
# and economy is always 0 — free. groupKey is opaque and stable.
# {
# "groups": [
# { "groupKey": "d2c1a9e40b77", "itemIndices": [0],
# "options": [
# { "speed": "economy", "priceCents": 0, "minTransitDays": 2, "maxTransitDays": 5,
# "estimatedDelivery": { "earliest": "2026-08-14", "latest": "2026-08-27" } },
# { "speed": "standard", "priceCents": 900, "minTransitDays": 2, "maxTransitDays": 5,
# "estimatedDelivery": { "earliest": "2026-08-12", "latest": "2026-08-21" } },
# { "speed": "express", "priceCents": 1800, "minTransitDays": 2, "maxTransitDays": 2,
# "estimatedDelivery": { "earliest": "2026-08-12", "latest": "2026-08-18" } },
# { "speed": "overnight", "priceCents": 2800, "minTransitDays": 1, "maxTransitDays": 1,
# "estimatedDelivery": { "earliest": "2026-08-11", "latest": "2026-08-17" } }
# ] },
# { "groupKey": "7be03f5a91cc", "itemIndices": [1],
# "options": [ { "speed": "economy", "priceCents": 0 } ] }
# ],
# "quotedAt": "2026-08-05T12:00:00.000Z"
# }speed tokens are the same values shippingMethod takes when you create the order — quote, let the shopper pick, then send the chosen speed with the order, and the order's totalPrice includes it. Both sides price from the same table, per package group, so the total you quoted and the total we charge agree. A speed a group doesn't offer is refused at create time (E_SPEED_UNAVAILABLE) rather than shipped unpriced. groupKey is an opaque, stable handle for "these items ship together"; it never identifies a facility. estimatedDelivery is absent when we don't have the data to promise dates — we never invent them.Delivery dates on your product page
Shoppers convert better when the page says when it arrives. GET /orders/delivery-estimates with a variantId and the shopper's country + postal (from their geolocation or a postal-code input on your side) and render the returned date ranges directly — real dates like "arrives Aug 14 – Aug 27", not day counts for you to do calendar math on. For a whole cart, use the quote above — it carries the same estimatedDelivery per speed.
# Arrival window for one variant — dates, not day counts. Built for
# product pages: render the strings directly.
curl "https://api.snowcone.app/orders/delivery-estimates?variantId=e92f40&country=US&postal=60601" \
-H "x-api-key: sk_your_orders_key"
# {
# "estimates": [
# { "speed": "economy", "priceCents": 0, "minTransitDays": 2, "maxTransitDays": 5,
# "estimatedDelivery": { "earliest": "2026-08-14", "latest": "2026-08-27" } },
# { "speed": "express", "priceCents": 1800, "minTransitDays": 2, "maxTransitDays": 2,
# "estimatedDelivery": { "earliest": "2026-08-12", "latest": "2026-08-18" } }
# ],
# "estimatedAt": "2026-08-05T12:00:00.000Z"
# }
# → "Free shipping — arrives Aug 14 – Aug 27"
# → "Express $18 — arrives Aug 12 – Aug 18"Errors
The API never guesses. Anything ambiguous fails with a typed code and the fix in the message — including the valid options, so one retry can be correct:
E_PLACEMENT_REQUIRED— the variant has several placements; the message lists their labels. UseimagePlacements.E_CONFLICTING_SHAPES— you mixed the simple and advanced forms (e.g.shipToandshipments, orartworkUrlandimagePlacements). Pick one; we won't choose for you.E_STATE_REQUIRED— the destination country uses state/province codes (US, CA, AU) and the address has none.E_ARTWORK_REQUIRED— an item has nothing to print.E_LINE_ITEM_IDS_PARTIAL— give every item alineItemIdor none (we number positionally).E_SHIPPING_REQUIRED— noshipToorshipments, or an unknownshippingMethod(the message lists the valid speeds).E_ITEMS_REQUIRED— a quote with no items, an item missingsku/variantId, or a quantity below 1.E_DESTINATION_INVALID—countryisn't a two-letter ISO code, orpostalis missing.E_DESTINATION_UNSUPPORTED— some items can't ship to that destination; the message names which items and the countries they can ship to.E_VARIANT_UNKNOWN— avariantIdisn't in the catalog. Use the gvid the catalog reports.E_SPEED_UNAVAILABLE— a shipment asks for a speed one of its package groups doesn't offer; the message names the items and lists what that group does offer. Quote the cart first to see the available speeds.
Order lifecycle
fulfillmentStatus on the order tells you where it is: pending and generating mean print files are being prepared, submitted means it entered production, then shipped (tracking appears on the order) and delivered. cancelled is terminal. Anything ending in _failed means we hit a problem preparing the order and are on it — the order does not enter production in a failed state.
Webhooks
Register HTTPS endpoints in the studio or with one call — the body is just url (plus an optional description):
# Register an endpoint (orders:add scope). We immediately probe the
# URL with a signed test delivery — it must answer 2xx to register.
curl https://api.snowcone.app/orders/webhooks \
-X POST \
-H "x-api-key: sk_your_orders_add_key" \
-H "Content-Type: application/json" \
-d '{ "url": "https://yourshop.com/api/snowcone-webhook",
"description": "production OMS" }'
# → { "endpoint": { "id", "url", "description", "secretPreview", … },
# "secret": "whsec_…" }
# "secret" is shown ONCE, only here — store it now. Every later read
# returns only the masked secretPreview.We push every event to registered endpoints — no polling. order.status_changed fires on each lifecycle transition (including in_production when the print shop confirms production started); shipment.shipped carries the carrier, tracking number, and tracking URL the moment the carrier first scans the package; shipment.out_for_delivery fires on the delivery-day carrier scan (with estimatedDeliveryAtwhen the carrier reports one); shipment.delivered closes the loop. Perfect for pushing fulfillment updates straight back into Shopify or your own OMS.
Every delivery is signed with your endpoint's whsec_ secret (shown once at registration). Verify before trusting:
// Verify a webhook delivery (Node). Header:
// X-Snowcone-Signature: t=<unix seconds>,v1=<hex hmac-sha256>[,v1=<…>]
// Signed content is `${t}.${rawBody}` with your whsec_ secret.
import { createHmac, timingSafeEqual } from "crypto";
function verifySnowconeWebhook(rawBody: string, header: string, secret: string): boolean {
const parts = header.split(",").map((kv) => kv.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay guard
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
// Accept if ANY v1 matches — a rotation window sends one per valid secret.
return parts.some(([k, v]) => k === "v1" && v?.length === expected.length &&
timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v, "hex")));
}
// Event envelope:
// { id, type, createdAt, data }
// order.status_changed → data.order { id, orderNumber, externalId, fulfillmentStatus }
// shipment.shipped → data.order + data.shipment { carrier, trackingNumber, trackingUrl }
// shipment.out_for_delivery → data.order + data.shipment { carrier, trackingNumber, trackingUrl, estimatedDeliveryAt }
// shipment.delivered → data.order + data.shipment { deliveredAt }
// test → data.messageRotate a signing secret
If a secret leaks — pasted into a ticket, committed, logged — rotate it. You get a new secret back once, and the old one keeps verifying for 24 hours so rotation is not an outage:
# Mint a new signing secret. Returned ONCE, like registration.
curl https://api.snowcone.app/orders/webhooks/<id>/rotate-secret \
-X POST \
-H "x-api-key: sk_your_orders_add_key"
# → { "secret": "whsec_…", "previousSecretValidUntil": "2026-08-02T06:00:00.000Z" }During the window every delivery carries two v1= signatures, one per valid secret, so receivers still running the old one keep working until you redeploy. This is why the verifier above accepts a match on any v1= — a verifier that parses the header into a map keeps only the last signature and will drop deliveries mid-rotation. GET /orders/webhooks reports previousSecretValidUntil while the window is open.
test event that arrives before the call returns your secret — so accept (2xx) test events without verifying their signature; verify everything else. Respond with a 2xx quickly — we time out at 10 seconds. Failed deliveries retry automatically (1m, 5m, 30m, 2h, 12h) and every attempt is visible in the studio's delivery log. Deliveries can arrive more than once; use the envelope id to deduplicate.Read it back
Fetch an order by id with an orders:read key to reconcile and track fulfillment.
# Read an order back (orders:read scope). Poll fulfillmentStatus to
# track progress; tracking numbers appear on the order as it ships.
curl https://api.snowcone.app/orders/<id> \
-H "x-api-key: sk_your_orders_read_key"Cancel an order
Orders can be cancelled any time before they enter production. Once an order has been submitted for production, cancellation is no longer guaranteed — contact support for in-production changes.
# Cancel before production starts (orders:add scope). Orders that
# have already been submitted for production cannot be cancelled here.
curl https://api.snowcone.app/orders/<id>/cancel \
-X POST \
-H "x-api-key: sk_your_orders_add_key"
