For designers and their developers
The 3Moss API
Every designer on 3Moss has their own API. Use it to sell your products from your own website, or give a key to another website that wants to sell them, and send the orders back: 3Moss prints and ships every one. Make keys in your account, under API.
Authentication
Send your key in the Authorization header, from your server. Anyone who has your key can read your products, so never put it in a page's JavaScript or a public repository. Make one key per website, so you can revoke one without breaking the others; you can have up to 10.
Authorization: Bearer 3m_live_…List your products
GET http://localhost:3000/api/v1/productslimit- 1–100, default 50.
store- One store's products only — an id from
/api/v1/stores. tag- Only products with this tag. Repeat it, or separate with commas, for products that have all of them — for tag pages.
q- Search: matches the title, description, SEO title and tags, in Georgian or English. Up to 100 characters.
updated_since- An ISO 8601 time, for syncing.
cursor- The
next_cursorfrom the previous page.
Without updated_since you get what is on sale now. With it, you get every product changed since then, on sale or not — hide the ones with "available": false. A page can hold fewer than limit products; keep following next_cursor until it is null. Deleted products are not in this list — ask /products/deleted for them. A product counts as changed when anything shown about it changes — including supplier stock, prices, delivery and its store's name — and its updated_at says when.
curl "http://localhost:3000/api/v1/products?limit=20" \
-H "Authorization: Bearer $THREEMOSS_KEY"One product
GET http://localhost:3000/api/v1/products/{id}
GET http://localhost:3000/api/v1/products/{handle}Returns the product even when it is not on sale, so you can show it as sold out. Use the id or the handle — so a page at /products/owl-moon can ask for owl-moon directly. If a string is both an id and a handle, the id wins.
Deleted products
GET http://localhost:3000/api/v1/products/deleted?since=2026-10-09T21:14:03ZEvery product you deleted after since, newest first, with the same store, limit and cursor as the product list. Take each one off your site. Use the time of your last sync as since, the same one you send as updated_since. Deletions are remembered for 90 days; if your site has not synced for longer than that, run a full sync instead.
{
"deleted": [
{
"id": "p_7Hk2…",
"store_id": "ecc0…",
"handle": "owl-moon",
"deleted_at": "2026-10-10T08:02:51.000Z"
}
],
"next_cursor": null
}Asking /products/{id} for a product deleted in those 90 days answers 410 with its deleted_at, instead of 404.
Your stores
GET http://localhost:3000/api/v1/storesWhat a product looks like
{
"id": "p_7Hk2…",
"store": {
"id": "ecc0…",
"name": "Night Owl Co."
},
"title": "Owl moon",
"description": "…",
"available": true,
"garment": {
"name": "Unisex Heavy Cotton Tee",
"brand": "Gildan 5000",
"category": "T-shirts"
},
"price": {
"amount": "59.94",
"currency": "GEL"
},
"shipping": {
"first": "12.34",
"additional": "6.24",
"currency": "GEL"
},
"variants": [
{
"color": {
"id": "white",
"name": "White",
"hex": "#f7f7f5"
},
"size": "M",
"available": true
}
],
"handle": "owl-moon",
"images": [
{
"url": "http://localhost:3000/api/images/…?t=…",
"view": "front",
"alt": "Owl moon — front"
}
],
"tags": [
"owl",
"night sky"
],
"seo": {
"title": "Owl moon tee, hand-drawn night sky",
"description": "…",
"image": "http://localhost:3000/api/images/…?t=…",
"json_ld": {
"@context": "https://schema.org",
"@type": "Product",
"name": "Owl moon",
"offers": {
"…": "…"
}
}
},
"attributes": {
"style": "vintage"
},
"updated_at": "2026-10-09T21:14:03.000Z"
}- Prices are strings with a
currency, so nothing is lost to floating point. Every price is in Georgian lari (GEL). priceis your price. You can charge more on your own website.- Image links last thirty days and are signed again on every call. Download the images rather than linking to them.
Search engines
Every product comes ready for a product page's <head>. handle is a readable link name, Georgian titles romanised (ღამის ბუ → ghamis-bu), unique among your products. It follows the title until the designer sets one, so store the id and treat the handle as the address: when it changes, redirect the old one. seo.title and seo.description are never empty and already fit what a search result shows. seo.json_ld is a schema.org Product with price and availability, for rich results; add your page's url to its offers. Each image has alt text.
<title>{product.seo.title}</title>
<meta name="description" content="{product.seo.description}">
<meta property="og:title" content="{product.seo.title}">
<meta property="og:image" content="{your copy of product.seo.image}">
<link rel="canonical" href="https://your-site.ge/products/{product.handle}">
<script type="application/ld+json">{JSON.stringify(product.seo.json_ld)}</script>Image links expire, so serve your own copies — in og:image and in the JSON-LD's image too.
Send an order
POST http://localhost:3000/api/v1/ordersWhen your website sells one of your products, send the order here and 3Moss prints and ships it to your customer. Your customer pays you; 3Moss charges you printing and delivery. If the balance of the store the products are in covers that, the order is paid from it at once and goes to print (status received). Otherwise it waits as awaiting_payment until you pay for it in your account, and is cancelled if it is still unpaid after 7 days. Either way you get a notification.
{
"external_id": "my-site-1042",
"customer": {
"name": "ნინო ბერიძე",
"phone": "+995 555 12 34 56",
"email": "nino@example.com"
},
"address": {
"line1": "რუსთაველის გამზ. 34, ბ. 5",
"city": "თბილისი"
},
"items": [
{
"product_id": "p_7Hk2…",
"color": "white",
"size": "M",
"quantity": 2
}
],
"paid": {
"amount": "139.88",
"currency": "GEL"
}
}external_idis your own order id. Sending the same one again is safe: you get the same order back, withcreated: falseand status200, instead of201— so retry freely after a timeout.- Use the product
id, avariants[].color.idand asizefrom the products API. Everything in one order must come from one of your stores; send one order per store. - The whole order is refused with
422if anything in it cannot be sold right now — a product paused, a colour or size out of stock — so check availability first, and show the message if it happens. - 3Moss delivers in Georgia: the phone and city are required, and
address.country, if you send it, isGE.customer.emailandpaidare optional;paidis what your customer paid you, for your own records.
{
"order": {
"id": "QWPEZW9V",
"external_id": "my-site-1042",
"status": "received",
"tracking": null,
"items": [
{
"product_id": "p_7Hk2…",
"title": "Owl moon",
"color": "white",
"size": "M",
"quantity": 2
}
],
"paid": {
"amount": "139.88",
"currency": "GEL"
},
"timeline": [
{
"status": "awaiting_payment",
"at": "2026-10-19T09:12:40.000Z"
},
{
"status": "received",
"at": "2026-10-19T09:12:40.000Z"
}
],
"created_at": "2026-10-19T09:12:40.000Z",
"updated_at": "2026-10-19T09:12:40.000Z"
},
"created": true
}Your orders
GET http://localhost:3000/api/v1/orders/{id}
GET http://localhost:3000/api/v1/orders/{external_id}
GET http://localhost:3000/api/v1/orders?updated_since=2026-10-19T09:00:00ZOne order, by 3Moss's id or your external_id; or every order your websites sent, oldest change first, with the same limit and cursor as the product list. Poll with updated_since to tell your customers when their order ships: tracking is filled in then. The statuses are awaiting_payment, received, in_production, shipped, delivered and cancelled. Only orders sent through the API are listed, from any of your keys.
Webhooks
Instead of polling, give a key a webhook address on the API page (an https:// URL on your server). Within about a minute of a change, 3Moss sends it a POST:
order.updated— an order this key sent changed status or got its tracking number.product.updated— something a product page shows changed: title, price, photos, colours, sizes.product.unavailable— it can no longer be bought: paused, sold out in every colour or size, taken down, or deleted.
Orders go only to the key that sent them; products go to every key you have. The body carries the whole object, exactly as GET /orders/{id} or GET /products/{id} returns it, so you need no second call:
{
"id": "evt_8fK2mQ…",
"type": "order.updated",
"created_at": "2026-10-20T09:14:03.000Z",
"data": {
"order": { "id": "QWPEZW9V", "external_id": "1042", "status": "shipped", "tracking": "GE123…", … }
}
}A deleted product has nothing left to show, so it carries only this:
{
"type": "product.unavailable",
"data": { "product": { "id": "p_7Hk2…", "store_id": "ecc0…", "handle": "owl-moon", "deleted": true } }
}Every request has X-3Moss-Event (the type), X-3Moss-Delivery (the event id, the same on every retry, so you can ignore a repeat) and X-3Moss-Signature. Check the signature before trusting the body: it is an HMAC-SHA256, made with the key's signing secret (whsec_…, shown once when you save the address), of the time and the raw body. Refuse anything older than five minutes, so an old request replayed later does nothing:
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received, before JSON.parse.
function from3Moss(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, "hex"));
}Answer with any 2xx quickly, and do the work afterwards; 3Moss waits ten seconds. Redirects are not followed. Anything else is tried again after 1, 5 and 30 minutes, then every hour. Only the latest state of an object is sent: if an order changes twice before you have taken the first, you get the second. After a day of failures the address is switched off and you are told; turn it back on from the API page, then catch up with updated_since. “Send a test” there sends a ping event with empty data, signed like the rest.
From your server
Node 22 or later:
const res = await fetch("http://localhost:3000/api/v1/products", {
headers: { Authorization: `Bearer ${process.env.THREEMOSS_KEY}` },
});
if (!res.ok) throw new Error((await res.json()).error.message);
const { products, next_cursor } = await res.json();Limits and errors
60 requests a minute and 1000 an hour per key. Past that you get 429 with a Retry-After header, in seconds. Every error looks the same:
{ "error": { "code": "not_found", "message": "No such product." } }| 400 | bad_request | A query parameter is not one the API accepts. The message names it. |
| 401 | unauthorized | No key, a wrong key, or a revoked one. |
| 404 | not_found | No such product, or order, among yours. |
| 410 | gone | That product of yours was deleted. The body also carries deleted_at. |
| 422 | invalid_order | The order cannot be taken as sent: the message says which item or field, and why. Nothing was created. |
| 429 | rate_limited | Too many requests. Wait the seconds in Retry-After. |
| 500 | internal | Something went wrong on our side. Try again. |