Vendor API
About 5 minutes to read
Connect your own Discord bot or shop to the Moonlight marketplace. When someone buys from you, your bot gives them access to your model by their Discord ID. If you refund them or their account is stolen, your bot takes the access away again.
You do not need this to sell on Moonlight. The marketplace checkout and license keys work without it. Use the API if you already sell through your own bot and want buyers to get access automatically.
#What you can do
| Action | What it does |
|---|---|
| List your packs | Returns your packs and their IDs |
| Check a user | Tells you whether a Discord user has access to one of your packs |
| Give access | Gives a Discord user lifetime access to one of your packs |
| Take access away | Removes a Discord user's access to one of your packs |
The key only works on your own packs. It cannot upload files, change prices or touch your payment settings.
#Before you start
- You need the Vendor role and at least one pack saved on your vendor page. A draft is enough to start testing.
- Your buyer must have signed in to moonlight.zip with their Discord account at least once. If they have not, the grant fails with
user_not_found. Tell them to sign in, then send the grant again.
#Step 1: Create your key
- Open your vendor page.
- Go to the Settings tab.
- In the API key panel, press Create key.
- Copy the key straight away. It starts with
mlv_and is shown only once.
Important
Keep the key on your server only. Never put it in a browser page, a desktop app, a public repository or a Discord message. Anyone who has it can give and remove access to your packs.
You have one key. If it leaks, press Replace key. The old key stops working immediately and you get a new one.
#Step 2: Find your pack ID
The API key panel lists your packs under Your pack IDs. Click an ID to copy it.
You can also ask the API:
curl 'https://moonlight.zip/api/vendor/v1/packs' \
-H 'Authorization: Bearer mlv_YOUR_KEY'
{
"success": true,
"data": {
"packs": [
{ "id": "my-valorant-model", "name": "My Valorant Model", "game": "valorant", "type": "model", "status": "published" }
]
}
}
#Step 3: Send requests
Base URL
https://moonlight.zip/api/vendor/v1
Authentication. Send your key in a header on every request:
Authorization: Bearer mlv_YOUR_KEY
Discord IDs are always sent as text, never as a number. Many languages round large numbers, which silently changes the ID.
#Give access
PUT /packs/{pack_id}/users/{discord_id}
Gives the user lifetime access. The body is optional. note is your own text, such as an order number (up to 500 characters). It shows next to the buyer on your Buyers tab.
curl -X PUT 'https://moonlight.zip/api/vendor/v1/packs/my-valorant-model/users/123456789012345678' \
-H 'Authorization: Bearer mlv_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{"note":"Order 1842"}'
{
"success": true,
"data": {
"pack_id": "my-valorant-model",
"discord_id": "123456789012345678",
"has_access": true,
"created": true,
"access": {
"status": "active",
"source": "manual",
"note": "Order 1842",
"granted_at": "2026-10-12T18:30:00.000Z",
"expires_at": null,
"revoked_at": null
}
}
}
A new grant answers 201 with "created": true. If the user already has access, you get 200 with "created": false and nothing changes. This means it is always safe to send the same grant again, for example after a timeout.
#Check a user
GET /packs/{pack_id}/users/{discord_id}
curl 'https://moonlight.zip/api/vendor/v1/packs/my-valorant-model/users/123456789012345678' \
-H 'Authorization: Bearer mlv_YOUR_KEY'
has_access is the field to read. It is true only while the user can use the pack.
access is null if the user never had the pack. Otherwise it tells you more:
| Field | Meaning |
|---|---|
status | active, expired or revoked |
source | How they got it: manual (you, by API or dashboard), purchase (marketplace checkout), license (a redeemed key) or free |
note | Your note, if you sent one |
granted_at | When access was given |
expires_at | When it ends, or null for lifetime |
revoked_at | When it was taken away, or null |
Checking a user who has never signed in to Moonlight is not an error. You get has_access: false.
#Take access away
DELETE /packs/{pack_id}/users/{discord_id}
Use this for refunds, chargebacks and stolen accounts. The body is optional. reason is saved with the record.
curl -X DELETE 'https://moonlight.zip/api/vendor/v1/packs/my-valorant-model/users/123456789012345678' \
-H 'Authorization: Bearer mlv_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{"reason":"Chargeback"}'
The answer has "revoked": true the first time and "revoked": false if there was nothing left to remove. Both are 200, so it is safe to repeat.
Note
Revoke works on any access to your pack, including a purchase made through the marketplace checkout. It stops future downloads. A file the user already downloaded cannot be pulled back. To restore access, send the grant again.
#Example: Python bot
This uses aiohttp, which discord.py already installs.
import os
import aiohttp
BASE = "https://moonlight.zip/api/vendor/v1"
KEY = os.environ["MOONLIGHT_VENDOR_KEY"]
PACK_ID = "my-valorant-model"
HEADERS = {"Authorization": f"Bearer {KEY}"}
async def moonlight(method: str, discord_id: int | str, body: dict | None = None) -> tuple[int, dict]:
url = f"{BASE}/packs/{PACK_ID}/users/{discord_id}" # the ID goes in as text
async with aiohttp.ClientSession(headers=HEADERS) as session:
async with session.request(method, url, json=body) as response:
return response.status, await response.json()
async def give_access(discord_id, order: str) -> str:
status, body = await moonlight("PUT", discord_id, {"note": order})
if status in (200, 201):
return "Access granted."
if body["error"]["code"] == "user_not_found":
return "Sign in at https://moonlight.zip with this Discord account first, then press the button again."
return f"Could not grant access: {body['error']['message']}"
async def has_access(discord_id) -> bool:
status, body = await moonlight("GET", discord_id)
return status == 200 and body["data"]["has_access"]
async def remove_access(discord_id, reason: str) -> bool:
status, body = await moonlight("DELETE", discord_id, {"reason": reason})
return status == 200
#Example: Node.js bot
Works with Node 18 or newer, where fetch is built in.
const BASE = 'https://moonlight.zip/api/vendor/v1';
const KEY = process.env.MOONLIGHT_VENDOR_KEY;
const PACK_ID = 'my-valorant-model';
async function moonlight(method, discordId, body) {
// Keep Discord IDs as strings. As numbers they lose their last digits.
const response = await fetch(`${BASE}/packs/${PACK_ID}/users/${String(discordId)}`, {
method,
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, body: await response.json() };
}
async function giveAccess(discordId, order) {
const { status, body } = await moonlight('PUT', discordId, { note: order });
if (status === 200 || status === 201) return 'Access granted.';
if (body.error.code === 'user_not_found') {
return 'Sign in at https://moonlight.zip with this Discord account first, then try again.';
}
return `Could not grant access: ${body.error.message}`;
}
async function hasAccess(discordId) {
const { status, body } = await moonlight('GET', discordId);
return status === 200 && body.data.has_access;
}
async function removeAccess(discordId, reason) {
const { status } = await moonlight('DELETE', discordId, { reason });
return status === 200;
}
#Limits
Per vendor, every 5 minutes:
| Kind of request | Limit |
|---|---|
Checks and pack list (GET) | 600 |
Grants and revokes (PUT, DELETE) | 240 |
Over the limit you get 429 with a Retry-After header. Wait that many seconds before trying again. Do not retry in a tight loop.
Warning
Requests without a working key are counted per network address. More than 20 in 5 minutes and that address is timed out for 15 minutes. The timeout doubles each time it happens again, up to 24 hours. A request with a working key is never held up by this. If your bot is still using a key you replaced, give it the new key and it works immediately.
#Errors
Every error has the same shape:
{ "success": false, "error": { "code": "user_not_found", "message": "..." } }
| Status | Code | What it means | What to do |
|---|---|---|---|
| 400 | invalid_request | The Discord ID or the body is malformed | Send the ID as 15 to 25 digits of text, and valid JSON |
| 401 | unauthorized | The key is missing, mistyped, replaced or turned off | Check the header, or create a new key |
| 403 | forbidden | Your vendor account is suspended or no longer has the Vendor role | Contact Moonlight staff |
| 404 | pack_not_found | No pack with that ID belongs to your key | Check the pack ID in your Settings tab |
| 404 | user_not_found | That Discord user has never signed in to moonlight.zip | Ask them to sign in once, then send the grant again |
| 429 | rate_limited | Too many requests | Wait for the Retry-After seconds |
| 500 | server_error | Something failed on our side | Safe to retry after a short wait |
#Common questions
Can I grant monthly access? Not through the API yet. API grants are lifetime. Monthly grants are available by hand on your Buyers tab.
Can I get a list of everyone who owns my pack? Not through the API. Your Buyers tab shows the full list. The API checks one user at a time.
What if my bot sends the same grant twice? Nothing bad. The second request changes nothing and tells you "created": false.
I got an HTML page with status 403 that says "Just a moment". That is not from the API. It is the site's protection layer stopping your server before it reaches us. Contact Moonlight staff with your server's IP address.
How do buyers get the model after I grant it? It appears in their Moonlight app and on the marketplace under their owned packs. They do not need to enter anything.
Where do I get help? Open a ticket in the Moonlight Discord and mention the Vendor API.
Stuck on a step? Ask in the Moonlight Discord and a staff member will help.