3Moss
ქართულიCreate an account

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/products
limit
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_cursor from 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:03Z

Every 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/stores

What 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).
  • price is 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/orders

When 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_id is your own order id. Sending the same one again is safe: you get the same order back, with created: false and status 200, instead of 201 — so retry freely after a timeout.
  • Use the product id, a variants[].color.id and a size from 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 422 if 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, is GE. customer.email and paid are optional; paid is 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:00Z

One 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." } }
400bad_requestA query parameter is not one the API accepts. The message names it.
401unauthorizedNo key, a wrong key, or a revoked one.
404not_foundNo such product, or order, among yours.
410goneThat product of yours was deleted. The body also carries deleted_at.
422invalid_orderThe order cannot be taken as sent: the message says which item or field, and why. Nothing was created.
429rate_limitedToo many requests. Wait the seconds in Retry-After.
500internalSomething went wrong on our side. Try again.