API reference
Publish releases from CI with an organization API key, connect your own key server, and call the marketplace and license routes the site and the launcher use.
Overview
Every route below is served under https://api.aimjunkies.net/api/v1. Authentication is an organization API key, a member session or a Bearer secret, depending on the route. All keys and tokens in the examples are samples.
aimjunkies_live_...) with only the scopes they need. The gateway secret authenticates AimJunkies to your server, never the other way round.Delivery gateway
WebSocket events
wss://api.yourdomain.com/v1/aimjunkies-streamGateway secret (AUTH frame)The endpoint you register as a wss:// Master Gateway. The Test button in the Developer Console connects to it, sends the AUTH frame below and checks your reply. Orders and key-stock pulls are delivered as authenticated HTTPS requests (next entry), so keep an HTTPS endpoint available as well.
| Name | In | Type | Description |
|---|---|---|---|
| actionrequired | frame | string | Set to "AUTH" for connection authentication, or "PING" for round-trip latency keepalive. |
| tokenrequired | frame | string | Your gateway secret (aj_master_sec_sample_99a8b7c6d5e4f3a2b1c0). |
| timestamprequired | frame | integer | Epoch millisecond timestamp used for anti-replay verification. |
import { WebSocketServer, WebSocket } from 'ws';
const wss = new WebSocketServer({ port: 8081 });
const MASTER_TOKEN = process.env.AIMJUNKIES_MASTER_TOKEN || 'aj_master_sec_sample_99a8b7c6d5e4f3a2b1c0';
wss.on('connection', (ws: WebSocket) => {
let authenticated = false;
ws.on('message', (raw: string) => {
const packet = JSON.parse(raw.toString());
// 1. Verify Mutual Handshake
if (packet.action === 'AUTH') {
if (packet.token === MASTER_TOKEN) {
authenticated = true;
return ws.send(JSON.stringify({ status: 'AUTHENTICATED', orgId: 'org_developer_hub' }));
}
return ws.close(4001, 'Unauthorized Master Token');
}
if (!authenticated) return ws.close(4002, 'Unauthenticated stream');
// 2. Handle Push Events
if (packet.event === 'ORDER_FULFILLMENT') {
ws.send(JSON.stringify({
status: 'SUCCESS',
orderId: packet.orderId,
licenseKey: 'SAMPLE-KEY-99A1-B882-C773',
instructions: 'Run loader as Admin.'
}));
}
});
});{
"status": "AUTHENTICATED",
"orgId": "org_developer_hub",
"operationalStatus": "OPERATIONAL",
"latencyMs": 12
}HTTPS fulfillment
https://api.yourdomain.com/v1/aimjunkies-gatewayAuthorization: Bearer <your secret>The endpoint you register for your own key server. AimJunkies POSTs here when an order is paid (ORDER_FULFILLMENT), to pull key stock (GENERATE_KEY_STOCK) and when you press Test (INTEGRATION_TEST). Every request carries your secret as a Bearer token and a deterministic X-Idempotency-Key: answer a repeated key with the result you gave the first time. Only https:// endpoints are accepted and redirects are not followed.
| Name | In | Type | Description |
|---|---|---|---|
| Authorizationrequired | header | string | Bearer <your secret>. Compare it in constant time and refuse anything else. |
| X-Idempotency-Keyrequired | header | string | Stable per fulfillment (the order item id, or the stock batch). A retry repeats it; return the same result, never a second license. |
| eventrequired | body | string | Event topic: "ORDER_FULFILLMENT", "GENERATE_KEY_STOCK", or "INTEGRATION_TEST". |
import express, { Request, Response } from 'express';
import crypto from 'crypto';
const app = express();
app.use(express.json());
const MASTER_TOKEN = process.env.AIMJUNKIES_MASTER_TOKEN || 'aj_master_sec_sample_99a8b7c6d5e4f3a2b1c0';
function verifySecret(req: Request, res: Response, next: Function) {
// Constant-time comparison of "Bearer <your secret>"
const trusted = Buffer.from('Bearer ' + MASTER_TOKEN, 'utf8');
const untrusted = Buffer.from((req.headers['authorization'] as string) || '', 'utf8');
if (trusted.length !== untrusted.length || !crypto.timingSafeEqual(trusted, untrusted)) {
return res.status(401).json({ status: 'FAILED', message: 'Invalid secret' });
}
next();
}
app.post('/v1/aimjunkies-gateway', verifySecret, (req, res) => {
res.json({ status: 'SUCCESS', operationalStatus: 'OPERATIONAL' });
});{
"status": "SUCCESS",
"operationalStatus": "OPERATIONAL",
"latencyMs": 14
}Releases and builds
Upload a build
/api/v1/organizations/:orgId/assetsAPI key (X-API-Key) or member sessionRegisters a file in your organization’s Build Vault and returns a short-lived upload URL. PUT the file to that URL, then call POST …/assets/:assetId/versions/:versionId/finalize to verify it. The version id you get back is the assetVersionId a release points at. New versions of the same asset use POST …/assets/:assetId/versions.
| Name | In | Type | Description |
|---|---|---|---|
| X-API-Keyrequired | header | string | Organization API key (aimjunkies_live_sample_8a9b0c1d2e3f4a5b6c7d8e9f). Roles OWNER, ADMIN or BUILDER; a key can also be sent as Authorization: Bearer. |
| namerequired | body | string | Asset name, 2 to 100 characters. |
| filenamerequired | body | string | File name (letters, digits, dot, dash, underscore). |
| mimeTyperequired | body | string | MIME type of the file. |
| sizeBytesrequired | body | integer | File size in bytes, up to 5 GB. |
| versionrequired | body | string | Semantic version, for example 1.0.0 or 1.0.0-rc1. |
| checksumSha256 | body | string | SHA-256 of the file, checked at finalize. |
| changelog | body | string | Notes for this version. |
const res = await fetch('https://api.aimjunkies.net/api/v1/organizations/org_developer_hub/assets', {
method: 'POST',
headers: { 'X-API-Key': process.env.AJ_API_KEY!, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Apex loader',
filename: 'loader-4.2.8.exe',
mimeType: 'application/octet-stream',
sizeBytes: 8421376,
version: '4.2.8',
}),
});
const { asset, uploadUrl } = await res.json();{
"asset": {
"id": "ast_…",
"name": "Apex loader",
"versions": [
{
"id": "ver_…",
"version": "4.2.8"
}
]
},
"uploadUrl": "https://…",
"expiresInSeconds": 900,
"expiresAt": "2026-11-10T12:15:00.000Z"
}List releases
/api/v1/organizations/:orgId/catalog/products/:productId/releasesAPI key (X-API-Key) or member sessionEvery release of one of your products, newest first, with its status (TESTING, LIVE, SUPERSEDED, ROLLED_BACK) and live detection status.
| Name | In | Type | Description |
|---|---|---|---|
| X-API-Keyrequired | header | string | Organization API key (aimjunkies_live_sample_8a9b0c1d2e3f4a5b6c7d8e9f). |
| orgIdrequired | path | string | Your organization id. |
| productIdrequired | path | string | The product id. |
const res = await fetch('https://api.aimjunkies.net/api/v1/organizations/org_developer_hub/catalog/products/PRODUCT_ID/releases', {
headers: { 'X-API-Key': process.env.AJ_API_KEY! },
});
const releases = await res.json();[
{
"id": "rel_…",
"version": "4.2.8",
"gameBuild": "1.2.3",
"status": "LIVE",
"detectionStatus": "UNDETECTED",
"promotedAt": "2026-11-09T18:00:00.000Z"
}
]Create a release
/api/v1/organizations/:orgId/catalog/products/:productId/releasesAPI key (X-API-Key) or member sessionCreates a release from a finalized Build Vault version. It starts in TESTING; buyers keep getting the live release until you promote this one. Detection status is never set with an API key.
| Name | In | Type | Description |
|---|---|---|---|
| X-API-Keyrequired | header | string | Organization API key (aimjunkies_live_sample_8a9b0c1d2e3f4a5b6c7d8e9f). |
| versionrequired | body | string | Release version: letters, digits and . _ + - only, up to 40 characters. |
| gameBuild | body | string | The game build it was verified on. |
| notes | body | string | Release notes, up to 5000 characters. |
| assetVersionId | body | string | The Build Vault asset version this release ships. |
| cliArgumentPattern | body | string | Command line the launcher passes a console loader; {KEY} becomes the licence key. |
await fetch('https://api.aimjunkies.net/api/v1/organizations/org_developer_hub/catalog/products/PRODUCT_ID/releases', {
method: 'POST',
headers: { 'X-API-Key': process.env.AJ_API_KEY!, 'Content-Type': 'application/json' },
body: JSON.stringify({ version: '4.2.8', gameBuild: '1.2.3', notes: 'Updated offsets.', assetVersionId: 'VAULT_VERSION_ID' }),
});{
"id": "rel_…",
"version": "4.2.8",
"gameBuild": "1.2.3",
"status": "TESTING",
"assetVersionId": "ver_…",
"createdAt": "2026-11-10T12:00:00.000Z"
}Promote a release
/api/v1/organizations/:orgId/catalog/products/:productId/releases/:releaseId/promoteAPI key (X-API-Key) or member sessionMakes the release the live one: buyers get it at once and the previous live release is superseded. An optional body opens a Prime early-access window (the release is live for Prime members first). POST …/releases/:releaseId/rollback goes back to the previous release.
| Name | In | Type | Description |
|---|---|---|---|
| X-API-Keyrequired | header | string | Organization API key (aimjunkies_live_sample_8a9b0c1d2e3f4a5b6c7d8e9f). |
| primeEarlyAccessHours | body | integer | Hours the release is live for Prime members before everyone else. Capped by the admin limit. |
await fetch('https://api.aimjunkies.net/api/v1/organizations/org_developer_hub/catalog/products/PRODUCT_ID/releases/RELEASE_ID/promote', {
method: 'POST',
headers: { 'X-API-Key': process.env.AJ_API_KEY! },
});{
"id": "rel_…",
"version": "4.2.8",
"status": "LIVE",
"promotedAt": "2026-11-10T12:05:00.000Z"
}Webhooks
Register a webhook
/api/v1/organizations/:orgId/webhooksAPI key (X-API-Key) or member sessionRegisters an HTTPS endpoint for your organization (public addresses only, no redirects). AimJunkies POSTs a JSON event to it when an order line of yours completes (order.completed), when a license is issued to a buyer of your product (license.issued) and when a line is refunded in full or in part (refund.processed); all three when events is left out. The body carries ids and statuses only, never a key, a secret field or an email: {"id", "type", "createdAt", "organizationId", "data": {"orderId", "orderItemId", "offerId", "productId", "status"}}; read the rest with your API key. Each request has the headers X-AimJunkies-Event, X-AimJunkies-Delivery (the same id on every retry: drop repeats) and X-AimJunkies-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the endpoint secret. Answer with any 2xx within 10 seconds; anything else is retried (6 attempts, 30 seconds then doubling) and then marked FAILED. The secret is returned once, here. GET …/webhooks lists endpoints without secrets, DELETE …/webhooks/:webhookId removes one (scope webhooks:write), and GET …/webhooks/deliveries shows every attempt with status code, response excerpt and duration (webhooks:read).
| Name | In | Type | Description |
|---|---|---|---|
| X-API-Keyrequired | header | string | Organization API key (aimjunkies_live_sample_8a9b0c1d2e3f4a5b6c7d8e9f). |
| urlrequired | body | string | Your https:// endpoint. |
| events | body | string[] | order.completed, license.issued, refund.processed. Defaults to all three. |
const res = await fetch('https://api.aimjunkies.net/api/v1/organizations/org_developer_hub/webhooks', {
method: 'POST',
headers: { 'X-API-Key': process.env.AJ_API_KEY!, 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://hooks.example.com/aimjunkies', events: ['order.completed'] }),
});
const { id, secret } = await res.json(); // keep the secret: it is shown once{
"id": "whk_…",
"organizationId": "org_developer_hub",
"url": "https://hooks.example.com/aimjunkies",
"secret": "whsec_…",
"events": [
"order.completed",
"refund.processed"
],
"status": "ACTIVE",
"createdAt": "2026-11-10T12:00:00.000Z"
}Licenses & HWID
Activate a license
/api/v1/licenses/activateNo authBinds a license key to a hardware ID on first use (or carries it to the same machine after a part swap) and returns a signed status valid for one hour. Rate limited to 10 requests a minute.
| Name | In | Type | Description |
|---|---|---|---|
| keyrequired | body | string | The license key. |
| hwidrequired | body | string | The machine’s hardware ID. |
| hwidComponents | body | object | Per-component hashes, used to recognise the same machine after a part swap. |
const res = await fetch('https://api.aimjunkies.net/api/v1/licenses/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'AJ-APEX-9921-X99B-814A', hwid: 'c7a93b4e182f09d6e84a2c5b3d1f8e7a' }),
});
const status = await res.json();{
"licenseId": "lic_…",
"hwid": "c7a93b4e182f09d6e84a2c5b3d1f8e7a",
"status": "ACTIVE",
"expiresAt": "2026-11-10T13:00:00.000Z",
"signature": "…"
}Validate a license
/api/v1/licenses/validateNo authChecks that a key is still valid for a hardware ID and returns a fresh signed status. Clients re-check about once an hour; a suspended or revoked license stops validating within that hour. Rate limited to 120 requests a minute (20 per key).
| Name | In | Type | Description |
|---|---|---|---|
| keyrequired | body | string | The license key. |
| hwidrequired | body | string | The machine’s hardware ID. |
| hwidComponents | body | object | Per-component hashes. |
const res = await fetch('https://api.aimjunkies.net/api/v1/licenses/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'AJ-APEX-9921-X99B-814A', hwid: 'c7a93b4e182f09d6e84a2c5b3d1f8e7a' }),
});
const status = await res.json();{
"licenseId": "lic_…",
"hwid": "c7a93b4e182f09d6e84a2c5b3d1f8e7a",
"status": "ACTIVE",
"expiresAt": "2026-11-10T13:00:00.000Z",
"signature": "…"
}Reset your own HWID
/api/v1/licenses/:id/hwid-reset/selfMember session (Authorization: Bearer)The license owner resets the hardware binding within the allowance of their plan (GET /api/v1/licenses/:id/hwid-reset/status shows how many are left and when the next one is due). Needs a signed-in member session; five requests an hour.
| Name | In | Type | Description |
|---|---|---|---|
| idrequired | path | string | The license id (from GET /api/v1/licenses/my). |
| Authorizationrequired | header | string | Bearer access token of the license owner. |
const res = await fetch('https://api.aimjunkies.net/api/v1/licenses/LICENSE_ID/hwid-reset/self', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
const allowance = await res.json(); // { allowed, remaining, nextAt, … }{
"allowed": true,
"blocked": null,
"limit": 3,
"used": 1,
"remaining": 2,
"periodDays": 365,
"lifetime": false,
"nextAt": null
}Marketplace
Search offers
/api/v1/catalog/market/offersNo authPublic, paged search over every live offer. Filter by section, game, offer type, product or seller, search with q, and sort. Offer-type attributes (for example region on a Steam key) can be sent as extra query parameters; GET /api/v1/catalog/market/facets lists the filters that apply. GET /api/v1/catalog/market/browse returns the section and game tree, and GET /api/v1/catalog/market/listings/:slug one listing with its offers.
| Name | In | Type | Description |
|---|---|---|---|
| section | query | string | Section slug, for example cheats or accounts. |
| game | query | string | Game slug. |
| type | query | string | Offer type slug, for example steam-key. |
| product | query | string | Product slug. |
| seller | query | string | Seller organization slug. |
| q | query | string | Text search. |
| sort | query | string | Sort order. |
| minPrice / maxPrice | query | number | Price range, in US dollars. |
| page / limit | query | integer | Paging. |
const res = await fetch('https://api.aimjunkies.net/api/v1/catalog/market/offers?section=keys-cards&type=steam-key&limit=20');
const offers = await res.json();{
"offers": [
{
"id": "off_…",
"title": "…",
"listing": {
"slug": "…"
},
"seller": {
"name": "…"
},
"purchasable": true
}
],
"filterable": [],
"meta": {
"total": 1,
"page": 1,
"limit": 20,
"pages": 1,
"sort": "relevance"
}
}Quote a cart
/api/v1/catalog/market/quoteNo authPrices a cart without buying anything: each line is checked against its offer (stock, plan, answers to the offer type’s checkout questions) and comes back priced or flagged. POST /api/v1/orders/quote does the same for a signed-in buyer, with their Prime and coupon discounts.
| Name | In | Type | Description |
|---|---|---|---|
| linesrequired | body | array | One entry per offer: offerId, and where the offer needs them planDays (days or "lifetime"), units, quantity and buyerInputs. |
const res = await fetch('https://api.aimjunkies.net/api/v1/catalog/market/quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lines: [{ offerId: 'OFFER_ID', planDays: 30, quantity: 1 }] }),
});
const quote = await res.json();{
"lines": [
{
"index": 0,
"offerId": "OFFER_ID",
"status": "ok",
"issues": [],
"deliveryEstimate": "Instant"
}
],
"subtotalCents": 0,
"currency": "USD",
"ready": true,
"checkout": {
"available": true,
"code": null
}
}Orders
Place an order
/api/v1/ordersMember session (Authorization: Bearer)Creates an order from offers for the signed-in member; each item becomes its own order line with its own status, delivery and protection window. The checkout page adds a bot check (Turnstile), so this route is meant for the site and apps, not for unattended scripts. Pay the order from the Wallet or through the payment provider checkout; the money is held in escrow until each line completes.
| Name | In | Type | Description |
|---|---|---|---|
| itemsrequired | body | array | 1 to 100 items: offerId, and as the offer needs planDays, units, quantity, buyerInputs, seenLineTotalCents. |
| couponCode | body | string | A coupon code. |
| gift | body | object | recipient and message, to send the order as a gift. |
const res = await fetch('https://api.aimjunkies.net/api/v1/orders', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ items: [{ offerId: 'OFFER_ID', planDays: 30, quantity: 1 }] }),
});
const order = await res.json();{
"id": "ord_…",
"status": "PENDING",
"items": [
{
"id": "itm_…",
"lineStatus": "AWAITING_PAYMENT"
}
]
}Confirm receipt
/api/v1/orders/lines/:id/confirmMember session (Authorization: Bearer)The buyer confirms an order line is received (status DELIVERED to RECEIVED). Without it the line confirms by itself after the offer type’s auto-confirm time; either way the protection window starts then. GET /api/v1/orders/lines/my lists your lines and GET /api/v1/orders/lines/:id shows one with its timers.
| Name | In | Type | Description |
|---|---|---|---|
| idrequired | path | string | The order line id. |
await fetch('https://api.aimjunkies.net/api/v1/orders/lines/LINE_ID/confirm', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});{
"id": "line_…",
"status": "PROTECTION",
"timers": {
"autoConfirmAt": null
}
}Open a dispute
/api/v1/orders/lines/:id/disputeMember session (Authorization: Bearer)The buyer disputes an order line from delivery until its protection window ends. Every timer on the line stops and the money stays held; the seller answers in the order chat (POST …/dispute/respond), and staff decide a full refund, a partial refund or a release. POST …/dispute/withdraw takes it back.
| Name | In | Type | Description |
|---|---|---|---|
| idrequired | path | string | The order line id. |
| reasonrequired | body | string | What is wrong. |
| requestedrequired | body | string | full_refund, partial_refund or replacement. |
| amountCents | body | integer | For a partial refund: the amount asked for. |
| evidenceAttachmentIds | body | string[] | Up to 5 attachments from the order chat. |
await fetch('https://api.aimjunkies.net/api/v1/orders/lines/LINE_ID/dispute', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: 'The key was already redeemed.', requested: 'full_refund' }),
});{
"id": "line_…",
"status": "DISPUTED"
}