API reference · v1
Build your shop on ours
Developer quickstart
Browse the live catalogue, quote a cart, and place orders from your decorate balance. Gift links appear on completed orders and can be delivered to your server through signed webhooks.
// Save the helper from "Your first request" as decorate.js
import { call } from './decorate.js';
const { items } = await call('GET', '/v1/catalogue?type=decoration&limit=3');
console.log(items.map((item) => item.name));Base URL
https://api.decorate.gg/v1Signed requests
HMAC-signed, scoped keys. Your secret never leaves your server.
Live catalogue
Every item with store prices, availability and previews.
Balance-paid orders
One request buys the whole cart from your decorate.gg balance.
Signed webhooks
Order updates pushed to your server, gift links included.
Safe retries
Idempotency keys mean a retried order is never charged twice.
Built for LLMs
Copy any section as Markdown, or point your agent at llms.txt.
Get started
Your first request
From a new key to a signed request.
Create an API key in your dashboard. Choose only the permissions your integration needs and save the secret when it is shown. You will not be able to read it again.
Set DECORATE_KEY_ID and DECORATE_API_SECRET in your server environment. Copy the Node.js or Python helper, then call GET /v1/ping to check your credentials.
Fetch the catalogue, quote the items you want, then add funds before placing an order. Use a unique idempotency key for each purchase; keep it when retrying that same purchase.
import { createHash, createHmac, randomBytes } from 'node:crypto';
const BASE = 'https://api.decorate.gg';
const KEY_ID = process.env.DECORATE_KEY_ID;
const SECRET = process.env.DECORATE_API_SECRET;
async function call(method, path, payload) {
const url = new URL(path, BASE);
const body = payload === undefined ? '' : JSON.stringify(payload);
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(16).toString('hex');
const hash = createHash('sha256').update(body).digest('hex');
const canonical = [method.toUpperCase(), url.pathname + url.search,
timestamp, nonce, hash].join('\n');
const signature = createHmac('sha256', SECRET)
.update(canonical).digest('hex');
const response = await fetch(url, {
method, headers: {
'Content-Type': 'application/json',
'X-Api-Key': KEY_ID,
'X-Api-Timestamp': timestamp,
'X-Api-Nonce': nonce,
'X-Api-Signature': signature,
},
...(body ? { body } : {}),
signal: AbortSignal.timeout(30000),
});
const data = await response.json();
if (!response.ok) throw new Error(JSON.stringify(data));
return data;
}
console.log(await call('GET', '/v1/ping'));Get started
Authentication
Signed requests. Scoped access. Replay protection.
Every endpoint except service status requires HMAC-SHA256 authentication. Sign the method, the exact path and query string, timestamp, nonce, and SHA-256 hash of the raw body, separated by newline characters.
Send a fresh cryptographically random nonce for every attempt. A retried order needs a new nonce and timestamp, but the same idempotency key and body. Sign the exact bytes you send; do not serialize the body again afterward.
Headers
X-Api-KeystringrequiredThe public ID of your API key.
X-Api-TimestampintegerrequiredCurrent Unix time in seconds, within 60 seconds of our clock. Keep your server clock synchronized.
X-Api-NoncestringrequiredA fresh 32–64 character lowercase hexadecimal value. The helper generates 16 random bytes as hex.
X-Api-SignaturestringrequiredLowercase hexadecimal HMAC-SHA256 of the canonical request, using your API secret.
An empty body still has a SHA-256 hash. The helpers handle GET requests correctly.
Key scopes restrict what an integration can do. Revoking a key stops authentication immediately.
Limits: 120 authenticated requests per minute per account, 20 order attempts per minute, and 16 KiB request bodies. The complete body must arrive within 5 seconds.
METHOD
/path?exact=query
timestamp
nonce
sha256(raw_body)import { createHash, createHmac, randomBytes } from 'node:crypto';
const BASE = 'https://api.decorate.gg';
const KEY_ID = process.env.DECORATE_KEY_ID;
const SECRET = process.env.DECORATE_API_SECRET;
async function call(method, path, payload) {
const url = new URL(path, BASE);
const body = payload === undefined ? '' : JSON.stringify(payload);
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(16).toString('hex');
const hash = createHash('sha256').update(body).digest('hex');
const canonical = [method.toUpperCase(), url.pathname + url.search,
timestamp, nonce, hash].join('\n');
const signature = createHmac('sha256', SECRET)
.update(canonical).digest('hex');
const response = await fetch(url, {
method, headers: {
'Content-Type': 'application/json',
'X-Api-Key': KEY_ID,
'X-Api-Timestamp': timestamp,
'X-Api-Nonce': nonce,
'X-Api-Signature': signature,
},
...(body ? { body } : {}),
signal: AbortSignal.timeout(30000),
});
const data = await response.json();
if (!response.ok) throw new Error(JSON.stringify(data));
return data;
}
console.log(await call('GET', '/v1/ping'));Get started
Errors & retries
Handle the status code and the machine-readable error.
Errors return an error object with a stable type and a readable message. Log the request ID, status, and error type to investigate a failed call. Do not log API secrets or signatures.
Retry network failures and rate limits with backoff. When retrying an order, keep its original idempotency key and exact cart. A new idempotency key represents a new purchase.
Status codes
400Invalid requestCheck the required fields, item quantities, and parameter formats.
401Authentication failedCheck your key, signature, timestamp, and nonce.
403Access deniedThe key lacks a required scope or the account is suspended.
404Not foundThe item or order does not exist, or the order is not yours.
409ConflictRe-quote a changed price or unavailable cart. Reusing an idempotency key with a different request is rejected.
402Insufficient balanceAdd funds before retrying the order.
429Rate limitedWait for the Retry-After interval before making another request.
408Request timed outThe request body did not finish within 5 seconds. Retry with a fresh timestamp and nonce.
413Body too largeKeep the complete UTF-8 request body at or below 16 KiB.
503Store unavailableThe store is temporarily closed. Check service status before retrying an order.
{
"error": {
"type": "insufficient_funds",
"message": "Your balance is too low for this order."
}
}Get started
Idempotent orders
Retry a purchase without buying it twice.
Every order requires an idempotency_key. Generate it once in your system and save it with your customer’s purchase. It is scoped to your account, so it remains usable if you rotate your API key.
Repeating the same key and request returns the original order without charging again. Reusing it with different items is a conflict. Concurrent requests are protected by the same transactional check.
Orders are all or nothing. If any item is unavailable, the price has changed, or the balance is insufficient, no partial cart is purchased.
Account
Service status
Check API availability without authenticating.
A successful status response confirms this endpoint is available. It is not an estimated delivery time.
curl https://api.decorate.gg/v1/status{
"status": "operational",
"api": "up",
"store": "open",
"queue": 2,
"updated": "2026-10-15T12:00:00Z"
}Account
Check authentication
Make a small signed call to verify your integration.
account:readawait call('GET', '/v1/ping');{
"ok": true,
"name": "My storefront",
"status": "active",
"account_status": "active"
}Uses the signed call helper from Your first request. Example values are illustrative.
Account
Retrieve balance
Read the balance available for API orders.
account:readawait call('GET', '/v1/balance');{
"balance_cents": 4200,
"currency": "USD"
}Uses the signed call helper from Your first request. Example values are illustrative.
Catalogue
List items
Live store prices, availability, and collectible previews.
catalogue:readQuery and path parameters
searchstringFilter by item name.
categorystringFilter by collection name (case-insensitive contains).
typestringOne of decoration, effect, nameplate, frame, or bundle.
limitinteger1–2,000 items; defaults to 100.
offsetintegerNumber of matching items to skip.
Example IDs in these docs are placeholders. Use an actual item ID returned by the catalogue.
Check sellable before offering an item. The catalogue includes unavailable items. Stock is null because availability is not represented as a unit count.
Images are absolute URLs when a single preview exists. Frames use composited layers, so their image is null.
await call('GET', '/v1/catalogue');{
"items": [
{
"id": "ITEM_ID",
"name": "Example decoration",
"type": "decoration",
"collection": "Example collection",
"price_cents": 250,
"currency": "USD",
"image": "https://cdn.discordapp.com/media/v1/collectibles-shop/ITEM_ID/static",
"stock": null,
"sellable": true,
"available_until": null
}
],
"count": 1,
"total": 1,
"limit": 100,
"offset": 0
}Uses the signed call helper from Your first request. Example values are illustrative.
Catalogue
Retrieve an item
Check one item before adding it to your cart.
catalogue:readQuery and path parameters
idstring · pathrequiredAn item ID from the catalogue.
await call('GET', '/v1/catalogue/{id}');{
"id": "ITEM_ID",
"name": "Example decoration",
"type": "decoration",
"collection": "Example collection",
"price_cents": 250,
"currency": "USD",
"image": "https://cdn.discordapp.com/media/v1/collectibles-shop/ITEM_ID/static",
"stock": null,
"sellable": true,
"available_until": null
}Uses the signed call helper from Your first request. Example values are illustrative.
Catalogue
Quote a cart
Validate a cart and calculate its total without spending balance.
catalogue:readBody
itemsarrayrequiredBetween 1 and 25 distinct items. Maximum order total: 500,000 cents ($5,000).
items[].idstringrequiredAn item ID from the catalogue.
items[].qtyintegerQuantity from 1 to 10; defaults to 1.
items[].expected_price_centsintegerThe unit price shown to your customer, in USD cents. A mismatch returns a price_changed line status on quotes and rejects order creation.
await call('POST', '/v1/quote', {
"items": [
{
"id": "ITEM_ID",
"qty": 1,
"expected_price_cents": 250
}
]
});{
"ok": true,
"total_cents": 250,
"currency": "USD",
"lines": [
{
"id": "ITEM_ID",
"name": "Example decoration",
"qty": 1,
"unit_price_cents": 250,
"line_total_cents": 250,
"status": "ok"
}
]
}Uses the signed call helper from Your first request. Example values are illustrative.
Orders
Create an order
Purchase the whole cart from your balance.
orders:writeBody
idempotency_keystringrequiredYour unique reference for this purchase: 8–100 letters, numbers, periods, underscores, colons, or hyphens. Reuse it only when retrying the same order.
itemsarrayrequiredThe same item shape used by Quote a cart, with 1–25 distinct items and quantities of 1–10. The entire cart must be available.
This endpoint spends your real balance. Quote the cart first.
Retries return gift links only when the key also has orders:read. A write-only key receives links:[] even for a delivered order.
Deliver gift links through your own integration. The API does not accept a recipient ID or send a customer DM for you.
await call('POST', '/v1/orders', {
"idempotency_key": "your-order-1042",
"items": [
{
"id": "ITEM_ID",
"qty": 1,
"expected_price_cents": 250
}
]
});{
"id": "ORDER_ID",
"status": "paid",
"total_cents": 250,
"currency": "USD",
"created_at": "2026-10-15T12:00:00Z",
"delivered_at": null,
"lines": [
{
"id": "ITEM_ID",
"name": "Example decoration",
"qty": 1,
"unit_price_cents": 250
}
],
"links": []
}Uses the signed call helper from Your first request. Example values are illustrative.
Orders
List your orders
Find your API purchases, newest first.
orders:readQuery and path parameters
limitinteger1–100 orders; defaults to 25.
offsetintegerNumber of orders to skip.
Lists return order summaries without gift links. Retrieve an individual order for its purchased items and gift links.
await call('GET', '/v1/orders');{
"orders": [],
"total": 0,
"limit": 25,
"offset": 0
}Uses the signed call helper from Your first request. Example values are illustrative.
Orders
Retrieve an order
Check progress and collect delivered gift links.
orders:readQuery and path parameters
idstring · pathrequiredYour decorate order ID.
Order states are paid (awaiting delivery), delivered (gift links ready), and cancelled (refunded before delivery).
Only orders belonging to your account are accessible. Unknown and unauthorized order IDs both return 404.
Treat gift links as sensitive. Anyone with a valid unclaimed link may redeem it.
await call('GET', '/v1/orders/{id}');{
"id": "ORDER_ID",
"status": "delivered",
"total_cents": 250,
"currency": "USD",
"created_at": "2026-10-15T12:00:00Z",
"delivered_at": "2026-10-15T12:05:00Z",
"lines": [
{
"id": "ITEM_ID",
"name": "Example decoration",
"qty": 1,
"unit_price_cents": 250
}
],
"links": [
"https://discord.gift/EXAMPLE"
]
}Uses the signed call helper from Your first request. Example values are illustrative.
Webhooks
Receive events
Receive signed order updates on your server.
Register your HTTPS endpoint in the API dashboard. Each event has a unique ID. Store that ID and process each event once; delivery may be retried.
Verify the signature against the raw request body before parsing JSON. The signing secret is separate from your API key and is shown only when created or rotated.
The signature covers timestamp, event ID, and raw body, separated by periods. Reject stale timestamps, compare signatures in constant time, and reply with a successful HTTP status after accepting the event.
Use Retrieve an order as your source of truth if an event is delayed or missed.
Public HTTPS endpoints are required. Private networks, localhost, and redirects are not supported.
Events: order.accepted, order.delivered, order.cancelled, catalogue.changed, and test. Events have id, event, created_at, and data fields. Order events contain data.order.
Failed delivery retries start at 1 minute, double up to an hour, and stop after 12 attempts or 24 hours. Events may arrive out of order; retrieve the order for its current state.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody must be the exact received bytes, before JSON parsing.
function verifyWebhook(headers, rawBody, secret) {
const timestamp = headers.get('X-Webhook-Timestamp') || '';
const eventId = headers.get('X-Webhook-Id') || '';
const signature = headers.get('X-Webhook-Signature') || '';
if (!/^\d+$/.test(timestamp) || !eventId) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
return false;
if (!/^[a-f0-9]{64}$/.test(signature)) return false;
const expected = createHmac('sha256', secret)
.update(timestamp + '.' + eventId + '.')
.update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}
// After verification, deduplicate the event ID in durable storage.Webhooks
Webhook health
Check your endpoint and recent delivery health.
webhooks:readawait call('GET', '/v1/webhook');{
"webhook_url": "https://your-shop.example/webhooks/decorate",
"pending": 0,
"failed": 0,
"last_delivered_at": "2026-10-15T12:05:00Z",
"last_failure_at": null,
"last_error": null
}Uses the signed call helper from Your first request. Example values are illustrative.
Webhooks
Set an endpoint
Send events to your public HTTPS endpoint.
webhooks:writeBody
urlstring | nullrequiredA public HTTPS endpoint that accepts JSON POST requests, or null to stop deliveries.
The response includes a signing secret only when a new endpoint is created. Save it immediately. Use the dashboard to rotate the separate webhook secret.
Changing the endpoint URL cancels deliveries queued for the previous destination.
await call('POST', '/v1/webhook', {
"url": "https://your-shop.example/webhooks/decorate"
});Uses the signed call helper from Your first request. Example values are illustrative.
Webhooks
Remove an endpoint
Stop webhook deliveries for your account.
webhooks:writeawait call('DELETE', '/v1/webhook');Uses the signed call helper from Your first request. Example values are illustrative.
Webhooks
Send a test event
Check your endpoint and signature verification end to end.
webhooks:writeThis sends a test event to your saved endpoint. It does not create an order or spend balance.
await call('POST', '/v1/webhook/test');{
"ok": true,
"queued": true
}Uses the signed call helper from Your first request. Example values are illustrative.
Ready to build?
Sign in with Discord, create a key, and make your first signed request.