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
fetchis available - Your key in an environment variable — never hard-coded, never in a browser bundle
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' });
}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;
}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.