decorate.ggDOCS

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/v1

What you get

Markdown OpenAPI

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-Keystringrequired

The public ID of your API key.

X-Api-Timestampintegerrequired

Current Unix time in seconds, within 60 seconds of our clock. Keep your server clock synchronized.

X-Api-Noncestringrequired

A fresh 32–64 character lowercase hexadecimal value. The helper generates 16 random bytes as hex.

X-Api-Signaturestringrequired

Lowercase 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.

String to sign
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 request

Check the required fields, item quantities, and parameter formats.

401Authentication failed

Check your key, signature, timestamp, and nonce.

403Access denied

The key lacks a required scope or the account is suspended.

404Not found

The item or order does not exist, or the order is not yours.

409Conflict

Re-quote a changed price or unavailable cart. Reusing an idempotency key with a different request is rejected.

402Insufficient balance

Add funds before retrying the order.

429Rate limited

Wait for the Retry-After interval before making another request.

408Request timed out

The request body did not finish within 5 seconds. Retry with a fresh timestamp and nonce.

413Body too large

Keep the complete UTF-8 request body at or below 16 KiB.

503Store unavailable

The store is temporarily closed. Check service status before retrying an order.

402 Payment Required
{
  "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
curl https://api.decorate.gg/v1/status
200 OK
{
  "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.

await call('GET', '/v1/ping');
200 OK
{
  "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.

await call('GET', '/v1/balance');
200 OK
{
  "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.

Query and path parameters

searchstring

Filter by item name.

categorystring

Filter by collection name (case-insensitive contains).

typestring

One of decoration, effect, nameplate, frame, or bundle.

limitinteger

1–2,000 items; defaults to 100.

offsetinteger

Number 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');
200 OK
{
  "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.

Query and path parameters

idstring · pathrequired

An item ID from the catalogue.

await call('GET', '/v1/catalogue/{id}');
200 OK
{
  "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.

Body

itemsarrayrequired

Between 1 and 25 distinct items. Maximum order total: 500,000 cents ($5,000).

items[].idstringrequired

An item ID from the catalogue.

items[].qtyinteger

Quantity from 1 to 10; defaults to 1.

items[].expected_price_centsinteger

The 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
    }
  ]
});
200 OK
{
  "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.

Body

idempotency_keystringrequired

Your unique reference for this purchase: 8–100 letters, numbers, periods, underscores, colons, or hyphens. Reuse it only when retrying the same order.

itemsarrayrequired

The 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
    }
  ]
});
200 OK
{
  "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.

Query and path parameters

limitinteger

1–100 orders; defaults to 25.

offsetinteger

Number 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');
200 OK
{
  "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.

Query and path parameters

idstring · pathrequired

Your 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}');
200 OK
{
  "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.

await call('GET', '/v1/webhook');
200 OK
{
  "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.

Body

urlstring | nullrequired

A public HTTPS endpoint that accepts JSON POST requests, or null to stop deliveries.

Setting a URL requires both webhooks:write and orders:read because the destination receives your private order details and delivered gift links. Sending null only requires webhooks:write.

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.

await 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.

This sends a test event to your saved endpoint. It does not create an order or spend balance.

await call('POST', '/v1/webhook/test');
200 OK
{
  "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.

Get an API key