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
EOFLoad the file before running each script:
set -a; . ./.env; set +aKeep 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
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.mjsConflict-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.
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.mjsPointing 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:8080Both 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.