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

ActionWhat it does
List your packsReturns your packs and their IDs
Check a userTells you whether a Discord user has access to one of your packs
Give accessGives a Discord user lifetime access to one of your packs
Take access awayRemoves 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

  1. You need the Vendor role and at least one pack saved on your vendor page. A draft is enough to start testing.
  2. 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

  1. Open your vendor page.
  2. Go to the Settings tab.
  3. In the API key panel, press Create key.
  4. 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:

FieldMeaning
statusactive, expired or revoked
sourceHow they got it: manual (you, by API or dashboard), purchase (marketplace checkout), license (a redeemed key) or free
noteYour note, if you sent one
granted_atWhen access was given
expires_atWhen it ends, or null for lifetime
revoked_atWhen 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 requestLimit
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": "..." } }
StatusCodeWhat it meansWhat to do
400invalid_requestThe Discord ID or the body is malformedSend the ID as 15 to 25 digits of text, and valid JSON
401unauthorizedThe key is missing, mistyped, replaced or turned offCheck the header, or create a new key
403forbiddenYour vendor account is suspended or no longer has the Vendor roleContact Moonlight staff
404pack_not_foundNo pack with that ID belongs to your keyCheck the pack ID in your Settings tab
404user_not_foundThat Discord user has never signed in to moonlight.zipAsk them to sign in once, then send the grant again
429rate_limitedToo many requestsWait for the Retry-After seconds
500server_errorSomething failed on our sideSafe 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.