authnet_core

authnet_dart: typed payments for Dart and Flutter

pub package pub points CI License: MIT Dart SDK

The pure-Dart engine of the authnet_dart SDK: config, typed models, request building, response parsing, and a typed exception hierarchy for Authorize.Net's Transaction/JSON API. No Flutter dependency; runs on server, Flutter, and web.

Not affiliated with, endorsed by, or certified by Authorize.Net or Visa.

Why use it

  • The broad Authorize.Net surface most applications need (payments, eCheck, PayPal, CIM, ARB, reporting, Account Updater, and hosted/tokenized flows) in one null-safe API.
  • Typed requests, results, filters, sorting, and pagination instead of raw JSON maps.
  • Payment-safe networking: ambiguous mutations are not retried, secrets are masked, and malformed responses fail closed.
  • 160/160 pub points, 400 automated tests, and 99%+ production-line coverage.

Install

dart pub add authnet_core

Or add it manually:

dependencies:
  authnet_core: ^1.0.2

Quick start: charging a card

import 'package:authnet_core/authnet_core.dart';

final client = AuthNetClient(
  config: AuthNetConfig(
    apiLoginId: 'your-api-login-id',
    transactionKey: 'your-transaction-key',
    environment: AuthNetEnvironment.sandbox, // switch to .production when ready
  ),
);

final result = await client.charge(
  PaymentRequest(
    amount: 19.99,
    method: PaymentMethod.creditCard,
    billing: BillingDetails(firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com'),
    card: CardDetails(number: '4111111111111111', expMonth: '12', expYear: '2030', cvv: '900'),
  ),
);

if (result.isApproved) {
  print('Approved: ${result.transactionId}');
} else {
  // pendingCustomerAction / declined / heldForReview / error / failed:
  // result.message is always safe to show; charge() never throws.
  print('${result.status}: ${result.message}');
}

charge() never throws: every outcome, including a network failure, comes back as a TransactionResult you can log or display directly.

eCheck (bank account)

final result = await client.charge(
  PaymentRequest(
    amount: 50.00,
    method: PaymentMethod.bankAccount,
    billing: BillingDetails(firstName: 'Jane', lastName: 'Doe'),
    bank: BankDetails(
      accountType: BankAccountType.checking,
      routingNumber: '071000013',
      accountNumber: '123456789',
      nameOnAccount: 'Jane Doe',
    ),
  ),
);

if (result.isPendingSettlement) {
  // eCheck "approved" means submitted, not settled: funds move over
  // 3-5 business days and can still be returned. Don't treat this as final.
}

Accept.js / Accept Hosted nonce

If you're tokenizing card data on the client (recommended for any app that touches user devices, see the root README's security model), charge the resulting nonce the same way:

final result = await client.charge(
  PaymentRequest(
    amount: 19.99,
    method: PaymentMethod.opaqueData,
    billing: BillingDetails(firstName: 'Jane', lastName: 'Doe'),
    opaqueData: OpaqueData(dataDescriptor: descriptor, dataValue: nonce),
  ),
);

PayPal redirect payments

Start a PayPal authorization or sale by using PaymentMethod.payPal. An accepted initial response has status: pendingCustomerAction and exposes the PayPal URL through secureAcceptanceUrl. After PayPal returns a payer id, finish with continuePayPal(); getPayPalDetails() can retrieve the payer details for an existing transaction.

final pending = await client.charge(PaymentRequest(
  amount: 19.99,
  method: PaymentMethod.payPal,
  payPal: const PayPalDetails(
    successUrl: 'https://example.com/paypal/success',
    cancelUrl: 'https://example.com/paypal/cancel',
  ),
));

// Open pending.secureAcceptanceUrl for the customer, then use the payer id
// returned by PayPal/Authorize.Net:
final completed = await client.continuePayPal(
  transactionId: pending.transactionId!,
  payerId: payerId,
);

The callback values must be absolute HTTPS URLs. They also need to route back to a page or app-link handler your customer can actually reach; passing syntax validation alone does not make a localhost or private-network URL usable.

Saved payment methods (CIM)

// Save the card just used, to charge again later without re-collecting it.
final saved = await client.saveMethodFromTransaction(transactionId: result.transactionId!);

// List a customer's saved cards/bank accounts.
final cards = await client.getSavedMethods(saved.customerProfileId!);

// Charge a saved method without touching raw card data again.
final again = await client.chargeSavedMethod(
  customerProfileId: saved.customerProfileId!,
  paymentProfileId: saved.customerPaymentProfileId!,
  amount: 19.99,
);

Unlike charge(), the CIM methods (getSavedMethods, getProfileByMerchantId, saveMethodFromTransaction) throw a typed AuthNetException on failure (AuthNetNetworkException, AuthNetApiException, AuthNetParseException, AuthNetConfigException): a lookup that can't complete needs to be distinguishable from a lookup that legitimately found nothing. The one exception: a profile that simply doesn't exist yet returns an empty result rather than throwing, since that's a normal, expected state.

Authorize now, capture later

final auth = await client.authOnly(
  PaymentRequest(
    amount: 19.99,
    method: PaymentMethod.creditCard,
    billing: BillingDetails(firstName: 'Jane', lastName: 'Doe'),
    card: CardDetails(number: '4111111111111111', expMonth: '12', expYear: '2030', cvv: '900'),
  ),
);

// ...later, capture the full amount (or pass a smaller `amount` to
// partially capture):
final captured = await client.priorAuthCapture(transactionId: auth.transactionId!);

Capture with an external auth code

final result = await client.captureOnly(
  CaptureOnlyRequest(
    amount: 19.99,
    authCode: 'AUTH123', // obtained outside Authorize.Net, e.g. a terminal
    card: CardDetails(number: '4111111111111111', expMonth: '12', expYear: '2030', cvv: '900'),
  ),
);

Refund or void

// A SETTLED transaction: refund it. An UNSETTLED one: void it instead;
// refunding an unsettled transaction fails (Authorize.Net error code 54).
final refunded = await client.refund(
  RefundRequest(
    transactionId: originalTransactionId,
    amount: 19.99,
    card: RefundCardInfo(cardNumber: '1111'), // last 4 digits are enough
  ),
);

final voided = await client.voidTransaction(originalTransactionId);

authOnly, priorAuthCapture, captureOnly, refund, and voidTransaction all follow charge()'s never-throws contract: every outcome, including a validation failure like a missing transaction id, comes back as a TransactionResult.

Full customer profile management (CIM)

Beyond the saved-payment-methods calls above, authnet_core covers the full Customer Information Manager surface (profiles, payment profiles, and shipping addresses) for building an account/wallet management UI:

// Customer profiles
final profileId = await client.createCustomerProfile(
  CreateCustomerProfileRequest(merchantCustomerId: 'user-42', email: 'jane@example.com'),
);
final allIds = await client.getCustomerProfileIds();
await client.updateCustomerProfile(customerProfileId: profileId, description: 'VIP');
await client.deleteCustomerProfile(profileId);

// Payment profiles: card, bank, or an Accept.js/Hosted nonce
final paymentProfileId = await client.createCustomerPaymentProfile(
  CreateCustomerPaymentProfileRequest(
    customerProfileId: profileId,
    opaqueData: OpaqueData(dataDescriptor: descriptor, dataValue: nonce),
  ),
);
final method = await client.getCustomerPaymentProfile(
  customerProfileId: profileId,
  customerPaymentProfileId: paymentProfileId,
);
await client.validateCustomerPaymentProfile(
  customerProfileId: profileId,
  customerPaymentProfileId: paymentProfileId,
);

// Shipping addresses
final addressId = await client.createCustomerShippingAddress(
  customerProfileId: profileId,
  address: ShippingAddress(firstName: 'Jane', lastName: 'Doe', city: 'Springfield'),
);

Like the other CIM calls, every method here throws a typed AuthNetException on failure rather than returning a result object; see "Saved payment methods (CIM)" above for why.

getCustomerPaymentProfileNonce() needs a connectedAccessToken, an OAuth-style token from Authorize.Net's Partner API, not your apiLoginId/transactionKey. If you're a normal merchant calling this on your own account, you very likely don't have one; see its dartdoc.

Transaction reporting

final detail = await client.getTransactionDetails(transactionId);
if (detail.isSettled) {
  // funds have actually cleared: see the eCheck note above for why an
  // "approved" charge() result alone isn't enough to know this.
}

final inBatch = await client.getTransactionList(batchId);
final forCustomer = await client.getTransactionListForCustomer(customerProfileId: profileId);
final unsettled = await client.getUnsettledTransactionList(
  orderBy: TransactionListOrderBy.submitTimeUtc,
  paging: const AuthNetPaging(limit: 100, offset: 1),
);
final held = await client.getHeldTransactionList();

transactionStatus on both types is Authorize.Net's raw status string ("settledSuccessfully", "capturedPendingSettlement", "FDSPendingReview", ...), not an enum, since this SDK isn't confident it knows every possible value. isSettled/isHeldForReview check the specific values it is confident about.

Batch and merchant reporting

final batches = await client.getSettledBatchList(
  firstSettlementDate: DateTime.utc(2026, 8, 1),
  lastSettlementDate: DateTime.utc(2026, 8, 20), // range capped at 31 days
);

final stats = await client.getBatchStatistics(batches.first.batchId);
for (final s in stats.statistics) {
  print('${s.accountType}: ${s.chargeCount} charges, ${s.chargeAmount}');
}

final merchant = await client.getMerchantDetails();
// merchant.publicClientKey is what Accept.js/Accept Hosted need client-side.

// Proactive card-update campaigns:
final expiringSoon = await client.findPaymentProfilesExpiringInMonth('2026-09');

Recurring billing and Account Updater

createSubscription() accepts raw card/bank details. Prefer createSubscriptionFromProfile() once a customer already has a saved CIM payment profile. getSubscriptionList() supports every documented ARB search type plus typed sorting/paging, and getSubscription() includes schedule, profile, masked payment, order, and billing-attempt detail.

Account Updater merchants can use getAccountUpdaterJobSummary() and getAccountUpdaterJobDetails(). Detail results distinguish updates from deletions and expose the documented old/new/deleted masked card records.

findPaymentProfilesExpiringInMonth() covers getCustomerPaymentProfileListRequest completely: Authorize.Net's schema defines only one searchType for this operation, cardsExpiringInMonth. There's no broader cross-customer search this SDK is missing.

Network reliability and cleanup

final client = AuthNetClient(
  config: config,
  retryPolicy: const RetryPolicy(maxAttempts: 3), // read-only calls only
);

// ...when you're done with this client (e.g. app shutdown, request scope ending):
client.close();

AuthNetClient applies retry policy only to semantically read-only lookups. Charges, captures, refunds, voids, profile/subscription mutations, and token creation are never retried automatically. package:http cannot prove from a ClientException whether request bytes were sent, and Authorize.Net has no idempotency key, so retrying an ambiguous mutation could duplicate a charge or account change. Timeouts and non-200 responses are never retried either. Pass RetryPolicy.none to disable even read-only retries.

close() releases the underlying HTTP connection pool: call it when you're done with a client, the same way you'd close any http.Client. If you passed your own httpClient to the constructor, close() leaves it open, since you're responsible for a client you provided (you might be reusing it elsewhere).

Logging

Pass onLog to see masked request/response bodies:

final client = AuthNetClient(config: config, onLog: print);

Every logged request/response is passed through maskForLog(). It redacts the API Login ID, Transaction Key, partner access token, cardCode, bank routingNumber/accountNumber, and opaque dataValue; cardNumber keeps only its last four digits. Other business/customer fields can remain, so these logs still need your normal access controls and retention policy.

Error model

TransactionStatus Meaning
approved responseCode 1 with a real transaction id
pendingCustomerAction PayPal responseCode 5 with a customer redirect URL
declined responseCode 2
heldForReview responseCode 4: fraud filter held it
error responseCode 3, a request-level API error, or Test Mode (transId "0")
failed No usable response: config/parse failure, or a network failure whose server-side outcome may be unknown; verify state before retrying

See the full API reference on pub.flutter-io.cn (dartdoc on every public member) and example/ for a runnable end-to-end example.

Performance

This is a network client: Authorize.Net's own round-trip (typically 200–800ms) dominates real-world latency, not anything in this package. Measured with benchmark/response_parsing_benchmark.dart (run it yourself with dart run benchmark/response_parsing_benchmark.dart):

Operation Time
Parse a typical transaction-detail response ~23 µs
Parse a 2-item transaction list ~3 µs
Parse a synthetic 1,000-item transaction list ~1.6 ms
maskForLog() a typical response body ~6.5 µs
RetryPolicy.delayFor() (backoff computation) ~0.1 µs

Parsing scales linearly with response size; even a 1,000-row report parses in under 2ms, a rounding error next to the network call that fetched it.

Security

  • Keep transactionKey server-side. See the root README for the three usage modes and their tradeoffs, and SECURITY.md for the full security model and PCI-DSS scope notes.

License

MIT: see LICENSE. This library does not certify PCI-DSS compliance.

Libraries

authnet_core
A pure-Dart client for the Authorize.Net Transaction/JSON API: config, typed models, request building, response parsing, and a typed exception hierarchy. Runs on server, Flutter, and web.