Search docs

Jump between documentation pages.

Accept cards with Authorize.Net in DaloyJS

Authorize.Net (a Visa / Cybersource brand) is one of the original US card gateways. This guide uses the official authorizenet Node SDK to charge cards via the Authorize.Net API, and the separate JSON Webhooks REST API to receive event notifications.

What you should know up front

  • It's a thin XML wrapper. Requests are built with ApiContracts.* classes and sent with ApiControllers.* controllers. There's no fluent client and no Promises by default — every controller exposes .execute(callback). Wrap it with util.promisify for a sane async API.
  • Don't POST raw card numbers from a browser. Use Accept.js or Accept Hosted on the client to tokenise the card into an opaqueData nonce. Sending raw PANs through your server puts you in full PCI-DSS SAQ-D scope.
  • Webhooks are a separate API. Subscribe and verify against https://api.authorize.net/rest/v1/webhooks (or the apitest host) — the XML transactions SDK doesn't handle them. Signatures use HMAC-SHA512 with a dedicated Signature Key, not the Transaction Key.
  • Node 14+ & TLS 1.2. Anything older is rejected at the connection layer.

1. Provision

  1. Create a sandbox account and copy your API Login ID and Transaction Key from Account → Settings → Security Settings → API Credentials and Keys.
  2. On the same screen, generate a Signature Key. You'll need this to verify webhook signatures.
  3. When you're ready for production, repeat with a live account and swap endpoint.sandbox for endpoint.production.

2. Install

ts
pnpm add authorizenet

The package ships its own TypeScript declarations, but they're hand-written and not exhaustive — expect to as any in a few spots when you reach for newer fields.

3. Environment variables

ts
# .env
AUTHNET_ENVIRONMENT=sandbox          # or "production"
AUTHNET_API_LOGIN_ID=use_your_api_login_id
AUTHNET_TRANSACTION_KEY=use_your_transaction_key
AUTHNET_SIGNATURE_KEY=use_your_signature_key   # hex string from the merchant interface

4. Plugin

ts
// src/plugins/authorizenet.ts
import { promisify } from "node:util";
import { createHmac, timingSafeEqual } from "node:crypto";
import {
  APIContracts as ApiContracts,
  APIControllers as ApiControllers,
  Constants as SDKConstants,
} from "authorizenet";
import type { App } from "@daloyjs/core";

const endpoint =
  process.env.AUTHNET_ENVIRONMENT === "production"
    ? SDKConstants.endpoint.production
    : SDKConstants.endpoint.sandbox;

function merchantAuth() {
  const auth = new ApiContracts.MerchantAuthenticationType();
  auth.setName(process.env.AUTHNET_API_LOGIN_ID!);
  auth.setTransactionKey(process.env.AUTHNET_TRANSACTION_KEY!);
  return auth;
}

// Centralise the callback-to-promise wrapping in one place.
function runController<TController extends { execute: (cb: () => void) => void; getResponse: () => unknown }>(
  controller: TController,
): Promise<unknown> {
  controller.setEnvironment(endpoint);
  return new Promise((resolve) => {
    controller.execute(() => resolve(controller.getResponse()));
  });
}

export interface AuthorizeNetClient {
  chargeOpaqueData(input: {
    amount: string;                 // "10.00" — string, two decimals.
    dataDescriptor: string;         // e.g. "COMMON.ACCEPT.INAPP.PAYMENT"
    dataValue: string;              // Accept.js nonce.
    invoiceNumber?: string;
    customerEmail?: string;
    customerIp?: string;
  }): Promise<{ transId: string; authCode: string; accountNumber: string }>;
  verifyWebhook(headers: Headers, rawBody: Buffer): boolean;
}

function buildSaleRequest(input: Parameters<AuthorizeNetClient["chargeOpaqueData"]>[0]) {
  const opaque = new ApiContracts.OpaqueDataType();
  opaque.setDataDescriptor(input.dataDescriptor);
  opaque.setDataValue(input.dataValue);

  const payment = new ApiContracts.PaymentType();
  payment.setOpaqueData(opaque);

  const order = new ApiContracts.OrderType();
  if (input.invoiceNumber) order.setInvoiceNumber(input.invoiceNumber);

  const tx = new ApiContracts.TransactionRequestType();
  tx.setTransactionType(ApiContracts.TransactionTypeEnum.AUTHCAPTURETRANSACTION);
  tx.setPayment(payment);
  tx.setAmount(input.amount);
  tx.setOrder(order);
  if (input.customerIp) tx.setCustomerIP(input.customerIp);
  if (input.customerEmail) {
    const customer = new ApiContracts.CustomerDataType();
    customer.setEmail(input.customerEmail);
    tx.setCustomer(customer);
  }

  const req = new ApiContracts.CreateTransactionRequest();
  req.setMerchantAuthentication(merchantAuth());
  req.setTransactionRequest(tx);
  return req;
}

function verifyAuthorizeNetSignature(rawBody: Buffer, headerValue: string | null) {
  if (!headerValue) return false;
  // The header looks like "sha512=ABCDEF..." — strip the prefix if present.
  const provided = headerValue.startsWith("sha512=")
    ? headerValue.slice("sha512=".length)
    : headerValue;
  const computed = createHmac("sha512", process.env.AUTHNET_SIGNATURE_KEY!)
    .update(rawBody)
    .digest("hex")
    .toUpperCase();
  const a = Buffer.from(provided.toUpperCase(), "utf8");
  const b = Buffer.from(computed, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

export const authorizeNetPlugin = {
  name: "authorizenet",
  register(app: App) {
    const client: AuthorizeNetClient = {
      async chargeOpaqueData(input) {
        const req = buildSaleRequest(input);
        const controller = new ApiControllers.CreateTransactionController(req.getJSON());
        const raw = (await runController(controller)) as Record<string, unknown>;
        const response = new ApiContracts.CreateTransactionResponse(raw);

        const messages = response.getMessages();
        if (messages?.getResultCode() !== ApiContracts.MessageTypeEnum.OK) {
          const first = messages?.getMessage()?.[0];
          throw Object.assign(new Error(first?.getText() ?? "Authorize.Net request failed"), {
            code: first?.getCode(),
          });
        }

        const txResp = response.getTransactionResponse();
        // responseCode "1" = approved. Anything else (2 declined, 3 error, 4 held) is a failure.
        if (!txResp || txResp.getResponseCode() !== "1") {
          const err = txResp?.getErrors()?.getError()?.[0];
          throw Object.assign(new Error(err?.getErrorText() ?? "Transaction not approved"), {
            code: err?.getErrorCode() ?? "DECLINED",
            transId: txResp?.getTransId(),
          });
        }

        return {
          transId: txResp.getTransId(),
          authCode: txResp.getAuthCode(),
          accountNumber: txResp.getAccountNumber(),
        };
      },
      verifyWebhook(headers, rawBody) {
        return verifyAuthorizeNetSignature(rawBody, headers.get("x-anet-signature"));
      },
    };
    app.decorate("authnet", client);
  },
};

declare module "@daloyjs/core" {
  interface AppState {
    authnet: AuthorizeNetClient;
  }
}

Why req.getJSON()? The SDK builds an XML envelope internally, but the controllers want a serialised JSON snapshot of the request graph. It's an awkward shape — that's the SDK, not a typo.

5. Charge an Accept.js nonce

Your frontend obtains an opaqueData nonce with Accept.js (dataDescriptor: "COMMON.ACCEPT.INAPP.PAYMENT" for browser-side Accept, or COMMON.APPLE.INAPP.PAYMENT / COMMON.GOOGLE.INAPP.PAYMENT for wallets). Your server only ever sees the nonce, never the card number.

ts
import { z } from "zod";
import { App, secureHeaders, rateLimit } from "@daloyjs/core";
import { authorizeNetPlugin } from "./plugins/authorizenet";

const app = new App();
app.use(secureHeaders());
app.use(rateLimit({ windowMs: 60_000, max: 30 }));
app.register(authorizeNetPlugin);

app.route({
  method: "POST",
  path: "/checkout",
  operationId: "checkout",
  request: {
    body: z.object({
      amount: z.string().regex(/^\d+\.\d{2}$/),
      dataDescriptor: z.string().min(1),
      dataValue: z.string().min(1),
      invoiceNumber: z.string().max(20).optional(),
      email: z.string().email().optional(),
    }),
  },
  responses: {
    201: {
      description: "approved",
      body: z.object({
        transId: z.string(),
        authCode: z.string(),
        last4: z.string(),
      }),
    },
  },
  handler: async ({ body, request, state }) => {
    const result = await state.authnet.chargeOpaqueData({
      amount: body.amount,
      dataDescriptor: body.dataDescriptor,
      dataValue: body.dataValue,
      invoiceNumber: body.invoiceNumber,
      customerEmail: body.email,
      customerIp: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
    });
    return {
      status: 201,
      body: {
        transId: result.transId,
        authCode: result.authCode,
        last4: result.accountNumber.replace(/^X+/, ""),
      },
    };
  },
});

Refunds, voids, and prior-auth captures all go through the same CreateTransactionController with a different transactionType (refundTransaction, voidTransaction, priorAuthCaptureTransaction) plus a refTransId. Reuse runController from the plugin and follow the same response shape.

6. Subscribe to webhooks

Webhooks aren't in the XML SDK. Use the REST API with HTTP Basic auth (API Login ID + Transaction Key) — once per environment, usually as a script or admin endpoint, not on every boot:

ts
const host =
  process.env.AUTHNET_ENVIRONMENT === "production"
    ? "https://api.authorize.net"
    : "https://apitest.authorize.net";

const basic = Buffer.from(
  `${process.env.AUTHNET_API_LOGIN_ID}:${process.env.AUTHNET_TRANSACTION_KEY}`,
).toString("base64");

await fetch(`${host}/rest/v1/webhooks`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Basic ${basic}`,
  },
  body: JSON.stringify({
    name: "Acme webhooks",
    url: "https://api.acme.com/webhooks/authorizenet",
    eventTypes: [
      "net.authorize.payment.authcapture.created",
      "net.authorize.payment.refund.created",
      "net.authorize.payment.void.created",
      "net.authorize.customer.subscription.expiring",
    ],
    status: "active",
  }),
});

7. Verify webhook deliveries

Authorize.Net signs each notification with HMAC-SHA512 over the raw body using the Signature Key, and sends the hex digest in the X-ANET-Signature header (often prefixed with sha512=). Hash bytes before JSON.parse:

ts
import { z } from "zod";
import { readRawBody } from "@daloyjs/core/raw";

app.route({
  method: "POST",
  path: "/webhooks/authorizenet",
  operationId: "authorizenetWebhook",
  responses: {
    200: { description: "ack", body: z.object({ ok: z.literal(true) }) },
    401: { description: "bad signature", body: z.object({ error: z.string() }) },
  },
  handler: async ({ request, state }) => {
    const raw = await readRawBody(request);
    if (!state.authnet.verifyWebhook(request.headers, raw)) {
      return { status: 401, body: { error: "invalid signature" } };
    }

    const event = JSON.parse(raw.toString("utf8")) as {
      notificationId: string;
      eventType: string;
      eventDate: string;
      webhookId: string;
      payload: { entityName: "transaction" | "customerProfile" | "customerPaymentProfile" | "subscription"; id: string };
    };

    if (await seen(event.notificationId)) {
      return { status: 200, body: { ok: true as const } };
    }

    // Webhook payloads are intentionally minimal. For the full record, call
    // getTransactionDetailsRequest / getCustomerProfileRequest / ARBGetSubscriptionRequest.
    await enqueueAuthnetEvent(event);
    return { status: 200, body: { ok: true as const } };
  },
});

Ack fast. Authorize.Net retries failed deliveries 10 times — 3× at 3-minute intervals, 3× at 8-hour intervals, 4× at 48-hour intervals — and then marks the webhook inactive. Do the heavy work in a queue.

Errors & result objects

There are two layers of failure. The outer messagesblock reports request-level errors (auth, schema, throttling). When that's OK, the inner transactionResponse still has a responseCode of 2 (decline), 3 (error), or 4 (held for review). The plugin above collapses both into thrown errors; route them through problem+json.

Runtimes

The SDK uses axios over Node's https, requires Node 14+ and TLS 1.2, and isn't designed for Cloudflare Workers or Vercel Edge. On an edge runtime, POST JSON straight to https://api.authorize.net/xml/v1/request.api with fetch instead — the gateway accepts the same JSON envelope that the SDK builds.

Modernisation notes

  • Use transHashSha2, not transHash. The MD5-based transHash field is being phased out. Compare against transHashSha2 if you echo a hash back for receipt-style verification.
  • Skip the shopify-style auto-retry config. The SDK has no built-in retry; if you need it, wrap runController with your own back-off on transient network errors only — never on declines.
  • Prefer Customer Profiles for repeat business. Vault the card into a customer payment profile on first charge, then bill subsequent transactions by profile.customerProfileId / paymentProfileId so you never touch the nonce twice.

See also the payments overview, Braintree guide, and problem+json errors.