Node.js examples

A small typed-ish client covering authentication, the record endpoints, retries and signature verification.

Before you start

  • Node 18 or newer, so global fetch is available
  • Your key in an environment variable — never hard-coded, never in a browser bundle
Client

A minimal wrapper

One place that handles the envelope, errors and retries, so callers stay simple.

const BASE = '"""+BASE+"""';

class SimcoaiError extends Error {
  constructor(status, body) {
    super(body?.message || `SIMCOAI request failed (${status})`);
    this.status = status;
    this.code = body?.details?.code || null;
    this.requestId = body?.requestId || null;
  }
}

async function call(path, { method = 'GET', body, idempotencyKey } = {}) {
  const headers = { 'X-SIMCOAI-API-Key': process.env.SIMCOAI_API_KEY };
  if (body) headers['Content-Type'] = 'application/json';
  if (idempotencyKey) headers['X-SIMCOAI-Idempotency-Key'] = idempotencyKey;

  for (let attempt = 0; attempt < 4; attempt++) {
    const res = await fetch(BASE + path, {
      method, headers, body: body ? JSON.stringify(body) : undefined
    });
    const json = await res.json().catch(() => null);

    // Only 429 and 5xx are worth retrying; every other 4xx will fail the same way.
    if (res.status === 429 || res.status >= 500) {
      await new Promise(r => setTimeout(r, (2 ** attempt) * 500 + Math.random() * 300));
      continue;
    }
    if (!res.ok || !json?.success) throw new SimcoaiError(res.status, json);
    return json.data;
  }
  throw new SimcoaiError(429, { message: 'Retries exhausted' });
}
Usage

Creating and reading records

// Confirm the key and its scopes
const me = await call('/me');

// Upsert an order. Re-running this with the same order_number updates it.
await call('/orders', {
  method: 'POST',
  body: { order_number: 'ORD-2048', status: 'processing', total_amount: '99.00', currency: 'GBP' }
});

// Create a refund safely. A retry with the same key will not create a second one.
try {
  await call('/refunds', {
    method: 'POST',
    idempotencyKey: 'refund-ORD-2048',
    body: { order_number: 'ORD-2048', reason: 'Damaged on arrival' }
  });
} catch (err) {
  // The work already exists — that is a success for our purposes.
  if (err.code !== 'WORKFLOW_DUPLICATE') throw err;
}
Webhooks

Verifying an inbound signature

Compare in constant time. A plain === on signatures is a timing oracle.

const crypto = require('crypto');

function verify(rawBody, headerSignature, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(String(headerSignature || ''), 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: capture the RAW body, not the parsed object — re-serialising changes the bytes.
app.post('/simcoai/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    if (!verify(req.body, req.get('X-SIMCOAI-Signature'), process.env.SIMCOAI_WEBHOOK_SECRET)) {
      return res.sendStatus(401);
    }
    res.sendStatus(200);              // acknowledge first
    queue.add(JSON.parse(req.body));  // process after
  });
Careful. Signature verification must run against the raw request body. If your framework parses JSON first and you re-serialise it, key order and spacing change and every signature will fail.