# Node.js Examples

Complete Node.js/JavaScript integration examples.

> **Note:** AuxCORE does not publish a Node.js SDK package. These examples call the REST API directly with `fetch` (Node.js 18+). In the browser, collect cards with the PCI SDK (`https://vault.auxcore.net/sdk/auxVault.js`) instead.

---

## Setup

```javascript
const BASE_URL = process.env.AUXCORE_API_BASE || 'https://uat.auxcore.net/api/v1/public';
const API_KEY = process.env.AUXCORE_API_KEY;

async function auxcore(method, path, body) {
  const res = await fetch(`${BASE_URL}${path}`, {
    method,
    headers: {
      'x-api-key': API_KEY,
      'Content-Type': 'application/json',
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  return { status: res.status, body: json };
}
```

Tenant-wide ERP keys (`lv_erp_…`) must also send `X-Merchant-ID: <merchant uuid>`.

---

## Process Payment

Raw card numbers are rejected on Sandbox and Production, so charge a `pci_tok_…` token from the PCI SDK or from `POST /tokenize-card` (server-side key only).

For this sale example, an accepted `pending` payment is awaiting its outcome. Save its transaction ID and wait for `transaction.approved` / `transaction.declined` before marking an order paid or sending a paid receipt. `status: 'success'` means approved, not necessarily settled. Handle `transaction.returned` as well if you accept ACH.

```javascript
async function processPayment({ pciToken, amount, name, email, orderId }) {
  const { status, body } = await auxcore('POST', '/transaction', {
    Amount: amount,
    pciToken,
    BillingCustomerName: name,
    BillingEmail: email,
    OrderId: orderId,
  });

  // 200: approved (status "success") or accepted-pending (status "pending", e.g. ACH)
  // 200 + requires_action: redirect the payer to body.data.RedirectUrl (3DS / hosted page)
  // 402: declined; 400/401/5xx: request or service error
  if (status === 200 && body.requires_action) {
    return {
      success: false,
      status: 'pending',
      requiresAction: true,
      redirectUrl: body.data.RedirectUrl,
      transactionId: body.data.TransactionId,
    };
  }
  if (status === 200 && body.success && body.status === 'pending') {
    console.log('Payment pending:', body.data.TransactionId);
    return { success: true, status: 'pending', transaction: body.data };
  }
  if (status === 200 && body.success && body.status === 'success') {
    console.log('Transaction approved:', body.data.TransactionId, body.data.AuthCode);
    return { success: true, status: 'approved', transaction: body.data };
  }
  console.error('Payment failed:', body.message || body.error);
  return {
    success: false,
    status: body.status || 'error',
    error: body.message || body.error,
    responseCode: body.data?.ResponseCode,
  };
}
```

---

## Process Refund

Omit `amount` for a full refund. `transactionId` accepts the `TransactionId` UUID or the `TXN-…` reference.

A gateway-declined refund can still return HTTP `201` with `success: true`; read the refund row's `data.status`. A full refund of an approved, unsettled TSYS transaction can instead return a `voided` transaction. Inspect `data.gatewayResponse.gatewayVoidSuccess` (or `gatewayVoided` and `warning` on `/void`) before reporting a local void as a confirmed gateway reversal.

```javascript
async function processRefund(transactionId, amount) {
  const { status, body } = await auxcore('POST', '/refund', { transactionId, amount });
  const outcome = body.data?.status;
  if (status >= 200 && status < 300 && body.success) {
    if (outcome === 'refunded') {
      return { success: true, outcome, refund: body.data };
    }
    if (outcome === 'voided') {
      return {
        success: true,
        outcome,
        transaction: body.data,
        gatewayVoided: body.gatewayVoided ?? (body.data?.gatewayResponse?.gatewayVoidSuccess === true),
        warning: body.warning,
      };
    }
  }
  return {
    success: false,
    outcome: outcome || 'unknown',
    refund: body.data,
    error: body.error?.message || body.message || body.data?.responseText || 'Refund not confirmed',
  };
}
```

Do not automatically resubmit an unknown or declined refund. Check the transaction and delivery history; declined refunds can already be queued for a server-side retry. Subscribe to `transaction.refunded` and `transaction.refund_failed`.

---

## Create Subscription

Recurring plans are created with `POST /recurring` (forwarded to the recurring service; `merchantId` comes from the key).

```javascript
async function createSubscription({ customerId, name, email, pciToken }) {
  const { status, body } = await auxcore('POST', '/recurring', {
    customerId,
    recurringUserName: name,
    customerEmail: email,
    recurringAmount: 99.00,
    period: 'Monthly',
    recurringDate1: '2026-11-01',
    maximumOccurrence: 'NoExpire',
    suggestedMode: 'CARD',
    children: [{ pciToken, isDefault: true }],
  });
  if (status === 201) {
    console.log('Subscription created:', body.data.id);
    return { success: true, subscription: body.data };
  }
  return { success: false, error: body.error?.message };
}
```

---

## Webhook Handler (Express)

Webhooks are signed with the endpoint secret returned when you created it. The `X-Webhook-Signature` header is `t=<unix>,v1=<hex>`, where `v1` is HMAC-SHA256 of `"<t>.<raw body>"`. Verify against the **raw** body.

Copy `verifySignature()` below into a shared server helper and reuse it in your integration. Missing or malformed headers return `false`. Register the raw-body route before `express.json()`.

`webhookInbox` is an adapter you implement, not an AuxCORE SDK. `enqueueOnce(eventId, event)` must atomically save the event and durable work using a unique event ID, succeed without enqueueing again for duplicates, and reject on storage failure. A worker calls `handleQueuedEvent()` for stored events and uses idempotent business updates and receipts so worker retries are safe.

```javascript
const express = require('express');
const crypto = require('crypto');
const app = express();

const WEBHOOK_SECRET = process.env.AUXCORE_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) throw new Error('AUXCORE_WEBHOOK_SECRET is required');

function verifySignature(rawBody, header, secret, toleranceSec = 300) {
  if (typeof rawBody !== 'string' || typeof header !== 'string' ||
      typeof secret !== 'string' || !secret) return false;
  if (!Number.isFinite(toleranceSec) || toleranceSec < 0) return false;

  // Matches the sender's t=<unix>,v1=<64 hex characters> header format.
  const match = /^t=(\d+),v1=([a-fA-F0-9]{64})$/.exec(header);
  if (!match) return false;
  const timestamp = Number(match[1]);
  if (!Number.isSafeInteger(timestamp) || timestamp <= 0 ||
      Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSec) return false;

  const expected = crypto.createHmac('sha256', secret)
    .update(`${match[1]}.${rawBody}`, 'utf8').digest();
  const received = Buffer.from(match[2], 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

app.post('/webhooks/auxcore', express.raw({ type: 'application/json' }), async (req, res, next) => {
  if (!Buffer.isBuffer(req.body)) return res.status(400).send('Expected raw JSON body');
  const rawBody = req.body.toString('utf8');
  if (!verifySignature(rawBody, req.headers['x-webhook-signature'], WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  let event;
  try {
    event = JSON.parse(rawBody); // { id, type, created, data, metadata: { tenantId, merchantId } }
  } catch {
    return res.status(400).send('Invalid JSON');
  }
  if (!event || typeof event.id !== 'string' || !event.id) {
    return res.status(400).send('Missing event ID');
  }

  try {
    await webhookInbox.enqueueOnce(event.id, event);
    return res.sendStatus(200);
  } catch (error) {
    return next(error); // Storage failed: return 5xx so delivery can be retried.
  }
});

// Called by your worker for events saved in the inbox.
async function handleQueuedEvent(event) {
  switch (event.type) {
    case 'transaction.approved':
      await handleTransactionApproved(event.data);
      break;
    case 'transaction.declined':
      await handleTransactionDeclined(event.data);
      break;
    case 'transaction.refunded':
      await handleTransactionRefunded(event.data);
      break;

    case 'transaction.refund_failed':
      await handleRefundFailed(event.data);
      break;

    case 'transaction.returned':
      await handleAchReturn(event.data);
      break;
  }
}

async function handleTransactionApproved(data) {
  console.log('Transaction approved:', data.transactionId, data.amount);
  // Update order status (data.orderId), send receipt, etc.
}

async function handleTransactionDeclined(data) {
  console.log('Transaction declined:', data.transactionId, data.responseText);
}

async function handleTransactionRefunded(data) {
  console.log('Refund recorded:', data.transactionId, 'for', data.originalTransactionId);
}

async function handleRefundFailed(data) {
  console.log('Refund failed:', data.transactionId, data.responseText);
  // Keep the refund unresolved; check its retry queue before submitting another refund.
}

async function handleAchReturn(data) {
  console.log('ACH returned:', data.transactionId, data.returnCode);
  // Reverse your paid-order state and reconcile the returned debit.
}

app.listen(3000, () => {
  console.log('Webhook server running on port 3000');
});
```

---

## Error Handling

Public API errors come in two shapes: API-key failures are `401` with a string `error` (`{ success: false, error: "API key validation failed", message }` for an unknown or deactivated key, `"API key required"` when none is sent), other errors use `{ success: false, error: { code, message } }`. Declines are HTTP `402` with `data.ResponseCode` and a readable `message`.

```javascript
async function processPaymentWithErrorHandling(payment) {
  const { status, body } = await auxcore('POST', '/transaction', payment);

  if (status === 200) {
    return {
      success: body.success,
      status: body.status,
      requiresAction: body.requires_action === true,
      redirectUrl: body.data?.RedirectUrl,
      transaction: body.data,
    };
  }

  switch (status) {
    case 402:
      return { success: false, message: body.message || 'Payment declined. Please try a different card.' };
    case 400:
      return { success: false, message: body.error?.message || body.message || 'Invalid request.' };
    case 401:
    case 403:
      return { success: false, message: 'Check your API key.' };
    case 429:
      return { success: false, retryable: true, message: 'Rate limited — retry later.' };
    default:
      return {
        success: false,
        retryable: false,
        outcomeUnknown: status >= 500,
        message: 'Payment outcome unconfirmed. Check the transaction before submitting again.',
      };
  }
}
```

---

## Retry with Exponential Backoff

The loop below is for read requests, such as the transaction lookup shown here. Retry reads on `429` and `5xx` with backoff. For writes protected by idempotency, reuse the same persisted key and exact payload; see [Use Idempotency Keys](../guides/best-practices.md#use-idempotency-keys).

Do not automatically retry a public `/transaction` after a timeout or `5xx`: this route does not forward `Idempotency-Key`, and `OrderId` does not deduplicate charges. Look up `GET /transaction-list?orderId=…` and wait for the webhook or confirm with the processor if the outcome remains unknown. An empty immediate lookup does not establish that the payment failed. Check the existing result before resubmitting voids or refunds as well.

```javascript
async function retryWithBackoff(fn, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const result = await fn();
    if (!result.retryable || attempt === maxRetries) return result;

    const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
    console.log(`Retry attempt ${attempt} after ${delay}ms`);
    await sleep(delay);
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// Usage
const result = await retryWithBackoff(() =>
  auxcore('GET', '/transaction-list?orderId=ORD-1001').then(({ status, body }) => ({
    retryable: status === 429 || status >= 500,
    ...body,
  }))
);
```

---

**Next:** [PHP examples →](./php.md)
