# MM2 Empire for Business — Partner API documentation
> The complete partner documentation as a single Markdown file, for AI assistants and offline reading. Money is in USD cents; the API is REST over HTTPS authenticated with an `X-Partner-Key` header.
# Introduction
> What MM2Empire for Business is, the two products it offers, and how a partner integration fits together.
## What this is
MM2Empire for Business lets your platform use Murder Mystery 2 items as money. Your users can **pay you** by selling their items to you, and you can **pay your users out** by buying items for them, without you holding a single item, running a Roblox account or operating a bot. We own the bot fleet, carry the stock and handle every in-game trade. You make API calls and receive webhooks.
Everything is priced and settled in **US dollars, as integer cents**. There are no coins on the partner rails and no exchange rate to track: a payout of `1240` is $12.40.
| Product | Direction | What happens |
| --- | --- | --- |
| [MM2 Payments](https://partner.mm2empire.com/en/docs/payments) | Your user pays you | Your user sells items through a pop-up on your site (or your own screens). We take the item, and you are credited in USD to spend, withdraw, or credit to your user however you like. |
| [MM2 Payouts](https://partner.mm2empire.com/en/docs/payouts) | You pay your user | Your user picks items from our live marketplace (or you place the order server-side). Your float pays for them and our bots deliver the items to their Roblox account. |
## How an integration fits together
Three surfaces make up a partner integration. Each is reached with a different credential, and none of them can stand in for another.
| Surface | Who uses it | Credential |
| --- | --- | --- |
| **Partner console** at partner.mm2empire.com | The people at your company | A seat login: username, password and an authenticator app. See the [console guide](https://partner.mm2empire.com/en/docs/console). |
| **Partner API** at `https://api.mm2empire.com/partner/v1/…` | Your server | An [API key](https://partner.mm2empire.com/en/docs/authentication) sent as `X-Partner-Key`. Never used from a browser. |
| **The pop-up** (embedded on your site) | Your end users | A one-time session token your server mints for one customer. It is exchanged for a short-lived bearer the moment the pop-up opens. |
The typical shape: your server links a customer, mints a session and hands the token to the pop-up on your page. Your user acts inside the pop-up. We tell your server what happened through a signed [webhook](https://partner.mm2empire.com/en/docs/webhooks), and that webhook is the record you credit or release your user from. Everything is also readable back through the API, so a reconciliation job never has to trust its own memory.
## How item delivery actually works
Roblox does not allow items to be handed to a player who is not present, so every item movement is **pull-based**. When your user deposits, they join one of our bots in-game and trade the items to it. When we deliver a payout, the item is placed with a bot and your user joins that bot to collect it.
> **Design around this:** Never show a user "delivered" on the strength of an order being placed, and never show "deposited" on the strength of a deposit session being opened. An item has arrived when it appears in the customer's inventory, and a payout has landed when its withdrawal reports `completed`. Both come to you as webhooks.
Item copies are fungible: we guarantee the item type, not one specific copy. Large deliveries are split across several trades because Roblox caps how much can move in one, and a single order can hold at most four distinct item types.
## Before you start
- An approved partner account. Applications are reviewed by hand; apply from the partner site and we will be in touch.
- A seat in the console. Your first seat is claimed with a **setup code** we hand you, then a password and an authenticator app.
- A server that can keep a secret and receive HTTPS requests. Your API key and your webhook signing secret live there and nowhere else.
Ready? The [quickstart](https://partner.mm2empire.com/en/docs/quickstart) takes you from a fresh key to a working pop-up.
---
# Quickstart
> From an approved account to a live pop-up taking your first payment, in six steps.
## The six steps
1. **Sign in and create an API key** — In the console, open **Integration → API keys** and create a key with a label such as `production`. The secret is shown exactly once. Store it in your secrets manager; the console can only ever show you the key id afterwards.
2. **Prove the key works** — Call `GET /partner/v1/ping` with the key. The response names your company and the key id it resolved to, plus our server time so you can check your clock before you send anything that moves money.
3. **Link a customer** — Every person you act for has to be linked to your company first. `POST /partner/v1/payments/customers` with their Roblox username or id and, ideally, your own `externalRef` for them. From then on any of those identifiers works as a `customerRef`.
4. **Open a session and embed the pop-up** — Your server calls `POST /partner/v1/payments/sessions` for that customer and passes the returned `token` to `MM2Payments.open(...)` on your page. Your user sells inside the pop-up.
5. **Receive the webhook** — Set an endpoint under **MM2 Payments → Integration**. Every completed payment arrives as a signed `payment.completed` event, and the order it belongs to as `order.completed`. Credit your user from those, not from the pop-up's callback.
6. **Turn buying on** — A new company starts with MM2 Payments switched **off**: the pop-up loads but refuses every sale until an owner turns buying on under **MM2 Payments → Pool**. Do that last, once your webhook receiver is live.
## 1. Ping
```bash title="curl"
curl https://api.mm2empire.com/partner/v1/ping \
-H "X-Partner-Key: $MM2_PARTNER_KEY"
```
```json title="200 OK"
{
"partnerSlug": "wild-trading-co",
"partnerName": "Wild Trading Co",
"keyId": "pk_live_3f9a…",
"serverTime": "2026-09-05T13:20:11.482910+00:00"
}
```
## 2. Link a customer
```bash title="curl"
curl -X POST https://api.mm2empire.com/partner/v1/payments/customers \
-H "X-Partner-Key: $MM2_PARTNER_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "robloxUsername": "xXShadowXx", "externalRef": "user_48213" }'
```
Send either `robloxUsername` or `robloxId`, never both. `externalRef` is your own id for the person; it must be unique within your company and it is what you will most often pass as `customerRef` later. Linking the same person twice simply refreshes the stored username.
## 3. Open a session and show the pop-up
```javascript title="Your server (Node)"
const response = await fetch('https://api.mm2empire.com/partner/v1/payments/sessions', {
method: 'POST',
headers: {
'X-Partner-Key': process.env.MM2_PARTNER_KEY,
'Idempotency-Key': crypto.randomUUID(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
customerRef: 'user_48213', // your externalRef, their Roblox id, or their username
orderId: 'dep_9f2c1', // your own id for this top-up, echoed on every webhook
metadata: { source: 'wallet' }, // flat key/value, up to 20 keys
}),
})
const session = await response.json()
// Hand session.token to the browser. Never hand it the API key.
```
```html title="Your page"
```
> **Note:** The token is single-use and expires five minutes after it is minted, so mint it when the user clicks, not when the page loads. `onComplete` is for updating your UI; the signed webhook is the record.
## What next
- Decide [how MM2 Payments is funded](https://partner.mm2empire.com/en/docs/payments#funding-sources). The default needs no float and no pricing.
- Read the [webhook guide](https://partner.mm2empire.com/en/docs/webhooks) and verify signatures before you rely on an event.
- If you also pay users out in items, continue with [MM2 Payouts](https://partner.mm2empire.com/en/docs/payouts).
- Connect a [Discord channel](https://partner.mm2empire.com/en/docs/discord-notifications) so your team sees activity without watching the console.
---
# Authentication & API keys
> API keys, the header they travel in, what a key can and cannot do, and how the three credentials stay apart.
## API keys
Your server authenticates to `https://api.mm2empire.com/partner/v1/…` with an API key in the `X-Partner-Key` header. Keys are created and revoked by an **owner** seat in the console under Integration → API keys, and by our staff on request. The secret is shown once, at creation; we store only a hash of it.
```http title="Every request"
GET /partner/v1/ping HTTP/1.1
Host: api.mm2empire.com
X-Partner-Key: pk_live_3f9a…
```
- The secret begins with the key id, so a log line or a support thread can name a key without exposing it.
- A company may hold several keys at once, one per environment or service. Rate limits and idempotency are scoped to the **company**, not the key, so rotating a key mid-retry still replays the first attempt rather than executing it twice.
- Revoking a key is immediate. The key stays in your list for recognition, marked revoked, and the console records whether your integration has ever used it.
> **Never in a browser:** An API key is a server credential. It can open sessions for any of your customers and, with your float funded, spend your money. The pop-up never needs it: your server mints a one-time session token and the browser only ever sees that.
## When a key is refused
| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `partner_key_invalid` | The header is missing, or the key is unknown or revoked. The message is the same in every case, on purpose. |
| 403 | `partner_account_inactive` | The key is genuine but your company is suspended. Every seat and key is refused until the account is reinstated. |
Both arrive in the standard [error envelope](https://partner.mm2empire.com/en/docs/errors). A `401` is never worth retrying; fix the credential.
## Three credentials that never mix
| Credential | Reaches | Does not reach |
| --- | --- | --- |
| API key (`X-Partner-Key`) | The machine rail, `/partner/v1/…` | The console. A leaked key cannot mint more keys, move your balance or change settings. |
| Seat session (console login) | Everything a person may read and change about your company | The machine rail. A portal token is refused there. |
| Pop-up session token | One customer's pop-up, for one session | Anything else. It is exchanged once and expires. |
The split is what makes a stolen credential survivable: each one can only do the job it exists for.
## Seats and roles
A company has seats, each a person with their own login and authenticator. An **owner** can move money, change product settings, create and revoke API keys, and set where notifications go. A **member** can read everything an owner can, including the delivery log, but cannot change anything that costs money or changes the integration.
A new seat is claimed with a setup code we issue, after which the person chooses a password and enrols an authenticator app. Recovery codes are shown once at enrolment.
---
# Core concepts
> The handful of ideas every endpoint is built on: money in cents, your balance buckets, customers, item stacks and idempotency.
## Money
Every amount on the partner rails is an **integer number of US cents**, and every field carrying one says so in its name: `payoutCents`, `chargedCents`, `priceCents`. Responses that carry money also carry `"currency": "USD"` so nothing has to be inferred. There are no floats and no coins.
Your company holds money in one prepaid balance with named buckets. Funding it, and taking money out, happens in the console; the API only ever spends from it or credits it.
| Bucket | What it is |
| --- | --- |
| **Spendable** | Money you can move into a pool, or withdraw. Top-ups land here. |
| **Payments pool** | Money reserved for buying items from your users when you fund MM2 Payments yourself. A sale debits it. |
| **Payouts float** | Money reserved for buying items for your users. A payout order debits it. Kept separate from the Payments pool unless you choose to share one. |
| **Claimable** | Money MM2Empire has credited you for payments it funded, waiting for an owner to claim it into spendable. Owed from the moment the item moves. |
## Customers
A customer is one of your users, identified by their **Roblox account** and linked to your company. Linking creates an MM2Empire account for that Roblox id if none exists, or attaches to the one that does, so a person who deposits before ever signing up on MM2Empire and one who already has an account both resolve to the same place.
Wherever the API takes a `customerRef` it accepts any of four identifiers, all resolved **within your company only**: your own `externalRef`, the Roblox id, the Roblox username, or the `customerId` we returned. You cannot address a person you have not linked, and a person linked to another partner reads as not found in exactly the same words as one who does not exist.
> **Items only ever go to their owner:** No request on the partner API has a destination field. A payout is delivered to the Roblox account on the customer link, and a withdrawal returns items to the account that owns them. A compromised key cannot route anything anywhere else.
## Items and stacks
Items are named by their catalogue id, `itemValueId` (for example `chroma_luger`). Because copies of one item are interchangeable, the API works in **stacks**: `{ itemValueId, quantity }` for things a customer holds, and `{ itemValueId, priceCents, quantity }` for things on the marketplace. You never see or name an individual copy.
Each item also has a catalogue **value** in cents (`itemValueCents`), which is the reference figure MM2 Payments prices are expressed against, and a rarity and type you can filter on.
## Idempotency
Every request that creates or moves something takes an `Idempotency-Key` header: a string of up to 128 characters that you generate, such as a UUID. Replaying a request with the same key returns the original response without doing the work again, so a timeout on your side is safe to retry.
| Situation | Result |
| --- | --- |
| Same key, same body, after the first finished | The stored response is returned. Nothing runs twice. |
| Same key, **different** body | `409 idempotency_key_conflict`. Use a new key for a new intent. |
| Same key while the first is still running | `409 idempotency_in_progress`. Retry shortly. |
| No header on a route that needs one | `422 idempotency_key_required`. |
Keys are scoped to your company and the operation. On the two routes that move money for a customer (`/payments/sell` and `/payouts/orders`) they are scoped to the customer as well, so two of your users checking out the same basket with the same key never share a result.
> **Tip:** Use a new key for each user action and the same key for each retry of that action. A key per click, not a key per request.
## Partial success is normal
Both products buy and sell off a live market where a listing can disappear between a quote and a click. Requests that act on several items therefore report **per-item outcomes** rather than succeeding or failing as a whole: a basket may come back with three payments and two refusals, and that is the honest answer. Nothing is rolled back for a refused line, and you are only ever charged or credited for what landed.
Always render the response, not the request. The `failed` array carries a code and a message per item you can show your user.
---
# Errors, retries & limits
> The error envelope, every code the machine rails return, rate limits, and what is safe to retry.
## The envelope
Every error on `/partner/v1/…` is JSON with a stable machine-readable code. Branch on `code`; the `message` is for your logs and may change.
```json title="409 Conflict"
{
"error": {
"code": "pool_too_low",
"message": "this order costs 6930 cents and the payouts pool holds 5000",
"retryable": false
}
}
```
`retryable` is our advice: `true` means the same request may succeed later without changes (an outage, a rate limit, a paused feature). Idempotency errors add an `operationState` field describing what became of the original attempt.
## Error codes
### Authentication and shape
| Status | Code | When |
| --- | --- | --- |
| 401 | `partner_key_invalid` | Missing, unknown or revoked API key. |
| 403 | `partner_account_inactive` | Your company is suspended. |
| 400 | `invalid_request` | A body or filter the route could not use, with the reason in the message. |
| 400 | `unsupported_game` | A `game` other than the ones we support. |
| 409 | `idempotency_key_conflict` | The key was already used with a different payload. |
| 409 | `idempotency_in_progress` | The original request with this key is still running. |
| 422 | `idempotency_key_required` | A mutating route was called without the header. |
| 429 | `rate_limit_exceeded` | Too many requests for this company on this route. Wait for `Retry-After` and retry. On a read route the body is a plain `{"detail": "too many requests"}` rather than the envelope; the status and header are the same. |
### Customers
| Status | Code | When |
| --- | --- | --- |
| 404 | `customer_not_found` | No customer of yours matches the reference. Also the answer for someone linked to another partner. |
| 404 | `roblox_account_not_found` | Roblox does not know that username. |
| 409 | `external_ref_in_use` | That `externalRef` already names a different customer of yours. |
| 409 | `customer_not_ready` | The customer is linked but has no MM2Empire account yet, so there is nothing to quote, sell or deliver to. It is created the first time they deposit. |
### MM2 Payments
| Status | Code | When |
| --- | --- | --- |
| 409 | `payments_disabled` | Buying is switched off in the console. |
| 409 | `item_not_accepted` | You (or MM2Empire, on our funding) do not buy this item, or the price falls below your minimum payout. |
| 409 | `stock_limit_reached` | You already hold the maximum number of copies you set for this item. |
| 409 | `pool_too_low` | Your Payments pool cannot cover the payout. |
| 409 | `item_unavailable` | The copy is listed, in a trade, being withdrawn or on hold and cannot move right now. |
| 404 | `record_not_found` | No payment of yours has that id. |
| 404 | `session_not_found` | No pop-up session of yours has that id. |
| 429 | `pricing_mode_cooldown` · `funding_source_cooldown` · `claim_cooldown` | A setting was changed, or earnings claimed, less than 15 minutes ago. `Retry-After` says when it reopens. |
### MM2 Payouts
| Status | Code | When |
| --- | --- | --- |
| 409 | `payouts_disabled` | Selling is switched off in the console. |
| 409 | `pool_too_low` | Your Payouts float cannot cover the order. |
| 400 | `order_too_large` | More than 4 item types or 100 items, or over the per-order ceiling or daily cap you set. |
| 409 | `nothing_purchased` | None of the stacks were still available at the quoted prices. |
| 409 | `pool_is_shared` | You tried to fund or release the Payouts float while it shares the Payments pool. |
| 404 | `order_not_found` | No order of yours has that id. |
| 503 | `withdrawals_disabled` | Item delivery is paused platform-wide during an incident. Retryable. |
## Rate limits
Limits are per **company** per route family, over a rolling minute, and shared across all of your API keys. A refused request returns `429` with a `Retry-After` header in seconds; inside a write carrying an `Idempotency-Key` the body is the error envelope with code `rate_limit_exceeded`, and on a read it is `{"detail": "too many requests"}`. The figures below are the current limits; they are generous for a real integration and tight enough that a runaway loop stops quickly.
| Route family | Requests per minute |
| --- | --- |
| Reads: customers, sessions, payments, orders, withdrawals, listings, claims | 120 |
| Link a customer · open or close a session · inventory · quote · rate card · deposits · pool | 60 |
| Payout orders | 30 |
| Sell · withdrawals | 20 |
## What to retry
- **Network timeouts and 5xx**: retry with the **same** `Idempotency-Key`. That is what the header is for.
- **429**: wait for `Retry-After`, then retry with the same key.
- **409 with `retryable: true`** (for example `withdrawals_disabled`): the request is fine and the platform is not ready; back off for a minute.
- **Any other 4xx**: do not retry unchanged. The request will be refused the same way every time.
---
# How MM2 Payments works
> Your users sell you MM2 items and you are credited in USD. How the money moves, who funds it, how prices are set, and what your user sees.
## What happens in a payment
A payment is one of your users selling one MM2 item. Your server opens a session for them, they choose items inside the pop-up on your site (or your own screens, through the API), and for every copy that sells one **payment record** is created, one `payment.completed` webhook is delivered, and a USD figure is credited. You then credit your user on your side, however your product measures value.
1. **Link the customer** — Once per person: `POST /partner/v1/payments/customers` with their Roblox account and your own reference for them.
2. **Open a session** — `POST /partner/v1/payments/sessions` returns a one-time token and the pop-up URL. Pass your own `orderId` so every event about this top-up carries it.
3. **Your user sells** — Inside the pop-up they see the items they hold on MM2Empire, what each is worth to you, and sell the ones they choose. Items not yet on MM2Empire are deposited first by trading them to one of our bots.
4. **You are told, and paid** — Each copy is a `payment.completed` event with the amount in cents. When the pop-up closes, `order.completed` carries the total for the whole session so you can credit once.
> **Note:** The pop-up handles deposits too. A user whose items are still in their Roblox inventory is shown which bot to join; once the trade lands, the items appear in the Sell tab. Nothing about that flow needs code on your side.
## Who pays for the items
The first choice you make about MM2 Payments is who funds it. Both options run the same pop-up and the same API; they differ in whose money buys the item, who sets the price, and who keeps the item afterwards. You pick one under **MM2 Payments → Pool** in the console, and you can switch later (with a 15-minute cooldown between switches, because switching reprices every item at once).
| | **MM2Empire funds it** (default) | **Your own pool** |
| --- | --- | --- |
| Who pays your user | MM2Empire. We buy the item at our own Quick Sell price. | Your Payments pool, which you fund from your balance. |
| Who sets the price | We do. Prices track our item values automatically. | You do: follow our prices, or set your own rate card. |
| Who keeps the item | We do. | You do. It becomes your stock to relist on the MM2Empire marketplace. |
| What you receive | The payout amount, credited to your **claimable** balance the moment the item moves. | The item. Your pool is debited by the payout. |
| What you need to set up | Nothing. Turn buying on. | Fund the pool, and optionally a rate card. |
The default is the simple product and the one most partners want: no float to fund, no prices to maintain, and every sale is a trade we would make with any customer anyway. Your own pool is for a company that wants to hold MM2 stock as an asset, price aggressively, or run its own resale.
> **Claiming earnings:** On MM2Empire funding the credit lands in your **claimable** bucket, not straight into spendable. An owner sweeps it into spendable with one click in the console (once every 15 minutes). The gap exists so a fraudulent sale can be unwound before anyone has withdrawn against it.
## How prices are set
Pricing only applies when you fund your own pool. Two modes:
| Mode | Behaviour |
| --- | --- |
| **Follow MM2Empire** (default) | Every item is priced at what our own Quick Sell pays, live. An item we stop buying is refused rather than repriced. Your per-item overrides are ignored while you are in this mode. |
| **Custom rates** | A headline rate as a share of catalogue value (seeded from ours when you switch, default 50%), plus per-item overrides: a fixed price, a different rate, a maximum number of copies to hold (default 10), and whether to relist automatically. "Copy MM2Empire's prices" stamps our current prices onto every row as a starting point. |
- **Minimum payout** (default $0.01): any item that works out below it is refused.
- **Stock limit**: once you hold the maximum copies of an item, further sales of it are refused with `stock_limit_reached`. Copies you have relisted and sold no longer count.
- `GET /partner/v1/payments/items` returns your whole rate card as the API sees it, including items you do not buy (`quote.enabled: false`), so a page you build can grey out exactly what the pop-up would refuse.
## Your stock and relisting
When your pool buys an item, the copy becomes your stock. From **MM2 Payments → Inventory** you list it for sale on the public MM2Empire marketplace, reprice it, or take it off sale. With **auto-relist** on, every copy you buy is listed the moment it lands, at its own relist price if you set one or at catalogue value plus your markup (default 10%).
> **Important:** When a relisted copy sells, the proceeds go **back into your Payments pool**, not into spendable balance. Your float replenishes itself; nothing has to be moved by hand.
A copy that leaves your stock any other way (a withdrawal, a hold) stops counting against the item's stock limit.
## Orders
An **order** is your own idea of what a pop-up was paying for: a wallet top-up, an invoice, a cart. You name it with `orderId` when you open the session, and it is copied onto every payment the session produces and every webhook about them, so your server can credit the right user straight off an event body.
- `orderId` is **not** unique. Re-opening the pop-up for an abandoned order is expected, and both sessions belong to the same order; `GET /partner/v1/payments/orders/{orderId}` totals across all of them.
- `metadata` is a flat object of up to 20 keys with string, integer, boolean or null values, echoed everywhere `orderId` is.
- When a session ends, `order.completed` fires **once** with the total and a status of `completed` (something sold) or `abandoned` (the user closed without selling). Nothing is ever a silent nothing.
The [pop-up guide](https://partner.mm2empire.com/en/docs/payments-popup#orders-and-rollups) covers when the rollup fires and how to close an order from your side.
## Switching it on
A new company starts with buying **off**. The pop-up loads and shows prices, but refuses every sale until an owner turns buying on under MM2 Payments → Pool. Do it last: once buying is on, every sale is money you owe your user.
---
# Pop-up integration
> The hosted pop-up: mint a session on your server, open it with one script tag, and let us render the sell, deposit and inventory screens.
## How it works
The pop-up is a page on `partner.mm2empire.com`, shown in a modal iframe on your site by our `embed.js`. Your server mints a **one-time session token** for one linked customer; the browser opens the pop-up with it; the pop-up exchanges it for a short-lived bearer and talks to our API directly. Your API key never leaves your server.
```text title="Sequence"
your server ──POST /partner/v1/payments/sessions──▶ api.mm2empire.com
◀── { token, url, sessionId } ─────────
your page ──MM2Payments.open({ token })──────────▶ partner.mm2empire.com/en/pay
◀── mm2:complete / mm2:close (postMessage)
api ──payment.completed, order.completed──▶ your webhook endpoint (signed)
```
## Create a session
`POST /partner/v1/payments/sessions` _(accepts Idempotency-Key)_
Open a pop-up session for one linked customer. Returns the URL to send them to, and the token on its own for a frame you build yourself. Limit 60 per minute.
**Body**
- `customerRef` _(string, required)_ — Your `externalRef`, the Roblox id, the Roblox username, or our `customerId`. Resolved within your company only.
- `orderId` _(string ≤ 120)_ — Your own id for what this pop-up is paying for. Echoed on every payment and webhook. Whitespace is trimmed.
- `metadata` _(object)_ — Up to 20 keys; values must be strings, integers, booleans or null. Echoed with `orderId`.
- `returnUrl` _(string ≤ 2048)_ — Where the pop-up may post its messages if it cannot learn your origin any other way. Rarely needed with `embed.js`.
- `locale` _(string)_ — A two-letter locale for the pop-up's language. Defaults to English.
- `ttlSeconds` _(integer ≤ 3600)_ — How long the pop-up stays usable once opened. Default 1800 (30 minutes), maximum one hour.
```json title="200 OK"
{
"sessionId": "6a93b48a38abc7ce614b60c4",
"token": "pst_8f0c…",
"url": "https://partner.mm2empire.com/en/pay?token=pst_8f0c…",
"expiresAt": "2026-09-05T13:25:11+00:00",
"orderId": "dep_9f2c1",
"metadata": { "source": "wallet" },
"customer": { "customerId": "…", "robloxId": 5501, "robloxUsername": "xXShadowXx", "externalRef": "user_48213", "…": "…" }
}
```
> **The token is single-use and short-lived:** `expiresAt` is five minutes after minting. Mint the session when the user clicks the button, hand the token straight to the browser, and never cache it. Once the pop-up has exchanged it, the token is worthless.
## Open it with embed.js
```html
```
**Options**
- `token` _(string, required)_ — The session token from `POST /sessions`.
- `onComplete` _(function(payment))_ — Called once per completed sale with the pop-up's own sell result: the payments taken, any refusals, and `totalPayoutCents`. For your UI only; the webhook is the record.
- `onClose` _(function())_ — Called when the modal is closed, by the user or by you.
- `lng` _(string)_ — Two-letter locale for the frame's URL. The session's own `locale` still wins inside the pop-up.
- `baseUrl` _(string)_ — Where the pop-up lives. Defaults to wherever `embed.js` was loaded from; only set it if you self-host a copy of the script.
The script defines one global, `MM2Payments`, appends one element to your page and removes it on close, locks page scroll while the modal is up, and closes on Escape or a click on the backdrop. The frame is capped at 520 × 760 px. It only ever listens to messages from the pop-up's own origin and its own iframe, so nothing else on your page can forge a completion.
### Building your own frame
If you would rather not load our script, open `url` from the session response in an iframe and append `&origin=` with your page's origin so the pop-up knows where to post. Listen for `message` events from `https://partner.mm2empire.com` only:
| Message | Meaning |
| --- | --- |
| `{ type: "mm2:complete", payment }` | A sale went through. Same payload as `onComplete`. |
| `{ type: "mm2:close" }` | The user is done; remove the frame. |
| `{ type: "mm2:resize", width }` | The pop-up would like a wider frame (the payouts shop asks for one). Advisory; clamp it. |
## Session lifecycle
`GET /partner/v1/payments/sessions/{sessionId}`
Whether the pop-up was opened, whether it is still live, what it bought, and `orderCompletedAt` once its order rollup has fired. The field to poll if you have no webhook receiver yet.
`POST /partner/v1/payments/sessions/{sessionId}/close`
End a pop-up from your own server and complete its order now, rather than waiting for the sweep. Idempotent: closing a completed order returns its state and fires nothing.
| Status | Meaning |
| --- | --- |
| `pending` | Minted, not yet opened. The token is still exchangeable (for five minutes). |
| `active` | Opened. The pop-up is usable until its access expiry (`ttlSeconds`, default 30 minutes). |
| `closed` | The user finished, or you closed it. |
| `expired` | The token was never exchanged in time, or the access window ran out. |
| `revoked` | Ended by your server or by us. |
## Orders and the rollup event
Every session belongs to an order (yours, via `orderId`, or an anonymous one). When a session ends, its order is **rolled up**: one `order.completed` event with `totalPayoutCents` and `paymentCount` across the session, and a status of `completed` or `abandoned`. Three things end a session and any of them fires the rollup exactly once:
- The user closes the pop-up (the pop-up tells us).
- Your server calls `POST /sessions/{id}/close`.
- The access window expires. A background sweep catches sessions nobody closed; it fires late rather than early, so a rollup never under-reports a sale still in flight.
> **Credit from the rollup:** Per-item `payment.completed` events tell you what sold as it sells. `order.completed` is the one to credit your user from, because it is the only event that knows the basket is finished. Deciding that from outside, per item, is a race you cannot win.
## Branding the pop-up
Under **MM2 Payments → Integration** an owner sets an accent colour, a dark or light surface, rounded or square corners, a display name and a logo, with a live preview of the real pop-up beside the controls. The list is deliberately short: the pop-up is served from our origin and carries our name, so it can wear your colours without being made to look like something it is not while asking someone to hand over items.
If MM2Empire staff have set a logo on your company, the pop-up shows it automatically until you choose one of your own. Logos must be https URLs; an http image would be blocked as mixed content inside the TLS pop-up.
## Deposits and the bots
Items live in a user's Roblox inventory until they are traded to one of our bots. The pop-up's Deposit tab shows which bots are online and how to join them; when the in-game trade completes, the items appear in the Sell tab within seconds. If you build your own screens, the same information is one call away:
`POST /partner/v1/payments/deposits` _(accepts Idempotency-Key)_
Where a customer should send items: the bots online right now with join links, the Roblox account they must trade from (their own), and a pop-up URL opened straight on the Deposit tab. A replayed key returns today's live bots, not the ones that were up first time.
> **Important:** Nothing here creates a deposit record. An item has been deposited when it appears in `GET /customers/{ref}/inventory`, and not before.
---
# Server-to-server API
> Skip the pop-up: link customers, read their inventory, quote and sell from your own server, and read everything back for reconciliation.
## When to use the API directly
Everything the pop-up does, your server can do. Build your own sell screen with your own basket and confirm step, run a reconciliation job against the payments feed, or mirror our rate card into your catalogue. Every endpoint below is on `https://api.mm2empire.com`, takes `X-Partner-Key`, and speaks the [error envelope](https://partner.mm2empire.com/en/docs/errors).
## Customers
`POST /partner/v1/payments/customers` _(accepts Idempotency-Key)_
Link an end user to your company, or refresh an existing link. Send `robloxUsername` **or** `robloxId` (never both) and optionally your own `externalRef`. Limit 60 per minute.
`GET /partner/v1/payments/customers`
Everyone you have linked, newest first. `search` matches username or external reference; `limit` up to 200.
`GET /partner/v1/payments/customers/{customerRef}`
One customer by any of the four identifiers.
`GET /partner/v1/payments/customers/{customerRef}/inventory`
What the customer holds on MM2Empire, stacked by item, with what you would pay for each stack right now (`quote.enabled`, `quote.priceCents`, `quote.remaining`) and whether the copies can move (`available`, `unavailableReason` of `listed`, `in_trade`, `withdrawing` or `on_hold`). A customer with no MM2Empire account yet has an empty inventory.
```json title="Customer"
{
"customerId": "6a8b0e5f2c9d4a1b3e7f9012",
"userId": "6a8b0e5f2c9d4a1b3e7f9013",
"robloxId": 5501,
"robloxUsername": "xXShadowXx",
"displayName": "Shadow",
"avatarUrl": "https://…",
"externalRef": "user_48213",
"createdAt": "2026-08-20T10:00:00+00:00",
"lastSeenAt": "2026-09-05T12:51:00+00:00"
}
```
## Quote and sell
`POST /partner/v1/payments/quote`
Price a selection without selling it. Read-only. Limit 60 per minute.
`POST /partner/v1/payments/sell` _(accepts Idempotency-Key)_
Sell a customer's items to you. The one route on this rail that moves money. Limit 20 per minute.
**Body (both routes)**
- `customerRef` _(string, required)_ — Who is selling.
- `stacks` _(array, required)_ — Up to 200 entries of `{ itemValueId, quantity }`. Copies resolve oldest-first; you never name a specific copy.
- `orderId` _(string ≤ 120)_ — Your own order id, echoed on every payment and webhook.
- `metadata` _(object)_ — Flat key/value, echoed with `orderId`.
```json title="POST /sell — 200 OK"
{
"payments": [
{
"id": "6a93b48a38abc7ce614b60c4",
"reference": "pay_6a93b48a38abc7ce614b60c4",
"orderId": "dep_9f2c1",
"metadata": { "source": "wallet" },
"customerUserId": "6a8b0e5f2c9d4a1b3e7f9013",
"robloxId": 5501,
"robloxUsername": "xXShadowXx",
"externalRef": "user_48213",
"itemValueId": "chroma_luger",
"itemName": "Chroma Luger",
"itemValueCents": 40000,
"payoutCents": 1240,
"currency": "USD",
"fundingSource": "mm2empire",
"createdAt": "2026-09-05T13:21:04+00:00"
}
],
"failed": [
{ "itemValueId": "harvester", "code": "stock_limit_reached", "message": "…" }
],
"totalPayoutCents": 1240
}
```
> **A quote is not a promise:** Headroom is spent as the list is walked and a pool can empty between the quote and the sale, so `/sell` returns its own per-item outcomes. Render those. Nothing is rolled back for a refused line, and the idempotency key is scoped to the customer as well as to you, so two users sending the same basket never share a result.
A customer who is linked but has never deposited has no MM2Empire account yet; both routes answer `409 customer_not_ready` until they do.
## Reading payments back
`GET /partner/v1/payments/payments`
Every payment you have taken, newest first. Filter by `orderId`, `sessionId` or `customerRef`; `limit` up to 200. The endpoint a reconciliation job runs against.
`GET /partner/v1/payments/payments/{paymentId}`
One payment. Another company's id reads as not found.
`GET /partner/v1/payments/orders/{orderId}`
Everything under one of **your** order ids: status (`pending`, `completed`, `abandoned`), `totalPayoutCents` and `paymentCount` across every session that carried the id, plus the sessions and payments themselves.
## Your rate card
`GET /partner/v1/payments/items`
Every catalogue item with what you pay for it: `enabled`, `defaultBuyRateBps` and `minPayoutCents` at the top, and per row a `quote` of `{ enabled, priceCents, remaining }`. Rows you do not buy are included with `enabled: false`, so a missing row always means a missing item. Filters: `search`, `itemType`, `rarity`; `limit` up to 100.
## Returning items to a customer
A customer may want items they deposited but did not sell sent back to their Roblox account. The pop-up's Items tab does this; so can your server.
`POST /partner/v1/payments/withdrawals` _(accepts Idempotency-Key)_
Send stacks of a customer's own items back to their own Roblox account. `items` is a list of `{ itemValueId, quantity }`; at most four distinct items per request. There is no destination field and none can be added. Limit 20 per minute.
`GET /partner/v1/payments/withdrawals/{withdrawalId}`
Where one withdrawal has got to: `status`, `statusReason`, `completedAt`.
Delivery is pull-based: the customer joins the bot holding the items to collect them. The response names the Roblox account being paid; the bot is shown to the customer in the pop-up.
---
# How MM2 Payouts works
> You pay your users out in MM2 items bought off our live marketplace. The float that pays for them, what you are charged, the limits you set, and how delivery works.
## What happens in a payout
A payout is you buying items for one of your users. The items come from MM2Empire's public marketplace, where real sellers list real copies. Your **Payouts float** pays the seller and our margin; the copy is handed to your user's MM2Empire account and a delivery starts; one of our bots hands it over in-game when your user joins. You get an event when the order is placed and another when each item lands.
1. **Your user chooses items** — In the shop pop-up on your site, or on a page you build from the catalogue API. Prices already include our margin, and in the pop-up they are multiplied by a retail factor you choose.
2. **The order is placed** — Your server (or the pop-up) submits a basket of stacks. The float is debited only for what actually landed; a listing that sold out a second earlier is reported as a failed line, not charged.
3. **We deliver** — Each copy is placed with a bot. Your user joins that bot in-game to collect it. The claim page in the pop-up, or `GET /claims` for your own page, names the bot.
4. **You are told** — `payout.order_completed` when the order is placed, `withdrawal.updated` as each copy moves, `payout.failed` if one of our own holds blocks a copy.
## The float
Payouts spend from a reserved bucket of your balance. By default it is **its own float**, separate from the MM2 Payments pool, so buying from your users can never drain the money your payouts customers are spending. A company that would rather keep one number topped up can point payouts at the Payments pool instead (**MM2 Payouts → Float → Which money pays for payouts**), after which fund and release controls disappear from the payouts page because the two products would be moving one pot.
- Fund and release the float in the console; both move money between your spendable balance and the float, and neither costs anything.
- `GET /partner/v1/payouts/pool` returns `poolCents`, so your side can alarm before we start refusing orders with `pool_too_low`.
- Switching pool mode has a 15-minute cooldown, so a float cannot change under orders that are already being priced.
## What you are charged
Every price on the payouts rail is **what you pay**: the seller's marketplace price plus MM2Empire's margin, as one number. There is deliberately no second figure to reconcile. The margin is a percentage set by our staff for your account (`markupBps`, basis points; 250 is 2.5%), visible in the console and on every catalogue response so you can see why our price differs from the public site's.
An order is charged for the copies that landed. `chargedCents` is the total and `markupCents` the margin inside it.
## Limits you control
| Limit | Default | Where |
| --- | --- | --- |
| Most per order (`maxOrderCents`) | $1,000 | Console → MM2 Payouts → Float |
| Most per day (`dailySpendCapCents`) | No limit | Console → MM2 Payouts → Float |
| Item types per order | 4 | Fixed: one in-game delivery carries at most four distinct stacks |
| Items per order | 100 | Fixed |
Your float is the real limit. The ceilings above exist so a mistake on your side, or a compromised key, stops at a number you chose rather than at an empty float. An order over any of them is refused with `order_too_large` before anything is bought.
## Delivery
The copy is moved to your user's MM2Empire account the moment it is bought, then a withdrawal to their Roblox account begins. Each line of an order carries a `withdrawalId` and a status:
| Line status | Meaning |
| --- | --- |
| `delivering` | A bot holds the copy; your user can collect it now. |
| `awaiting_delivery` | Bought and owned by your user, waiting for a bot. Retried automatically. |
| `completed` | Traded to your user's Roblox account. Announced as `withdrawal.updated`. |
| `blocked` | Held by our own fraud controls. Announced as `payout.failed` with code `delivery_blocked`. Nothing is refunded automatically, because these holds are routinely cleared and paying you back for an item your user then receives is the worse error; contact us if one sticks. |
An order's own status summarises its lines: `completed`, `partial` (some listings sold out before we could buy them), `awaiting_delivery`, or `failed`.
> **Important:** Release your user from their side of the deal on `payout.order_completed`, and tell them their item is ready on `withdrawal.updated` with `completed`. Do not show "delivered" before that.
## Switching it on
Selling starts **off**. Fund the float, decide the limits, then turn selling on under MM2 Payouts → Float. While it is off the shop loads but refuses every order with `payouts_disabled`.
---
# Shop pop-up integration
> The hosted shop: one server call opens a pop-up with our catalogue at your retail price, a cart, and a claim page that names the bot to join.
## How it works
The payouts pop-up is the same page and the same `embed.js` as MM2 Payments; the session decides which product it shows. Your server mints a session with `POST /partner/v1/payouts/sessions`, your page opens it, and your user shops, checks out and collects without ever leaving your site. The one thing you tell us is **what to multiply our price by**, because we render the shop but do not know what you charge.
## Create a session
`POST /partner/v1/payouts/sessions` _(accepts Idempotency-Key)_
Open a shop pop-up for one linked customer. Limit 60 per minute.
**Body**
- `customerRef` _(string, required)_ — Who is shopping. Items are delivered to this customer's own Roblox account and nowhere else.
- `retailMultiplierBps` _(integer 10000–50000)_ — What the pop-up shows your user, as a multiple of what you pay us, in basis points. `10000` is 1.0× (default), `12000` is 1.2×, `50000` is the 5× ceiling. Retail prices round **up** to the cent.
- `orderId` _(string ≤ 120)_ — Your own id for this withdrawal, echoed on every order and event the session produces.
- `metadata` _(object)_ — Flat key/value, echoed with `orderId`.
- `returnUrl` _(string)_ — Fallback origin for the pop-up's messages. Rarely needed with `embed.js`.
- `locale` _(string)_ — Two-letter locale for the pop-up's language.
- `ttlSeconds` _(integer ≤ 3600)_ — How long the pop-up stays usable once opened. Default 30 minutes.
```javascript title="Your server (Node)"
const session = await fetch('https://api.mm2empire.com/partner/v1/payouts/sessions', {
method: 'POST',
headers: {
'X-Partner-Key': process.env.MM2_PARTNER_KEY,
'Idempotency-Key': crypto.randomUUID(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
customerRef: 'user_48213',
orderId: 'wd_99120',
retailMultiplierBps: 12000, // your user pays 1.2x what you pay us
}),
}).then(r => r.json())
// Browser:
// MM2Payments.open({ token: session.token })
```
> **Why the multiplier lives on the session:** The browser is your user's, so a price it could set would be a price anyone could set. Your server fixes the retail factor when it mints the session, and the pop-up cannot change it. Different storefronts can run different margins by minting sessions with different factors.
## What your user sees
| Tab | What it does |
| --- | --- |
| **Shop** | Our live marketplace at your retail price: search, rarity and type filters, sorting, and item art. Sold-out items disappear as they sell. Your own relisted stock is never shown to your own users. |
| **Cart and checkout** | Up to four item types and 100 items, the same ceiling as an API order. Checkout places one order for the basket; a line whose listing sold out a moment earlier is reported rather than silently repriced. |
| **Claim** | Everything the user still has to collect, each with the bot holding it and a join link. This is where "join the bot to receive your item" lives. |
The shop lays items out one of two ways, chosen under **MM2 Payouts → Float → How your shop lists items**: one card per item priced from its cheapest copy (`stacked`), or one card per item-and-price so a shopper can pick a specific seller's price (`separated`).
The frame widens itself for the shop (it asks `embed.js` for up to 1100 px and the script clamps it). Branding follows the same settings as the Payments pop-up.
## What you receive
Each checkout is an order: `payout.order_completed` carries the lines, `chargedCents` and your `orderId`. Then `withdrawal.updated` follows every copy to `completed`, and `payout.failed` names any copy our holds block. Read the [event reference](https://partner.mm2empire.com/en/docs/webhook-events) for payloads, or poll `GET /partner/v1/payouts/orders/{orderId}` with your own id if you have no receiver yet.
---
# Server-to-server API
> Build your own shop, cart and claim page: the catalogue at your prices, orders by stack, delivery status, and the float.
## When to use the API directly
On its own, this rail is the whole product: a partner who wants their own shop page needs nothing from us but these endpoints and a webhook receiver. There is no session and no pop-up on this path; your user never reaches us. Every endpoint is on `https://api.mm2empire.com`, takes `X-Partner-Key`, and speaks the [error envelope](https://partner.mm2empire.com/en/docs/errors).
## The catalogue
`GET /partner/v1/payouts/listings`
Our Buy page at your prices, minus your own relisted stock. Limit 120 per minute.
**Query**
- `search` _(string ≥ 2)_ — Item name search.
- `itemValueId` _(string)_ — One item only.
- `itemCategory · itemType · rarity` _(string)_ — Catalogue filters.
- `minPriceCents · maxPriceCents` _(integer)_ — A price band, in the prices this rail quotes, so a slider agrees with the rows.
- `sort` _(string)_ — `newest` (default), `price_asc`, `price_desc`, `value_asc`, `value_desc`, `best_value`.
- `limit · offset` _(integer)_ — `limit` up to 100, `offset` up to 5000.
```json title="200 OK"
{
"items": [
{
"itemValueId": "chroma_luger",
"itemName": "Chroma Luger",
"itemType": "gun",
"rarity": "chroma",
"imageUrl": "https://…/high.png",
"imageSmallUrl": "https://…/small.png",
"priceCents": 3465,
"availableCount": 7,
"priceStacks": [
{ "priceCents": 3465, "availableCount": 4 },
{ "priceCents": 3570, "availableCount": 3 }
]
}
],
"total": 412,
"limit": 50,
"offset": 0,
"markupBps": 500
}
```
`priceCents` is the cheapest copy and `priceStacks` the other prices the same item is available at. Every figure includes your `markupBps`. Prices are marketplace prices and move; the order request repeats the price you saw, which is what protects you when they do.
## Placing an order
`POST /partner/v1/payouts/orders` _(accepts Idempotency-Key)_
Buy a basket for one linked customer and start delivery to their own Roblox account. Limit 30 per minute.
**Body**
- `customerRef` _(string, required)_ — Who receives the items.
- `stacks` _(array (1–4), required)_ — Each `{ itemValueId, priceCents, quantity }`. `priceCents` is the price the catalogue quoted you; a stack only matches copies at exactly that price, which makes it an implicit maximum. Up to 100 items in total.
- `orderId` _(string ≤ 120)_ — Your own id, echoed on the order and every event about it. Look the order up by it later.
- `metadata` _(object)_ — Flat key/value, echoed with `orderId`.
```json title="200 OK"
{
"orderId": "6a9d1c0e5b7f4a2d8c3e9f10",
"partnerOrderId": "wd_99120",
"status": "completed",
"chargedCents": 6930,
"markupCents": 330,
"deliverToRobloxId": 5501,
"deliverToRobloxUsername": "xXShadowXx",
"lines": [
{ "itemValueId": "chroma_luger", "chargeCents": 3465, "withdrawalId": "6a9d…", "status": "delivering" },
{ "itemValueId": "chroma_luger", "chargeCents": 3465, "withdrawalId": "6a9d…", "status": "delivering" }
],
"failed": []
}
```
> **Partial success is normal:** A listing can sell between your page load and your user's click. When it does, that line appears in `failed` with the reason and is not charged; the rest of the basket goes through. `status` becomes `partial`. Nothing is rolled back and nothing is silently bought at a higher price: a stack whose price moved matches nothing.
Refusals before anything is bought: `payouts_disabled`, `pool_too_low`, `order_too_large`, `nothing_purchased` (every stack was gone), `customer_not_ready` (the customer has no MM2Empire account yet), and `withdrawals_disabled` while item delivery is paused platform-wide.
`GET /partner/v1/payouts/orders/{orderId}`
One order, by our id **or** your own `orderId`. Line statuses update as delivery progresses.
## Claims: where the items are
`GET /partner/v1/payouts/claims?customerRef=…`
Everything this customer still has to collect, each with the bot holding it (`botUsername`, `botDisplayName`, `joinUrl`) and the item's name and art. Scoped to one customer on purpose: it answers "where is my item", never "where is your stock". Limit 120 per minute.
This is what a self-built claim page renders. Tell your user to join the named bot in Murder Mystery 2 and accept the trade; the corresponding `withdrawal.updated` arrives when it completes.
## The float, from the API
`GET /partner/v1/payouts/pool`
`poolCents` (what is left to spend), `poolMode`, `sharedWithPayments`, the spendable balance available to move in (`fundingWalletAvailableCents`) and your `markupBps`. Funding and releasing are console actions.
---
# Webhooks
> Signed HTTPS callbacks to your server for every payment, order, payout and delivery. Set the endpoint, verify the signature, handle retries.
## Setting an endpoint
One endpoint per company covers **both products**. An owner sets it under **MM2 Payments → Integration → Webhooks** in the console. Saving hands back a signing secret (`whsec_…`) exactly once; we keep it encrypted and cannot show it again. Saving a new URL reissues the secret, and **Rotate** mints a new one for the same URL with no overlap window, because a company rotating is usually reacting to a leak.
| Rule | Why |
| --- | --- |
| `https://` only, on port 443 | The body carries your customers' activity. |
| No username or password in the URL | We would store it and you would log it. The signature is the authentication. |
| A public hostname | The host is resolved when you save **and** on every delivery, and anything resolving into a private range is refused. Repointing DNS later does not get around it. |
## What a delivery looks like
```http
POST /mm2/webhooks HTTP/1.1
Host: api.yourcompany.com
Content-Type: application/json
X-MM2-Event: payment.completed
X-MM2-Delivery: whd_3c1f9a2b8d4e7f60a1b2c3d4
X-MM2-Signature: t=1757077211,v1=5f1d…e8a3
{"id":"whd_3c1f9a2b8d4e7f60a1b2c3d4","type":"payment.completed","reference":"pay_6a93b48a38abc7ce614b60c4","partnerId":"6a8ae577a87a1007a21e15b6","data":{"payment":{…}}}
```
**Body**
- `id` _(string)_ — The delivery id, `whd_` plus 24 hex characters. **Identical on every retry** of the same event, so it is what you dedupe on.
- `type` _(string)_ — The event type. Same value as the `X-MM2-Event` header.
- `reference` _(string)_ — The thing the event is about: a payment reference, an order reference, a withdrawal and its state.
- `partnerId` _(string)_ — Your company's id.
- `data` _(object)_ — The payload for the event type. See the [event reference](https://partner.mm2empire.com/en/docs/webhook-events).
- `resendOf` _(string)_ — Present only on a manual resend from the console, naming the original delivery id.
The body is compact JSON with no whitespace and no timestamp: it is byte-identical on every attempt, which is what lets you dedupe on `id` alone. The timestamp that matters is inside the signature.
## Verifying the signature
`X-MM2-Signature` is `t=,v1=` where `v1` is HMAC-SHA256 over the string `"{t}.{raw body}"` using your signing secret. The timestamp is inside the MAC, so a captured request cannot be replayed later with a fresh time.
1. **Read the raw bytes** — Compute over the exact body you received, before any JSON parsing or re-serialisation. A body your framework has re-encoded will not match.
2. **Recompute** — HMAC-SHA256 with your secret over `t`, a literal dot, and the raw body. Hex-encode it.
3. **Compare in constant time** — Use your language's constant-time comparison against `v1`.
4. **Check the age** — Reject a `t` more than **300 seconds** from your clock. `GET /partner/v1/ping` returns our server time if you want to check for drift.
```javascript title="Node (Express)"
import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.post('/mm2/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-MM2-Signature') ?? ''
const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')))
const expected = crypto
.createHmac('sha256', process.env.MM2_WEBHOOK_SECRET)
.update(`${parts.t}.`)
.update(req.body) // the raw Buffer, untouched
.digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300
const valid =
expected.length === (parts.v1 ?? '').length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
if (!fresh || !valid) return res.status(400).end()
const event = JSON.parse(req.body)
if (alreadyProcessed(event.id)) return res.status(200).end() // dedupe on the delivery id
handle(event) // your logic
res.status(200).end()
})
```
```python title="Python (FastAPI)"
import hmac, hashlib, time, json
from fastapi import FastAPI, Request, Response
app = FastAPI()
@app.post("/mm2/webhooks")
async def mm2_webhook(request: Request):
raw = await request.body()
parts = dict(kv.split("=", 1) for kv in request.headers["X-MM2-Signature"].split(","))
expected = hmac.new(
SECRET.encode(), f"{parts['t']}.".encode() + raw, hashlib.sha256
).hexdigest()
if abs(time.time() - int(parts["t"])) > 300:
return Response(status_code=400)
if not hmac.compare_digest(expected, parts.get("v1", "")):
return Response(status_code=400)
event = json.loads(raw)
if not already_processed(event["id"]):
handle(event)
return Response(status_code=200)
```
## Retries and the delivery log
Any response other than a 2xx, and any timeout, is a failed attempt. We retry with exponential backoff, roughly **2, 4, 8, 16, 32 minutes, then hourly**, up to **20 attempts**. Respond quickly with a 2xx and do the work afterwards; a receiver that takes seconds to answer is a receiver that times out.
Every attempt is written to the **delivery log** under MM2 Payments → Integration, kept for 30 days: the status code we got (or the error when there was no response), the exact signed body, and a state:
| State | Meaning |
| --- | --- |
| `pending` | Queued, not yet attempted. |
| `retrying` | Attempted and refused, with retries still to come. |
| `succeeded` | Your endpoint answered 2xx. |
| `failed` | Every attempt was refused. **Lost until someone presses Resend.** |
| `skipped` | There was no endpoint configured when the event came up. The answer to "where did my webhooks go" that is invisible everywhere else. |
- **Resend** replays a delivery as a **new** delivery with a new id and `resendOf` naming the original, because your handler dedupes on the id and replaying the same one is exactly what it ignores.
- **Send test event** posts a `webhook.test` with obviously fake figures (a one-cent payment) so you can fail before a customer is involved.
- Failed deliveries also raise the **Integration health** card on your dashboard and, if you have connected one, a [Discord notification](https://partner.mm2empire.com/en/docs/discord-notifications).
## Handling events well
- **Dedupe on `id`.** Retries carry the same id; a resend carries a new one plus `resendOf`. Store processed ids.
- **Do not assume order.** A `withdrawal.updated` can arrive before the `payout.order_completed` that created it if your endpoint was briefly down. Key your state on the ids in the payload, not on arrival order.
- **Credit once, from the rollup.** For MM2 Payments, `order.completed` is the event to credit a user from; `payment.completed` is for showing progress.
- **Trust the webhook, not the pop-up.** `onComplete` in the browser is a convenience for your UI. The signed event is the record.
- **Read back when in doubt.** Every event has an API read behind it: `GET /payments/{id}`, `GET /orders/{orderId}`, `GET /payouts/orders/{id}`, `GET /payments/withdrawals/{id}`.
---
# Event reference
> Every event type, when it fires, and the exact shape of its data payload.
## Event types
| Type | Product | Fires when |
| --- | --- | --- |
| `payment.completed` | Payments | One copy was bought from your user. One event per copy. |
| `payment.failed` | Payments | A basket bought **nothing**. A partial basket's successes already announced themselves. |
| `order.completed` | Payments | A pop-up session ended: the total for the order, or `abandoned`. |
| `payout.order_completed` | Payouts | An order was placed, with its lines and what was charged. |
| `withdrawal.updated` | Payouts | A delivered copy changed state. One event per copy per state. |
| `payout.failed` | Payouts | A copy's delivery was blocked by our own holds (`delivery_blocked`). |
| `webhook.test` | Both | You pressed **Send test event** in the console. |
> **Note:** `deposit.credited` and `payout.completed` are reserved names and are not emitted today. Do not wait on them.
## payment.completed
```json title="data"
{
"payment": {
"id": "6a93b48a38abc7ce614b60c4",
"reference": "pay_6a93b48a38abc7ce614b60c4",
"orderId": "dep_9f2c1",
"metadata": { "source": "wallet" },
"customer": {
"userId": "6a8b0e5f2c9d4a1b3e7f9013",
"robloxId": 5501,
"robloxUsername": "xXShadowXx",
"externalRef": "user_48213"
},
"item": { "itemValueId": "chroma_luger", "name": "Chroma Luger", "valueCents": 40000 },
"payoutCents": 1240,
"currency": "USD",
"fundingSource": "mm2empire",
"sessionId": "6a93b3f1…",
"createdAt": "2026-09-05T13:21:04+00:00"
}
}
```
`fundingSource` is `mm2empire` when we paid and the money is already in your claimable balance, or `partner` when your own pool paid and the item is now your stock. `orderId` and `metadata` are yours, echoed verbatim.
## payment.failed
```json title="data"
{
"failure": {
"reference": "fail_6a93b3f1_9c1e2f3a4b5c6d7e",
"orderId": "dep_9f2c1",
"metadata": { "source": "wallet" },
"sessionId": "6a93b3f1…",
"customer": { "userId": "6a8b0e5f…", "externalRef": "user_48213" },
"items": [
{ "code": "stock_limit_reached", "message": "you already hold the most copies of this item you allow" }
]
}
}
```
## order.completed
```json title="data"
{
"order": {
"orderId": "dep_9f2c1",
"sessionId": "6a93b3f1…",
"status": "completed",
"metadata": { "source": "wallet" },
"customer": { "userId": "6a8b0e5f…", "robloxId": 5501, "robloxUsername": "xXShadowXx", "externalRef": "user_48213" },
"currency": "USD",
"totalPayoutCents": 2480,
"paymentCount": 2,
"truncated": false,
"payments": [
{ "id": "6a93b48a…", "reference": "pay_6a93b48a…", "itemValueId": "chroma_luger", "name": "Chroma Luger", "valueCents": 40000, "payoutCents": 1240, "fundingSource": "mm2empire", "createdAt": "…" },
{ "id": "6a93b4a0…", "reference": "pay_6a93b4a0…", "itemValueId": "chroma_luger", "name": "Chroma Luger", "valueCents": 40000, "payoutCents": 1240, "fundingSource": "mm2empire", "createdAt": "…" }
],
"completedAt": "2026-09-05T13:24:40+00:00"
}
}
```
`status` is `abandoned` when the pop-up closed without selling; `totalPayoutCents` is then 0 and `payments` empty. `payments` lists at most 100 entries and `truncated` says whether more exist; `paymentCount` and `totalPayoutCents` are always complete.
## payout.order_completed
```json title="data"
{
"order": {
"id": "6a9d1c0e5b7f4a2d8c3e9f10",
"orderId": "wd_99120",
"metadata": {},
"status": "completed",
"customer": { "robloxId": 5501, "externalRef": "user_48213" },
"chargedCents": 6930,
"currency": "USD",
"itemCount": 2,
"items": [
{ "itemValueId": "chroma_luger", "chargeCents": 3465, "withdrawalId": "6a9d…", "status": "delivering" },
{ "itemValueId": "chroma_luger", "chargeCents": 3465, "withdrawalId": "6a9d…", "status": "delivering" }
],
"createdAt": "2026-09-05T12:51:00+00:00"
}
}
```
`id` is our order id and `orderId` is yours. `status` is `completed`, `partial` or `awaiting_delivery` at the moment of placing.
## withdrawal.updated
```json title="data"
{
"withdrawal": {
"id": "6a9d1c1f…",
"status": "completed",
"statusReason": null,
"itemValueId": "chroma_luger",
"robloxId": 5501,
"payoutOrderId": "6a9d1c0e5b7f4a2d8c3e9f10",
"orderId": "wd_99120",
"completedAt": "2026-09-05T13:04:12+00:00"
}
}
```
One event per copy per state. `status` moves through `delivering` to `completed`, or to `failed` or `cancelled`. Nothing about which bot or how; the claim endpoint answers that for one customer.
## payout.failed
```json title="data"
{
"payout": {
"id": "6a9d1c0e5b7f4a2d8c3e9f10",
"orderId": "wd_99120",
"metadata": {},
"itemValueId": "chroma_luger",
"chargeCents": 3465,
"currency": "USD",
"code": "delivery_blocked",
"customer": { "robloxId": 5501, "externalRef": "user_48213" }
}
}
```
The copy was bought and belongs to your user, but our fraud controls are holding it. These holds are routinely cleared within a day; credit your user anyway, wait, or contact us. Nothing is refunded automatically.
## webhook.test
```json title="data"
{
"test": {
"reference": "test_3",
"message": "This is a test delivery from the MM2Empire partner console. No money moved and no item changed hands.",
"payment": { "id": "pay_test", "orderId": "test-order", "metadata": { "source": "console-test" }, "payoutCents": 1, "…": "…" }
}
}
```
Its own event type, so a test can never be mistaken for money. Your handler should recognise `webhook.test` and do nothing but return 2xx.
---
# Discord notifications
> A Discord channel for the people running your site: readable messages for every payment, payout and money movement, no code required.
## What it is
Webhooks are for your server. Discord notifications are for your **team**: a message in a channel of yours when a user sells you an item, a payout goes out, a delivery finishes or is blocked, or your company moves money between its pools. Everything is one rendering of the same events your server receives, so the two never disagree.
Set it up under **Integration → Notifications** in the console. It needs no code and covers both products.
## Connecting a channel
1. **Create a webhook in Discord** — In your server, open **Server settings → Integrations → Webhooks**, create one, pick the channel it posts to, and copy its URL. It starts with `https://discord.com/api/webhooks/`.
2. **Paste it in the console** — An owner pastes the URL on the Notifications page and presses Connect. We post a connection message to the channel **before** saving, so a wrong URL fails in front of you rather than silently later.
3. **Choose what to send** — Three switches: **Payments** (a sale, an order completing, a refused basket), **Payouts** (an order, a finished or blocked delivery) and **Balance and funding** (pool funding, releases, claimed earnings). All on by default.
> **The URL is a password:** Anyone holding a Discord webhook URL can post to that channel. We store it encrypted and only ever show its numeric id again. If it leaks, delete the webhook in Discord and connect a new one.
## What arrives
| Message | When |
| --- | --- |
| **Payment taken** | Who sold what, for how much, and whether MM2Empire or your pool paid. |
| **Payment refused** | A basket that bought nothing, with the reasons. |
| **Order completed** / **abandoned** | A pop-up session ended, with the total. |
| **Payout order** | Items bought for a user and what your float was charged. |
| **Delivery completed** / **failed** / **Payout delivery blocked** | An item reaching your user's Roblox account, or one that cannot. |
| **Pool funded** / **released**, **Earnings claimed** | Money moved by your company, with the balance after. |
Messages arrive within a few seconds of the event. If MM2Empire staff have set a logo on your company it appears on each message.
## When Discord says no
If Discord rejects a message because the webhook was deleted or the URL is wrong, we **switch the connection off** after that one attempt rather than retrying into a dead channel. The Notifications page shows the reason and the time; connecting the webhook again (or a new one) turns it back on. Rate limits and outages on Discord's side are retried automatically and never switch anything off.
Any seat can press **Send test** to post a test message; only an owner can connect, change the switches, replace or disconnect.
---
# Console guide
> A tour of the partner console: what each page is for, who can change what, and where to look when something is wrong.
## Signing in
The console lives at `partner.mm2empire.com`. Each person at your company has a **seat** with their own username, password and authenticator app. A new seat is claimed with a one-time **setup code** we issue when the seat is created; the person enters their username, the code, chooses a password and enrols an authenticator. Recovery codes are shown once, at enrolment.
| Role | Can |
| --- | --- |
| **Owner** | Everything: move money, change product settings, create and revoke API keys, set the webhook and notifications, restyle the pop-up. |
| **Member** | Read everything an owner can, send test events and test notifications. Cannot change anything that costs money or changes the integration. |
## Dashboard
The front door. Your spendable balance and the buckets holding money, then one filter (lifetime, 30 days, 7 days, 24 hours) that scopes everything under it: volume processed, transactions, customers served and money credited to you, each against the previous period; a chart of activity by product; both products' state; integration health; and the last few movements across both products.
- Windows are calendar-aligned in UTC, so a tile equals the sum of the bars beneath it.
- **Integration health** turns red when webhook deliveries have run out of retries, with a link to the delivery log. That card is the first place to look when a partner says an event never arrived.
- Until your account is fully set up, a checklist at the top says what is left and links to each step.
## MM2 Payments
| Tab | What lives there |
| --- | --- |
| **Pool** | Who funds your buys (MM2Empire, or your own pool), the buying switch, your pool and claimable earnings, performance over a range, every payment taken, and the buying settings (rate, relist markup, minimum payout, auto-relist). |
| **Rates** | Your rate card: every MM2 item with what you pay and what MM2Empire pays, per-item price, stock limit and relist switches. Read-only while MM2Empire funds you. |
| **Inventory** | Copies your pool bought and has not sold on: list them for sale, reprice, or take them off sale. |
| **Integration** | The pop-up's branding with a live preview, the embed snippet, the webhook endpoint and its signing secret, the delivery log, and the test event button. |
Every payment in the list opens a detail dialog with your order id first, then the customer, the item, the amount and the webhook deliveries it produced.
## MM2 Payouts
| Tab | What lives there |
| --- | --- |
| **Float** | The selling switch, the float and the spendable balance beside it, fund and release, which money pays for payouts (its own float or the Payments pool), how the shop lists items, MM2Empire's margin on your buys, and your per-order and per-day ceilings. |
| **Orders** | Every order placed for your users, with its status, what was charged and when. Partly-filled orders are shown as normal outcomes, not failures. |
| **Integration** | The pop-up snippet for the shop, the API route for a self-built shop, and the events you will receive. |
## Integration
- **API keys**: create a key (the secret is shown once, with a ready-made `curl` to prove it works), see when each key was last used, and revoke.
- **Notifications**: connect a Discord channel for your team, choose what it hears about, send a test, disconnect. See [Discord notifications](https://partner.mm2empire.com/en/docs/discord-notifications).
- **Documentation**: this site.
## Billing and account
- **Transactions**: every movement on your wallet, newest first, with the balance after each. Top-ups, withdrawals, pool moves and claims all appear here. See [Balance, top-ups and withdrawals](https://partner.mm2empire.com/en/docs/billing).
- **Company**: your company name, the handle the API echoes back on `/ping`, contact email, and your own seat's role. Company details are set by our staff, since they are what was vetted.
The balance pill in the top bar shows spendable balance on every page, with Add funds and Withdraw one click away, and re-reads itself on every navigation so it never goes stale after a move.
---
# Balance, top-ups & withdrawals
> How money gets into your account, where it sits, how it moves between pools, and how it comes back out.
## One balance, named buckets
Your company holds one prepaid USD balance. Different jobs hold different slices of it, and the console always names which slice a figure is:
| Bucket | Filled by | Spent by |
| --- | --- | --- |
| **Spendable** | Top-ups, money returned from a pool or float, claimed earnings | Moving money into a pool or float; withdrawing |
| **Payments pool** | Add to pool (from spendable); relisted stock that sells | Buying items from your users on your own funding |
| **Payouts float** | Add to float (from spendable) | Payout orders |
| **Claimable** | Payments MM2Empire funded, the moment the item moves | Claim, which moves it to spendable |
> **Note:** The dashboard deliberately shows no single "total". The buckets mean different things, and a headline that added them would be the one figure a finance team must never be shown.
## Adding funds
Money credits **at par**: what you put in is what you can spend. Two rails, both from the Add funds dialog:
| Rail | Amounts | Notes |
| --- | --- | --- |
| **Crypto** | Any amount above the minimum, no ceiling | We generate a deposit address for the currency you pick; the balance updates once the network confirms. A small processing fee is shown before you send. This is the rail that funds a real float. |
| **Card, Apple Pay, Cash App** | Fixed tiers, capped per charge | Priced above the credit because of the card surcharge, so they suit topping up rather than funding a float. You finish on our payment provider's page; card details never reach us. |
Our staff can also credit or debit your balance directly against a wire or an invoice; those movements appear in Transactions with the note they were made with.
## Moving money between buckets
- **Add to pool / Return to balance** on the Payments page, and **Add to float / Take money out** on the Payouts page, move money between spendable and that product's bucket. Nothing is spent by a move and it can be reversed any time.
- **Claim** on the Payments page sweeps everything MM2Empire has credited you into spendable. Once every 15 minutes, owners only.
- Every move is announced to your Discord channel if you have connected one, with the balance after.
## Withdrawing
An owner can send unspent **spendable** balance back out to a crypto address from the Withdraw dialog: pick the asset and its network, enter the address, confirm. The dialog shows the estimated network fee and your balance after. A payout to the wrong network cannot be recovered, so the address is checked against the network you chose and confirmed once more before it is sent.
Money in a pool or a float has to be returned to spendable first; money waiting to be claimed has to be claimed first.
## Statements
The **Transactions** page is your statement: every posted movement on the wallet, whatever put it there, with the description, your reference where there was one, and the balance after. It includes staff adjustments, deposits, withdrawals, pool moves and claims. The per-product pages carry the product-level feeds (payments taken, payout orders).
---
# Endpoint index & limits
> Every endpoint on the partner API in one place, with its rate limit, plus the limits and defaults the products run on.
## Conventions
| | |
| --- | --- |
| Base URL | `https://api.mm2empire.com` |
| Authentication | `X-Partner-Key: ` on every request. See [Authentication](https://partner.mm2empire.com/en/docs/authentication). |
| Content type | `application/json` for every request body and response. |
| Money | Integer US cents. Fields end in `Cents`; responses carry `"currency": "USD"`. |
| Timestamps | ISO 8601 with offset, for example `2026-09-05T13:21:04+00:00`. |
| Writes | Every `POST` marked below takes an `Idempotency-Key` header (≤ 128 characters). See [Core concepts](https://partner.mm2empire.com/en/docs/concepts#idempotency). |
| Errors | `{ "error": { "code", "message", "retryable" } }`. See [Errors](https://partner.mm2empire.com/en/docs/errors). |
| Rate limits | Per company per route family per minute, shared across your keys. `429` with `Retry-After`. |
| Paging | `limit` and `offset` query parameters; responses carry `items`, `total`, `limit`, `offset`. |
## General
| Endpoint | Purpose | Limit / min |
| --- | --- | --- |
| `GET /partner/v1/ping` | Prove a key works; returns your slug, the key id and our server time. | — |
## MM2 Payments — /partner/v1/payments
| Endpoint | Purpose | Limit / min |
| --- | --- | --- |
| `POST /customers` ⁱ | Link a customer (Roblox username or id, optional `externalRef`). | 60 |
| `GET /customers` | List linked customers. `search`, `limit ≤ 200`. | 120 |
| `GET /customers/{ref}` | One customer by any identifier. | 120 |
| `GET /customers/{ref}/inventory` | What they hold, stacked, with your quote per stack. `game`, `limit ≤ 100`. | 60 |
| `POST /sessions` ⁱ | Open a pop-up session; returns token and URL. | 60 |
| `GET /sessions/{id}` | Session status, payments, `orderCompletedAt`. | 120 |
| `POST /sessions/{id}/close` | End a session and fire its order rollup now. | 60 |
| `POST /quote` | Price stacks without selling. | 60 |
| `POST /sell` ⁱ | Sell stacks to you. Per-item outcomes. | 20 |
| `GET /payments` | Payments taken. `orderId`, `sessionId`, `customerRef`, `limit ≤ 200`. | 120 |
| `GET /payments/{id}` | One payment. | 120 |
| `GET /orders/{orderId}` | Everything under one of your order ids. | 120 |
| `GET /items` | Your rate card. `search`, `itemType`, `rarity`, `limit ≤ 100`. | 60 |
| `POST /deposits` ⁱ | Where a customer sends items: live bots, join links, pop-up URL. | 60 |
| `POST /withdrawals` ⁱ | Return a customer's own items to their Roblox account. ≤ 4 item types. | 20 |
| `GET /withdrawals/{id}` | One withdrawal's status. | 120 |
ⁱ takes an `Idempotency-Key`.
## MM2 Payouts — /partner/v1/payouts
| Endpoint | Purpose | Limit / min |
| --- | --- | --- |
| `GET /listings` | The catalogue at your prices. Filters, `sort`, `limit ≤ 100`, `offset ≤ 5000`. | 120 |
| `POST /orders` ⁱ | Buy up to 4 stacks / 100 items for a customer and start delivery. | 30 |
| `GET /orders/{id}` | One order, by our id or yours. | 120 |
| `POST /sessions` ⁱ | Open a shop pop-up with a retail multiplier. | 60 |
| `GET /claims?customerRef=` | What one customer still has to collect, and which bot holds it. | 120 |
| `GET /pool` | Float balance, mode and your markup. | 60 |
## Limits and defaults
| Setting | Value | Notes |
| --- | --- | --- |
| Session token lifetime | 5 minutes | One-time. Exchanged when the pop-up opens. |
| Pop-up access window | 30 minutes default, 60 maximum | `ttlSeconds` on session creation. |
| `orderId` length | 120 characters | Trimmed. Not required to be unique. |
| `metadata` | 20 keys, scalar values | Strings, integers, booleans or null. No nesting. |
| Idempotency key length | 128 characters | |
| Stacks per sell request | 200 | MM2 Payments `/sell`. |
| Item types per withdrawal or payout order | 4 | One in-game delivery carries at most four stacks. |
| Items per payout order | 100 | |
| Payout order ceiling | $1,000 default | `maxOrderCents`, set in the console. Optional daily cap. |
| Retail multiplier | 1.0× to 5.0× | `retailMultiplierBps` 10000–50000. Rounds up. |
| MM2Empire margin on payouts | Set by staff, at most 100% | `markupBps`, read-only for partners. |
| Default buy rate (own pool, custom rates) | 50% of catalogue value | `defaultBuyRateBps` 5000. Seeded from ours on switch. |
| Default stock limit per item | 10 copies | `maxInventory`. |
| Default relist markup | 10% over value | `relistMarkupBps` 1000; may be negative down to −50%. |
| Minimum payout | $0.01 default | `minPayoutCents`. |
| Setting cooldowns | 15 minutes | Funding source, pricing mode, pool mode, claiming earnings. |
| Webhook signature tolerance | 300 seconds | |
| Webhook retries | 20 attempts, exponential backoff to hourly | |
| Webhook delivery log retention | 30 days | |
| Webhook URL length | 2048 characters | https, port 443, public host. |
## Changes to the API
The machine rail is a published contract. Fields are added, never removed or renamed, and a new field is always optional to read. Event types are added under new names; existing payloads keep their shape. If you parse responses strictly, allow unknown fields.