/api/v1Discovery document
Returns scopes, quotas, webhook events, and the full operations catalog, including stock. Any valid API key works.
The Seller Open API is for sellers who want Discord alerts, stock sync, Zapier-style automation, or a custom back office on top of RMT.GG.
Create an API key in Developer settings, then call discovery to print the live catalog.
/api/v1Returns scopes, quotas, webhook events, and the full operations catalog, including stock. Any valid API key works.
Send your live secret key on every /api/v1 request. Prefer HTTPS only. Never embed keys in public clients or browser bundles.
Preferred header
Authorization: Bearer rmt_sk_live_<prefix>_<secret>Alternate header
X-Api-Key: rmt_sk_live_<prefix>_<secret>Reusable TypeScript client (Bearer auth, typed errors, 429 retry)
const API_BASE = "https://rmt.gg/api/v1";
const API_KEY = process.env.RMT_API_KEY!; // rmt_sk_live_…
export class RmtApiError extends Error {
constructor(
readonly status: number,
readonly code: string | undefined,
message: string,
readonly retryAfterSec?: number,
) {
super(message);
this.name = "RmtApiError";
}
}
type RmtFetchInit = RequestInit & { idempotencyKey?: string };
export async function rmtFetch<T>(path: string, init: RmtFetchInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${API_KEY}`);
// Alternate: headers.set("X-Api-Key", API_KEY);
headers.set("Accept", "application/json");
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (init.idempotencyKey) headers.set("Idempotency-Key", init.idempotencyKey);
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
const retryAfter = Number(res.headers.get("Retry-After") ?? "");
const body = (await res.json().catch(() => ({}))) as {
error?: string;
code?: string;
retryAfter?: number;
};
if (res.status === 429) {
throw new RmtApiError(
429,
body.code ?? "RATE_LIMITED",
body.error ?? "Rate limited",
Number.isFinite(retryAfter) ? retryAfter : body.retryAfter,
);
}
if (!res.ok) {
throw new RmtApiError(res.status, body.code, body.error ?? res.statusText);
}
return body as T;
}
export async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 4): Promise<T> {
let attempt = 0;
for (;;) {
try {
return await fn();
} catch (err) {
attempt += 1;
if (!(err instanceof RmtApiError) || err.status !== 429 || attempt >= maxAttempts) {
throw err;
}
const waitSec = Math.max(1, err.retryAfterSec ?? 1);
await new Promise((r) => setTimeout(r, waitSec * 1000));
}
}
}
Rotate on leak
If a key leaks, revoke it in Developer settings and create a new one. Update your automation before revoking if you are live.
Each API key carries scopes that gate endpoints. Missing scope returns 403 SCOPE_MISSING.
offers:read: List and get your offers, plus stock counts and delivery field names.offers:write: Create, update, publish, and delete offers. Restock quantity and saved items.orders:read: List and get seller orders.orders:write: Mark orders delivered.webhooks:manage: Reserved for future Open API webhook management. Configure Discord/Telegram in Notifications and JSON webhooks in Hosted checkout or Developer settings today.checkout:write: Create and read hosted checkout sessions. Requires admin-approved partner checkout.Default key scopes
New keys on Developer receive offers:read, offers:write, orders:read, orders:write, and checkout:write. Hosted checkout keys from that settings page get checkout:write and orders:read. Outbound webhook CRUD stays in the Settings UI (session auth).
Offer identifiers accept the public url slug or numeric id. Responses omit internal id and sellerId.
What PATCH cannot change yet
Option prices, media, and attributes are still managed in the seller editor. Inventory uses GET /api/v1/stock and POST /api/v1/offers/:url/stock.
/api/v1/offersFilter with archive=active (default), archived, or all.
Request
archive| Name | In | Type | Required | Description |
|---|---|---|---|---|
archive | query | string | Optional | One of "active" (default), "archived", or "all". |
/api/v1/offersCreates an empty draft owned by the authenticated seller. No body required.
/api/v1/offers/:urlOrIdLoad by public url slug or numeric id. Relations (options) may be included; stock items are not.
Request
urlOrId| Name | In | Type | Required | Description |
|---|---|---|---|---|
urlOrId | path | string | Required | Offer.url slug or Offer.id. |
/api/v1/offers/:urlOrIdPatch a safe subset of listing fields. Emits offer.updated when outbound webhooks are configured.
Request
urlOrIdtitledescriptionvisibilitycategoryIdofferingIdthumbnailofferTypelistingMode| Name | In | Type | Required | Description |
|---|---|---|---|---|
urlOrId | path | string | Required | Offer.url slug or Offer.id. |
title | body | string | Optional | Listing title. |
description | body | string | Optional | Listing description. |
visibility | body | string | Optional | PUBLIC | PRIVATE | UNPUBLISHED. |
categoryId | body | number | Optional | Catalog category id. |
offeringId | body | number | Optional | Catalog offering id. |
thumbnail | body | string | Optional | Thumbnail URL or asset reference. |
offerType | body | string | Optional | Offer type string used by the listing. |
listingMode | body | string | Optional | Listing mode (for example STANDARD, RANK_BOOST, SESSION). |
/api/v1/offers/:urlOrIdSame delete/archive rules as the seller UI.
Request
urlOrId| Name | In | Type | Required | Description |
|---|---|---|---|---|
urlOrId | path | string | Required | Offer.url slug or Offer.id. |
/api/v1/offers/:urlOrId/publishPublishes a draft (or changes visibility). Fails with 400 if required listing fields are incomplete.
Request
urlOrIdvisibility| Name | In | Type | Required | Description |
|---|---|---|---|---|
urlOrId | path | string | Required | Offer.url slug or Offer.id. |
visibility | body | string | Optional | Optional. PUBLIC (default), PRIVATE, or UNPUBLISHED. |
See buyable counts per tier, match delivery field names to the right listing, then restock quantity or saved keys and accounts.
How matching works
GET /api/v1/stock?fields=username,password finds listings whose schema has those fields. Restock with option names (or optionId) and field names. You do not need internal field ids. Responses never include credential values.
/api/v1/stockReturns per-tier counts and delivery field names so you can match keys and accounts to the right offer. Filter with q, fields, stockMode, and lowStock. Never returns credential values.
Request
qfieldsstockModelowStockarchive| Name | In | Type | Required | Limits | Description |
|---|---|---|---|---|---|
q | query | string | Optional | Max 80 | Filter by listing title or url slug. |
fields | query | string | Optional | Comma-separated delivery field names. The listing must have all of them (Username,Password). Names match case-insensitively. | |
stockMode | query | string | Optional | QUANTITY or COMPLEX. Listing must have at least one option in that mode. | |
lowStock | query | number | Optional | Keep listings that have a finite tier with available less than or equal to this number. | |
archive | query | string | Optional | One of "active" (default), "archived", or "all". |
/api/v1/offers/:urlOrId/stockSame StockOffer shape as the index, for one url or numeric id. Counts only.
Request
urlOrId| Name | In | Type | Required | Description |
|---|---|---|---|---|
urlOrId | path | string | Required | Offer.url slug or Offer.id. |
/api/v1/offers/:urlOrId/stockQuantity tiers: add, remove, or set. Saved-item tiers: items objects by field name, keys[] when there is one field, or delimited text. Several tiers in one call via options[]. dryRun previews matching. onDuplicate defaults to skip.
{
"option": "1 Month",
"add": 50
}Request
urlOrIdoptionoptionIdaddremovesetitemskeystextdelimiterheadersoptionsdryRunonDuplicate| Name | In | Type | Required | Limits | Description |
|---|---|---|---|---|---|
urlOrId | path | string | Required | Offer.url slug or Offer.id. | |
option | body | string | Conditional | Pricing option name (case-insensitive). Omit when the listing has a single tier. | |
optionId | body | number | Conditional | Pricing option id from GET stock. Wins over option when both are sent. Ambiguous names return 409 OPTION_AMBIGUOUS. | |
add | body | number | Conditional | 1-1,000,000 | QUANTITY: add this many units. Fails with 400 UNLIMITED_STOCK if the tier is unlimited. |
remove | body | number | Conditional | 1-1,000,000 | QUANTITY: withdraw this many units. Fails with 400 INSUFFICIENT_STOCK when there is not enough. |
set | body | number | null | Conditional | QUANTITY: set an absolute count. null means unlimited. Cannot go below units held in checkout. | |
items | body | object[] | Conditional | Max 1,000 | COMPLEX: objects keyed by delivery field name, for example { "Username": "a", "Password": "b" }. Names match case-insensitively. |
keys | body | string[] | Conditional | Max 1,000 | COMPLEX: license keys when the listing has exactly one delivery field. Otherwise 400 FIELD_MAPPING_AMBIGUOUS. |
text | body | string | Conditional | Max 1,000 rows | COMPLEX: delimited paste. A header row that matches field names is detected automatically. Otherwise columns map in field sort order when the column count matches. |
delimiter | body | string | Optional | Default : | Delimiter for text. Ignored unless text is sent. |
headers | body | string[] | Optional | Optional column headers for text when the first line is data, not names. | |
options | body | object[] | Conditional | Restock several tiers in one call. Each element is the same shape as a single-option body (option, add, items, …). | |
dryRun | body | boolean | Optional | Preview matching and counts without writing. Default false. | |
onDuplicate | body | string | Optional | skip (default) or error | COMPLEX: skip existing unsold fingerprints, or fail the request with 409 DUPLICATE_ITEMS. |
POST /api/v1/offers/:url/stock with items[] for accounts or keys[] for single-field license codes. Chunk to 1,000 rows per request.
Pick the payload that matches the listing
Several delivery fields: items objects (Username, Password, E-Mail). Exactly one field: keys[]. Quantity listings: add, remove, or set. Never mix those actions on one option.
1,000 rows per request. 5,000 unsold items per tier. 300 requests per minute. Duplicates skip by default.
items[] JSON
[
{ "Username": "player1", "Password": "secret1", "E-Mail": "[email protected]" },
{ "Username": "player2", "Password": "secret2", "E-Mail": "[email protected]" }
]CSV in the text field
Username,Password,E-Mail
player1,secret1,p1@example.com
player2,secret2,p2@example.comkeys[] (one per line)
AAAA-BBBB-CCCC
DDDD-EEEE-FFFF
GGGG-HHHH-IIIIcURL
# Inspect field names and stockMode
curl -s -H "Authorization: Bearer rmt_sk_live_…" \
-H "Accept: application/json" \
https://rmt.gg/api/v1/offers/YOUR_OFFER_URL/stock
# Preview (no write)
curl -s -X POST \
-H "Authorization: Bearer rmt_sk_live_…" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
https://rmt.gg/api/v1/offers/YOUR_OFFER_URL/stock \
-d '{"dryRun":true,"onDuplicate":"skip","option":"Premium","items":[{"Username":"player1","Password":"secret1","E-Mail":"[email protected]"}]}'
# Apply accounts
curl -s -X POST \
-H "Authorization: Bearer rmt_sk_live_…" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
https://rmt.gg/api/v1/offers/YOUR_OFFER_URL/stock \
-d '{"onDuplicate":"skip","option":"Premium","items":[{"Username":"player1","Password":"secret1","E-Mail":"[email protected]"}]}'
# Apply license keys (listing must have exactly one delivery field)
curl -s -X POST \
-H "Authorization: Bearer rmt_sk_live_…" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
https://rmt.gg/api/v1/offers/YOUR_OFFER_URL/stock \
-d '{"option":"Steam","keys":["AAAA-BBBB-CCCC","DDDD-EEEE-FFFF"]}'
# Or paste CSV / colon-separated rows in text
curl -s -X POST \
-H "Authorization: Bearer rmt_sk_live_…" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
https://rmt.gg/api/v1/offers/YOUR_OFFER_URL/stock \
-d '{"option":"Premium","delimiter":",","text":"Username,Password,E-Mail\nplayer1,secret1,[email protected]"}'TypeScript with rmtFetch from Auth (1,000 rows per request)
// Paste rmtFetch and withRetry from the Auth section first.
const CHUNK = 1000;
async function importRows(offerUrl: string, option: string, rows: Array<Record<string, string>>) {
for (let i = 0; i < rows.length; i += CHUNK) {
const items = rows.slice(i, i + CHUNK);
const preview = await rmtFetch<{
results: Array<{ wouldImport: number; skippedDuplicates: number; errors: string[] }>;
}>(`/offers/${offerUrl}/stock`, {
method: "POST",
body: JSON.stringify({ dryRun: true, onDuplicate: "skip", option, items }),
});
const row = preview.results[0];
if ((row?.errors?.length ?? 0) > 0) {
throw new Error(row.errors.join("; "));
}
await withRetry(() =>
rmtFetch(`/offers/${offerUrl}/stock`, {
method: "POST",
body: JSON.stringify({ onDuplicate: "skip", option, items }),
}),
);
}
}
// License keys: only when GET stock.fields has exactly one name
async function importKeys(offerUrl: string, option: string, keys: string[]) {
for (let i = 0; i < keys.length; i += CHUNK) {
await withRetry(() =>
rmtFetch(`/offers/${offerUrl}/stock`, {
method: "POST",
body: JSON.stringify({ option, keys: keys.slice(i, i + CHUNK) }),
}),
);
}
}
Keep credentials off the wire logs
offers:write is required. You can only restock your own listings. GET and POST responses never echo saved values. Use HTTPS in production, store the key in an environment variable, and do not log the request body.
On Windows, use curl.exe (not the curl alias). Quote the -d JSON so PowerShell does not split it.
Orders are scoped to your seller account. Buyer billing details may be redacted under marketplace-of-record privacy rules.
/api/v1/ordersSupports limit, offset, status, q, and sort (newest, oldest, total_high, total_low).
Request
limitoffsetstatusqsort| Name | In | Type | Required | Limits | Description |
|---|---|---|---|---|---|
limit | query | number | Optional | 1-100, default 20 | Page size. |
offset | query | number | Optional | >= 0, default 0 | Skip this many rows. |
status | query | string | Optional | Max 32 | Filter by order status (for example PAID, DELIVERED, COMPLETED). |
q | query | string | Optional | Max 80 | Search reference or related text. |
sort | query | string | Optional | newest (default) | newest | oldest | total_high | total_low. |
/api/v1/orders/:uidReturns the order with line items. Use the public order uid.
Request
uid| Name | In | Type | Required | Description |
|---|---|---|---|---|
uid | path | string | Required | Order.uid. |
/api/v1/orders/:uid/deliverManual fulfillment. COMPLEX lines must be fully attached when required. Emits order.delivered.
Request
uidevidence| Name | In | Type | Required | Limits | Description |
|---|---|---|---|---|---|
uid | path | string | Required | Order.uid. | |
evidence | body | string[] | Optional | HTTPS, max 10 | Optional screenshot or transfer-proof URLs. |
Any approved partner shop or backend can send buyers to an RMT.GG pay page. We stay merchant of record and take 4% of the locked amount.
Allowlist and fulfillment
Apply under Settings, Hosted checkout, then create an API key and JSON webhook there. After payment we emit checkout.completed. Delivery values stay on the RMT.GG confirmation; they are not in seller GET or webhooks.
/api/v1/checkout/sessionsSend buyers to a locked RMT.GG pay page. One item: amount and itemName. Cart: items[] with name and amount on each line. Currency defaults to USD. After payment the buyer stays on RMT.GG when there are delivery fields to copy. returnUrl continues to the shop; with no delivery we send them back after a short countdown. Amount, lengths, and other caps are in the Limits column.
{
amount: 10, // what the buyer pays
itemName: "Gold pack", // pay page heading
}Request
amountcurrencyitemNametitledescriptionimageUrlitemsitems[].nameitems[].titleitems[].descriptionitems[].amountitems[].quantityitems[].imageUrlitems[].deliveryitems[].delivery[].nameitems[].delivery[].typeitems[].delivery[].valueemailreturnUrlcancelUrlinvoiceIdcategorySlugofferingmetadataIdempotency-Key| Name | In | Type | Required | Limits | Description |
|---|---|---|---|---|---|
amount | body | number | Conditional | > 0, max 1,000,000 | What the buyer pays. Required for a single item. With items[], omit it or send the line sum. Mismatch: 400 AMOUNT_MISMATCH. |
currency | body | string | Optional | Default USD | ISO 4217 code such as USD or EUR. |
itemName | body | string | Conditional | Max 120 | Pay page heading. Required for a single item. Alias: title. With items[], defaults to the first line name. Missing: 400 ITEM_NAME_REQUIRED. |
title | body | string | Optional | Alias of itemName. If both are sent, itemName wins. | |
description | body | string | Optional | Max 200 | Copy under the heading. If omitted, the heading is reused. |
imageUrl | body | string | Optional | HTTPS, max 2048 | Product image, or fallback for lines without imageUrl. Invalid: 400 INVALID_IMAGE_URL. |
items | body | object[] | Conditional | 1-20 lines, JSON max 48,000 | Locked cart. Required when amount is omitted. Buyers cannot change lines. Empty: 400 INVALID_ITEMS. |
items[].name | body | string | Required | Max 120 | Line title. Alias: title. |
items[].title | body | string | Optional | Alias of items[].name. If both are sent, name wins. | |
items[].description | body | string | Optional | Max 200 | Line copy under the name. |
items[].amount | body | number | Required | > 0, max 1,000,000 | Unit price. Session total is sum(amount * quantity). |
items[].quantity | body | number | Optional | 1-99, default 1 | Locked on the pay page. |
items[].imageUrl | body | string | Optional | HTTPS, max 2048 | Line image. Falls back to top-level imageUrl. |
items[].delivery | body | object[] | Optional | Max 16 fields | Shown after payment on RMT.GG. Seller GET and webhooks omit values. |
items[].delivery[].name | body | string | Required | Max 80 | Field label, for example Code or Password. |
items[].delivery[].type | body | string | Optional | text, password, textarea | password is blurred until the buyer reveals it. Default text. |
items[].delivery[].value | body | string | Required | Max 2048 | Field value. Numbers are stored as strings. Empty: 400 INVALID_DELIVERY. |
email | body | string | Optional | Invalid values ignored | Prefills the pay page. The buyer still confirms email before paying. |
returnUrl | body | string | Optional | HTTPS, max 2048 | Continue-to-shop after payment. Delivery fields keep the buyer on RMT.GG with a button. No delivery: we send them back after a short countdown. If omitted, there is no shop button. http://localhost is allowed for local shops. |
cancelUrl | body | string | Optional | HTTPS, max 2048 | Redirect if the buyer cancels or the session expires. If omitted, they stay on the pay page. |
invoiceId | body | string | Optional | Max 128 | Your shop id. Same payload returns the existing session. A different payload: 409 INVOICE_CONFLICT. |
categorySlug | body | string | Conditional | With offering, or omit both | Public root slug such as games. Used for card, PayPal, and crypto labels, not the pay page title. Wrong pair: 400 INVALID_PSP_CATEGORY. |
offering | body | string | Conditional | With categorySlug, or omit both | Catalog offering such as Mods. Mapped to labels like Games · Add-ons. |
metadata | body | object | Optional | Object, max 4096 chars | Stored on the session. Not returned on seller GET. |
Idempotency-Key | header | string | Optional | Max 128 | Replay header. Same key and payload returns the existing session. A different payload: 409 IDEMPOTENCY_CONFLICT. |
Response
uidstatuspaidamountcurrencyitemNamedescriptionemaillangreturnUrlcancelUrlinvoiceIdexternalInvoiceIdsourceexpiresAthostedUrlorderUiditemshosted_urlexpires_at| Name | In | Type | Description |
|---|---|---|---|
uid | response | string | Session id. Same value as in hostedUrl / hosted_url. |
status | response | string | created | pending_payment | paid | canceled | expired | refunded | error. Unpaid sessions expire after 24 hours. |
paid | response | boolean | true only when status is paid. false for refunded, expired, canceled, and unpaid states. |
amount | response | number | Locked buyer total in major units. |
currency | response | string | ISO currency code stored on the session (for example USD). |
itemName | response | string | null | Pay page heading. |
description | response | string | Longer copy under the heading. |
email | response | string | null | Prefill or confirmed buyer email. Guest checkout placeholders are returned as null. |
lang | response | string | null | Buyer locale when known. Not a create-session field. |
returnUrl | response | string | null | Continue-to-shop URL stored on the session, or null. |
cancelUrl | response | string | null | Cancel/expiry redirect, or null. |
invoiceId | response | string | null | Your invoice id. Same value as externalInvoiceId. |
externalInvoiceId | response | string | null | Same as invoiceId (legacy alias). |
source | response | string | How the session was created. API sessions are "api". |
expiresAt | response | string | ISO timestamp. Unpaid checkouts cannot be completed after this time. |
hostedUrl | response | string | Pay page URL (same target as top-level hosted_url on create). |
orderUid | response | string | null | Marketplace order uid after payment. null until the session is paid. |
items | response | object[] | Locked lines: name, description, amount, quantity, imageUrl. Seller GET never includes delivery values. |
hosted_url | response | string | Pay page URL. Send the buyer here. Same target as hostedUrl. |
expires_at | response | string | ISO timestamp. Same value as expiresAt. |
/api/v1/checkout/sessions/:uidReturns the session you created. Use this if checkout.completed is delayed. paid is true only when status is paid. items never include delivery values.
Request
uid| Name | In | Type | Required | Description |
|---|---|---|---|---|
uid | path | string | Required | Session uid returned at create time. |
Response
uidstatuspaidamountcurrencyitemNamedescriptionemaillangreturnUrlcancelUrlinvoiceIdexternalInvoiceIdsourceexpiresAthostedUrlorderUiditems| Name | In | Type | Description |
|---|---|---|---|
uid | response | string | Session id. Same value as in hostedUrl / hosted_url. |
status | response | string | created | pending_payment | paid | canceled | expired | refunded | error. Unpaid sessions expire after 24 hours. |
paid | response | boolean | true only when status is paid. false for refunded, expired, canceled, and unpaid states. |
amount | response | number | Locked buyer total in major units. |
currency | response | string | ISO currency code stored on the session (for example USD). |
itemName | response | string | null | Pay page heading. |
description | response | string | Longer copy under the heading. |
email | response | string | null | Prefill or confirmed buyer email. Guest checkout placeholders are returned as null. |
lang | response | string | null | Buyer locale when known. Not a create-session field. |
returnUrl | response | string | null | Continue-to-shop URL stored on the session, or null. |
cancelUrl | response | string | null | Cancel/expiry redirect, or null. |
invoiceId | response | string | null | Your invoice id. Same value as externalInvoiceId. |
externalInvoiceId | response | string | null | Same as invoiceId (legacy alias). |
source | response | string | How the session was created. API sessions are "api". |
expiresAt | response | string | ISO timestamp. Unpaid checkouts cannot be completed after this time. |
hostedUrl | response | string | Pay page URL (same target as top-level hosted_url on create). |
orderUid | response | string | null | Marketplace order uid after payment. null until the session is paid. |
items | response | object[] | Locked lines: name, description, amount, quantity, imageUrl. Seller GET never includes delivery values. |
/api/v1/checkout/sessionsSame session object as GET by uid. Pass the invoiceId you sent at create. Missing: 400 INVOICE_ID_REQUIRED. Unknown: 404 NOT_FOUND.
Request
invoiceId| Name | In | Type | Required | Limits | Description |
|---|---|---|---|---|---|
invoiceId | query | string | Required | Max 128 | invoiceId from create. Missing: 400 INVOICE_ID_REQUIRED. Too long: 400 INVALID_INVOICE_ID. |
Response
uidstatuspaidamountcurrencyitemNamedescriptionemaillangreturnUrlcancelUrlinvoiceIdexternalInvoiceIdsourceexpiresAthostedUrlorderUiditems| Name | In | Type | Description |
|---|---|---|---|
uid | response | string | Session id. Same value as in hostedUrl / hosted_url. |
status | response | string | created | pending_payment | paid | canceled | expired | refunded | error. Unpaid sessions expire after 24 hours. |
paid | response | boolean | true only when status is paid. false for refunded, expired, canceled, and unpaid states. |
amount | response | number | Locked buyer total in major units. |
currency | response | string | ISO currency code stored on the session (for example USD). |
itemName | response | string | null | Pay page heading. |
description | response | string | Longer copy under the heading. |
email | response | string | null | Prefill or confirmed buyer email. Guest checkout placeholders are returned as null. |
lang | response | string | null | Buyer locale when known. Not a create-session field. |
returnUrl | response | string | null | Continue-to-shop URL stored on the session, or null. |
cancelUrl | response | string | null | Cancel/expiry redirect, or null. |
invoiceId | response | string | null | Your invoice id. Same value as externalInvoiceId. |
externalInvoiceId | response | string | null | Same as invoiceId (legacy alias). |
source | response | string | How the session was created. API sessions are "api". |
expiresAt | response | string | ISO timestamp. Unpaid checkouts cannot be completed after this time. |
hostedUrl | response | string | Pay page URL (same target as top-level hosted_url on create). |
orderUid | response | string | null | Marketplace order uid after payment. null until the session is paid. |
items | response | object[] | Locked lines: name, description, amount, quantity, imageUrl. Seller GET never includes delivery values. |
Configure HTTPS endpoints in Settings → Hosted checkout (or Developer). RMT POSTs when subscribed events fire.
JSON delivery envelope
{
"id": "whd_…",
"type": "order.paid",
"created": "2026-07-23T12:00:00.000Z",
"data": {
"order": {
"uid": "ord_…",
"reference": "RMT-…",
"status": "PAID",
"url": "https://rmt.gg/orders/ord_…",
"items": [ /* line items with offer names */ ]
}
}
}Signed delivery headers
{
"X-RMT-Event": "order.paid",
"X-RMT-Delivery": "whd_…",
"X-RMT-Timestamp": "1710000000",
"X-RMT-Signature": "v1=abc123…"
}checkout.completed payload
{
"id": "whd_…",
"type": "checkout.completed",
"created": "2026-08-14T12:00:00.000Z",
"data": {
"checkout": {
"uid": "pcs_…",
"status": "paid",
"amount": 10,
"currency": "USD",
"itemName": "Gold pack",
"description": "1000 gold for account example",
"invoiceId": "inv-12345",
"source": "api",
"orderUid": "ord_…",
"hostedUrl": "https://rmt.gg/pay/pcs_…",
"email": "[email protected]",
"paidAt": "2026-08-14T12:01:00.000Z",
"expiresAt": "2026-08-15T12:00:00.000Z",
"createdAt": "2026-08-14T12:00:00.000Z",
"reason": null,
"items": [
{
"name": "Gold pack",
"description": "1000 gold for account example",
"amount": 10,
"quantity": 1,
"imageUrl": "https://cdn.shop.example/gold.png"
}
]
}
}
}checkout.canceled payload
{
"id": "whd_…",
"type": "checkout.canceled",
"created": "2026-08-14T12:20:00.000Z",
"data": {
"checkout": {
"uid": "pcs_…",
"status": "canceled",
"amount": 10,
"currency": "USD",
"itemName": "Gold pack",
"description": "1000 gold for account example",
"invoiceId": "inv-12345",
"source": "api",
"orderUid": null,
"hostedUrl": "https://rmt.gg/pay/pcs_…",
"email": "[email protected]",
"paidAt": null,
"expiresAt": "2026-08-15T12:00:00.000Z",
"createdAt": "2026-08-14T12:00:00.000Z",
"reason": "buyer_canceled",
"items": [
{
"name": "Gold pack",
"description": "1000 gold for account example",
"amount": 10,
"quantity": 1,
"imageUrl": "https://cdn.shop.example/gold.png"
}
]
}
}
}Used for outbound webhooks and on-demand inventory (reserve) calls when you set a signing secret.
The secret stays on RMT. Each signed POST includes X-RMT-Timestamp (Unix seconds) and X-RMT-Signature (v1= plus hex). Compute HMAC-SHA256 over the string timestamp + '.' + rawBody using your secret, then compare to the hex after v1=. Reject timestamps older than 5 minutes.
TypeScript verification (timing-safe compare and 5-minute replay window)
import { createHmac, timingSafeEqual } from "node:crypto";
const MAX_AGE_SEC = 5 * 60; // reject replays older than 5 minutes
export function verifyRmtSignature(opts: {
secret: string;
timestamp: string | null | undefined;
signatureHeader: string | null | undefined;
rawBody: string; // exact POST bytes. Do not JSON.parse then re-stringify.
nowSec?: number;
}): boolean {
const secret = opts.secret.trim();
const timestamp = String(opts.timestamp ?? "").trim();
const provided = String(opts.signatureHeader ?? "").trim().replace(/^v1=/i, "");
if (!secret || !timestamp || !provided) return false;
const ts = Number(timestamp);
if (!Number.isInteger(ts) || ts <= 0) return false;
const nowSec = opts.nowSec ?? Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - ts) > MAX_AGE_SEC) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${opts.rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(provided.toLowerCase(), "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
// Express / Node HTTP example:
// const rawBody = (req as { rawBody?: string }).rawBody
// ?? JSON.stringify(req.body); // only if you captured the raw string first
// const ok = verifyRmtSignature({
// secret: process.env.RMT_WEBHOOK_SECRET!,
// timestamp: req.headers["x-rmt-timestamp"] as string,
// signatureHeader: req.headers["x-rmt-signature"] as string,
// rawBody,
// });
// if (!ok) return res.status(401).end();
TypeScript webhook handler
type CheckoutCompleted = {
id: string;
type: "checkout.completed";
created: string;
data: {
checkout: {
uid: string;
status: "paid";
amount: number;
currency: string;
itemName: string | null;
description: string;
invoiceId: string | null;
source: string | null;
orderUid: string | null;
hostedUrl: string;
email: string | null;
paidAt: string | null;
expiresAt: string | null;
createdAt: string | null;
reason: null;
items: Array<{
name: string;
description: string | null;
amount: number;
quantity: number;
imageUrl: string | null;
}>;
};
};
};
type CheckoutCanceled = {
type: "checkout.canceled";
data: {
checkout: {
uid: string;
status: "canceled" | "expired";
invoiceId: string | null;
reason: "buyer_canceled" | "expired";
};
};
};
export async function handleRmtWebhook(rawBody: string, headers: Headers) {
const ok = verifyRmtSignature({
secret: process.env.RMT_WEBHOOK_SECRET!,
timestamp: headers.get("x-rmt-timestamp"),
signatureHeader: headers.get("x-rmt-signature"),
rawBody,
});
if (!ok) throw new Response("Unauthorized", { status: 401 });
const event = JSON.parse(rawBody) as { type: string; data: Record<string, unknown> };
switch (event.type) {
case "checkout.completed": {
const checkout = (event as CheckoutCompleted).data.checkout;
if (!checkout.invoiceId || !checkout.paidAt) break;
await fulfillShopOrder(checkout.invoiceId, checkout.orderUid);
break;
}
case "checkout.canceled": {
const checkout = (event as CheckoutCanceled).data.checkout;
await markShopOrderCanceled(checkout.invoiceId, checkout.reason);
break;
}
case "checkout.refunded":
case "order.paid":
case "order.delivered":
break;
default:
break;
}
}
For COMPLEX (unique unit) listings, RMT POSTs your HTTPS API after payment to fetch the next license, account, or key when local stock is short.
Payment-safe failures
If your endpoint times out or returns invalid data, the order stays PAID. The buyer is charged; you see an error on the order and can retry reserve or attach keys manually.
Canonical POST body (truncated)
{
"id": "rsv_…",
"type": "reserve.item",
"order": { "uid": "ord_…", "reference": "RMT-…", "url": "https://rmt.gg/orders/ord_…" },
"offer": { "url": "my-offer", "title": "Game key", "pageUrl": "https://rmt.gg/offers/my-offer" },
"option": { "id": 1, "name": "Standard" },
"fields": [{ "id": 10, "name": "License", "type": "text", "required": true }],
"quantity": 1
}Request headers (when a signing secret is set)
{
"Content-Type": "application/json",
"X-RMT-Event": "reserve.item",
"X-RMT-Delivery": "rsv_…",
"X-RMT-Timestamp": "1710000000",
"X-RMT-Signature": "v1=abc123…"
}Convenience response
{
"entries": [
{ "name": "License", "value": "AAAA-BBBB-CCCC" }
]
}Mapped JSON fields (with responseMap paths like $.license)
{
"license": "AAAA-BBBB-CCCC",
"email": "[email protected]",
"password": "temporary-pass"
}If you set a secret on the offer, every reserve POST is signed. Recompute HMAC-SHA256(secret, timestamp + '.' + rawBody) and compare to X-RMT-Signature after stripping the v1= prefix. The secret itself is never included in the request.
See full verification exampleTypeScript reserve handler (verify, then return entries)
type ReserveRequest = {
id: string;
type: "reserve.item";
dryRun?: boolean;
quantity: number;
fields: Array<{ name: string; required?: boolean }>;
};
export async function handleReserve(rawBody: string, headers: Headers) {
const ok = verifyRmtSignature({
secret: process.env.RMT_RESERVE_SECRET!,
timestamp: headers.get("x-rmt-timestamp"),
signatureHeader: headers.get("x-rmt-signature"),
rawBody,
});
if (!ok) return new Response("Unauthorized", { status: 401 });
const body = JSON.parse(rawBody) as ReserveRequest;
if (body.type !== "reserve.item") {
return Response.json({ error: "Unexpected event" }, { status: 400 });
}
const qty = Number(body.quantity);
if (!Number.isInteger(qty) || qty < 1) {
return Response.json({ error: "Invalid quantity" }, { status: 400 });
}
if (body.dryRun) {
return Response.json({
entries: [{ name: "License", value: "TEST-AAAA-BBBB" }],
});
}
const license = await mintLicense(); // your inventory
return Response.json({
entries: [{ name: "License", value: license }],
});
}
Do not call reserve before payment
RMT only calls your endpoint after payment succeeds, so abandoned checkouts do not burn licenses.
Errors return JSON { error, code? }. Open API traffic is limited to 300 requests per minute per API key.
API_KEY_REQUIREDMissing Authorization or X-Api-Key header.
API_KEY_INVALIDKey unknown, revoked, expired, or developer access suspended.
SCOPE_MISSINGKey lacks the scope required by the endpoint.
RATE_LIMITEDToo many requests. Honor Retry-After and X-RateLimit-Reset.
CHECKOUT_PARTNER_NOT_APPROVEDThis seller is not approved for hosted checkout.
INVALID_JSONRequest body must be JSON.
INVALID_AMOUNTamount must be greater than 0 and at most 1,000,000.
UNSUPPORTED_CURRENCYcurrency is not a supported ISO code.
INVALID_RETURN_URLreturnUrl and cancelUrl must be https (http://localhost is allowed for local shops).
INVALID_PSP_CATEGORYcategorySlug and offering must be sent together and match a public catalog pair. Omit both to use your Hosted checkout default. A wrong pair returns 400 and does not create a session.
INVALID_INVOICE_IDinvoiceId is longer than 128 characters.
INVOICE_ID_REQUIREDGET /checkout/sessions requires invoiceId as a query parameter.
INVALID_IDEMPOTENCY_KEYIdempotency-Key is longer than 128 characters.
INVALID_METADATAmetadata must be a JSON object, not an array or primitive.
METADATA_TOO_LARGESerialized metadata is larger than 4096 characters.
IDEMPOTENCY_CONFLICTIdempotency-Key was reused with a different amount, currency, or item.
INVOICE_CONFLICTinvoiceId was reused with a different amount, currency, or item.
INVALID_IMAGE_URLimageUrl must be an https URL.
ITEM_NAME_REQUIREDitemName (or title) is required when items is omitted.
INVALID_ITEMSitems must be a non-empty array of locked line items (max 20). Each line needs name and amount.
TOO_MANY_ITEMSitems cannot contain more than 20 lines.
AMOUNT_MISMATCHamount must equal the sum of each line amount times quantity.
INVALID_DELIVERYdelivery fields are invalid. Each field needs a name (max 80) and value (max 2048). type must be text, password, or textarea (default text). Max 16 fields per line.
ITEMS_TOO_LARGESerialized items JSON is larger than 48,000 characters.
NOT_FOUNDNo hosted checkout session matches that uid or invoiceId for this seller.
RESERVE_FAILEDOn-demand inventory call timed out, returned invalid data, or missed required fields.
OPTION_AMBIGUOUSMore than one pricing option matches that name. Pass optionId from GET stock.
OPTION_NOT_FOUNDNo pricing option matches that id or name on this listing.
OPTION_REQUIREDThis listing has multiple pricing options. Pass option or optionId.
UNKNOWN_FIELDA field name does not match this listing's delivery schema.
FIELD_MAPPING_AMBIGUOUSCould not map columns or keys to delivery fields. Send headers, or use items objects keyed by field name.
STOCK_MODE_MISMATCHThat payload does not match the option's stock mode (quantity vs saved items).
IMPORT_TOO_LARGEA restock request may import at most 1,000 saved items per option.
OPTION_ITEM_CAPACITYThis pricing option already has the maximum of 5,000 unsold saved items.
DUPLICATE_ITEMSonDuplicate=error and at least one item already exists on this option.
UNLIMITED_STOCKThis option has unlimited quantity. Use set to switch to a finite count first.
INSUFFICIENT_STOCKNot enough quantity stock to remove.
STOCK_HELD_IN_CHECKOUTCannot lower quantity below units currently reserved in checkout.
INVALID_RESTOCKThe restock body is missing a required action, or combines add/items in one option.
| Code | HTTP | Description |
|---|---|---|
| API_KEY_REQUIRED | 401 | Missing Authorization or X-Api-Key header. |
| API_KEY_INVALID | 401 | Key unknown, revoked, expired, or developer access suspended. |
| SCOPE_MISSING | 403 | Key lacks the scope required by the endpoint. |
| RATE_LIMITED | 429 | Too many requests. Honor Retry-After and X-RateLimit-Reset. |
| CHECKOUT_PARTNER_NOT_APPROVED | 403 | This seller is not approved for hosted checkout. |
| INVALID_JSON | 400 | Request body must be JSON. |
| INVALID_AMOUNT | 400 | amount must be greater than 0 and at most 1,000,000. |
| UNSUPPORTED_CURRENCY | 400 | currency is not a supported ISO code. |
| INVALID_RETURN_URL | 400 | returnUrl and cancelUrl must be https (http://localhost is allowed for local shops). |
| INVALID_PSP_CATEGORY | 400 | categorySlug and offering must be sent together and match a public catalog pair. Omit both to use your Hosted checkout default. A wrong pair returns 400 and does not create a session. |
| INVALID_INVOICE_ID | 400 | invoiceId is longer than 128 characters. |
| INVOICE_ID_REQUIRED | 400 | GET /checkout/sessions requires invoiceId as a query parameter. |
| INVALID_IDEMPOTENCY_KEY | 400 | Idempotency-Key is longer than 128 characters. |
| INVALID_METADATA | 400 | metadata must be a JSON object, not an array or primitive. |
| METADATA_TOO_LARGE | 400 | Serialized metadata is larger than 4096 characters. |
| IDEMPOTENCY_CONFLICT | 409 | Idempotency-Key was reused with a different amount, currency, or item. |
| INVOICE_CONFLICT | 409 | invoiceId was reused with a different amount, currency, or item. |
| INVALID_IMAGE_URL | 400 | imageUrl must be an https URL. |
| ITEM_NAME_REQUIRED | 400 | itemName (or title) is required when items is omitted. |
| INVALID_ITEMS | 400 | items must be a non-empty array of locked line items (max 20). Each line needs name and amount. |
| TOO_MANY_ITEMS | 400 | items cannot contain more than 20 lines. |
| AMOUNT_MISMATCH | 400 | amount must equal the sum of each line amount times quantity. |
| INVALID_DELIVERY | 400 | delivery fields are invalid. Each field needs a name (max 80) and value (max 2048). type must be text, password, or textarea (default text). Max 16 fields per line. |
| ITEMS_TOO_LARGE | 400 | Serialized items JSON is larger than 48,000 characters. |
| NOT_FOUND | 404 | No hosted checkout session matches that uid or invoiceId for this seller. |
| RESERVE_FAILED | 400 | On-demand inventory call timed out, returned invalid data, or missed required fields. |
| OPTION_AMBIGUOUS | 409 | More than one pricing option matches that name. Pass optionId from GET stock. |
| OPTION_NOT_FOUND | 404 | No pricing option matches that id or name on this listing. |
| OPTION_REQUIRED | 400 | This listing has multiple pricing options. Pass option or optionId. |
| UNKNOWN_FIELD | 400 | A field name does not match this listing's delivery schema. |
| FIELD_MAPPING_AMBIGUOUS | 400 | Could not map columns or keys to delivery fields. Send headers, or use items objects keyed by field name. |
| STOCK_MODE_MISMATCH | 400 | That payload does not match the option's stock mode (quantity vs saved items). |
| IMPORT_TOO_LARGE | 400 | A restock request may import at most 1,000 saved items per option. |
| OPTION_ITEM_CAPACITY | 400 | This pricing option already has the maximum of 5,000 unsold saved items. |
| DUPLICATE_ITEMS | 409 | onDuplicate=error and at least one item already exists on this option. |
| UNLIMITED_STOCK | 400 | This option has unlimited quantity. Use set to switch to a finite count first. |
| INSUFFICIENT_STOCK | 400 | Not enough quantity stock to remove. |
| STOCK_HELD_IN_CHECKOUT | 400 | Cannot lower quantity below units currently reserved in checkout. |
| INVALID_RESTOCK | 400 | The restock body is missing a required action, or combines add/items in one option. |
Handle 429
Back off using Retry-After seconds. Do not rotate keys to bypass limits; the limit is per key and flat for all sellers.
Successful responses include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset.
Create a key in Developer settings, and connect Discord or Telegram under Notifications.