ExamplesNode.js

Node.js

Two small scripts. The first places a purchase and polls it to a terminal status. The second is a webhook receiver that verifies the HMAC signature. They are independent — run them in separate terminals.

Tested against Node.js 20+. The only runtime dependency is axios for the buyer script and express for the receiver. Everything else is the standard library.

Setup

mkdir tradeon-integration && cd tradeon-integration
npm init -y
npm pkg set type=module
npm install axios express
 
cat > .env <<'EOF'
TRADEON_API_BASE=https://api.tradeon.market
TRADEON_API_KEY=mer_live_REPLACE_ME
TRADEON_WEBHOOK_SECRET=whsec_REPLACE_ME
EOF

Load the file before running each script:

set -a; . ./.env; set +a
⚠️

Keep the API key and webhook secret out of source control. Both are issued in the merchant cabinet at merchant.tradeon.market (Settings → API key and Settings → Webhook → Regenerate secret) and are shown exactly once.

Place a purchase and poll the result

buy.mjs
import axios from 'axios'
import { setTimeout as sleep } from 'node:timers/promises'
 
const API_BASE = process.env.TRADEON_API_BASE ?? 'https://api.tradeon.market'
const API_KEY = requireEnv('TRADEON_API_KEY')
 
const http = axios.create({
  baseURL: API_BASE,
  headers: { Authorization: `Bearer ${API_KEY}` },
  timeout: 15_000,
  validateStatus: () => true, // never throw — inspect status ourselves
})
 
async function buy({ itemId, maxPrice, customId, targetSteamId, targetTradeUrl }) {
  const res = await http.post('/api/v1/merchant/buy', {
    item_id: itemId,
    max_price: maxPrice,
    custom_id: customId,
    target_steam_id: targetSteamId,
    target_trade_url: targetTradeUrl,
  })
 
  if (res.status === 201) return { kind: 'created', purchase: res.data }
 
  // Branch on error.code, not the HTTP status. See /getting-started/errors.
  const code = res.data?.error?.code
  if (res.status === 400) return { kind: 'invalid_request', code, error: res.data.error }
  if (res.status === 402) return { kind: 'insufficient_balance', code, error: res.data.error }
  if (res.status === 404) return { kind: 'item_not_found', code, error: res.data.error }
  if (res.status === 409) return { kind: 'conflict', code, error: res.data.error }
  if (res.status === 401) throw new Error('API key missing or malformed Authorization header')
  if (res.status === 403) throw new Error('API key invalid or merchant blocked (rotate via the cabinet)')
  throw new Error(`unexpected ${res.status}: ${JSON.stringify(res.data)}`)
}
 
async function pollUntilTerminal(purchaseId, { intervalMs = 5_000, timeoutMs = 10 * 60_000 } = {}) {
  const deadline = Date.now() + timeoutMs
  while (Date.now() < deadline) {
    const res = await http.get(`/api/v1/merchant/purchases/${purchaseId}`)
    if (res.status !== 200) throw new Error(`GET /purchases/${purchaseId} returned ${res.status}`)
    if (res.data.status === 'completed' || res.data.status === 'failed') return res.data
    await sleep(intervalMs)
  }
  throw new Error(`timed out polling purchase ${purchaseId}`)
}
 
function requireEnv(name) {
  const v = process.env[name]
  if (!v) throw new Error(`${name} is not set`)
  return v
}
 
const outcome = await buy({
  itemId:          'REPLACE_WITH_ITEM_ID',
  maxPrice:        12.5,
  customId:        `order-${Date.now()}`, // unique per order; never random across retries
  targetSteamId:   '76561198000000000',
  targetTradeUrl:  'https://steamcommunity.com/tradeoffer/new/?partner=12345&token=abc123',
})
 
console.log(outcome)
 
if (outcome.kind === 'created') {
  const terminal = await pollUntilTerminal(outcome.purchase.purchase_id)
  console.log('terminal:', terminal)
}
node buy.mjs

Conflict-class errors (MERCHANT_PRICE_CHANGED, MERCHANT_DUPLICATE_CUSTOM_ID, MERCHANT_ITEM_UNAVAILABLE) are surfaced to the caller, not retried — the right recovery is business-specific. See Errors → Retry guidance.

Receive webhooks

Run this in a separate terminal. It listens on port 8080 and verifies every incoming request the way the API signs them — see the full algorithm for the rationale.

webhook-server.mjs
import express from 'express'
import crypto from 'node:crypto'
 
const PORT = Number(process.env.PORT ?? 8080)
const SECRET = requireEnv('TRADEON_WEBHOOK_SECRET')
 
const app = express()
 
// Process-local dedup. Replace with Redis SETNX or a DB unique index in prod —
// a Set forgets everything on restart, so a retry across a deploy gets re-handled.
const seenEventIds = new Set()
 
app.post(
  '/webhooks/tradeon',
  // express.raw is critical: signing the parsed-then-restringified body would
  // fail because of whitespace / key-ordering differences. We need the exact bytes.
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-Signature') ?? ''
    const timestamp = req.header('X-Timestamp') ?? ''
    const eventId   = req.header('X-Event-Id') ?? ''
 
    if (!signature || !timestamp || !eventId) {
      return res.status(400).send('missing signature headers')
    }
 
    // Reject anything skewed by more than ±5 minutes — see /webhooks/signature.
    const tsNum = Number(timestamp)
    if (!Number.isFinite(tsNum) || Math.abs(Date.now() / 1000 - tsNum) > 300) {
      return res.status(400).send('stale timestamp')
    }
 
    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${timestamp}.`)
      .update(req.body) // Buffer, not a string
      .digest('hex')
 
    const a = Buffer.from(expected, 'hex')
    const b = Buffer.from(signature, 'hex')
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(400).send('bad signature')
    }
 
    // Dedup: retries reuse X-Event-Id. Ack 2xx on dup — 4xx would mark the
    // event permanently failed.
    if (seenEventIds.has(eventId)) {
      return res.status(200).end()
    }
    seenEventIds.add(eventId)
 
    const event = JSON.parse(req.body.toString('utf8'))
    console.log(`[webhook] ${event.event} eid=${eventId} data=${JSON.stringify(event.data)}`)
 
    // TODO: dispatch by event.event — see /webhooks/events for the catalogue.
    // Forward-compat: ignore unknown event types (return 2xx); the API may add new ones.
 
    res.status(200).end()
  },
)
 
function requireEnv(name) {
  const v = process.env[name]
  if (!v) throw new Error(`${name} is not set`)
  return v
}
 
app.listen(PORT, () => {
  console.log(`tradeon webhook listener on http://localhost:${PORT}/webhooks/tradeon`)
})
node webhook-server.mjs

Pointing the webhook at your local machine

The TradeOn API can only POST to a public URL. Two common ways to expose localhost:8080 during development:

# ngrok
ngrok http 8080
 
# Cloudflare Tunnel — free, no account needed for ad-hoc tunnels
cloudflared tunnel --url http://localhost:8080

Both print an https://… URL. Register it as the webhook target in the merchant cabinet (Settings → Webhook). Trigger a test event by buying a low-priced item with buy.mjs; you should see purchase.created arrive within a second or two on the receiver.

If a request fails (any non-2xx, including signature mismatches you might hit while iterating on the verifier), the API retries on the 1m → 10m → 1h schedule. Bring the listener back up and the backlog drains automatically — you do not need to ask for a manual replay.