sondahub / Authorize.net sandbox
An Authorize.net mock API that checks your requests like the real one
Authorize.net’s API, answered by sondahub: XML or JSON checked against the gateway’s schema, element order included, the testing guide’s cards and triggers, batches that settle every minute so refunds and reports work, customer profiles, subscriptions, Accept nonces and a hosted payment form, and webhooks signed so your verification code can be proven.
An independent imitation for testing. Not affiliated with, or endorsed by, Authorize.net, Visa or Cybersource. Nothing is charged.
Connect
- Instead of
- https://apitest.authorize.net/xml/v1/request.api
- Use
- https://api.sondahub.com/xml/v1/request.api
- API Login ID
- any, up to 25 characters — sondahub
- Transaction Key
- any 16 characters — SandboxKey123456
- Signature Key
- B1224AC959AB2E41CF56404EBDC5677F804E9CDD88DA26E9D8DEB2B91E8466F116F66A580DC0120ABB662C672C4C76D51E360B893327D077FD6EFA95F5EA439C
- Webhooks REST
- https://api.sondahub.com/rest/v1 (Basic: login and key)
- Hosted form
- https://api.sondahub.com/sandbox/authorizenet/payment/payment
- OpenAPI 3
- https://api.sondahub.com/sandbox/authorizenet/openapi.json
Import it. In Sonda: Import → From a URL, paste the OpenAPI address. The real API is one endpoint every call is posted to, so the import gives each call — and each kind of transaction — a path of its own after /xml/v1/request.api: sondahub’s labels, since the sandbox reads only the body. Each request comes with a body in schema order, the demo credentials in merchantAuthentication and ids from the seed account, so they work as they are. The webhooks requests take Basic auth: sondahub / SandboxKey123456.
Authorize.net’s SDKs know nothing about sondahub’s sessions, so give them the few lines that point them here and carry X-Sondahub-Session from one answer to the next request — otherwise every call starts from the seed account and the payment you just made is not there to capture.
Python (authorizenet)
from decimal import Decimal
import requests
from authorizenet import apicontractsv1
from authorizenet.apicontrollers import createTransactionController
# send the SDK to the sandbox, and carry its session from each answer to the next request
session = {}
_post = requests.post
def _sandbox(url, data=None, headers=None, **kw):
url = url.replace('https://apitest.authorize.net', 'https://api.sondahub.com')
r = _post(url, data=data, headers={**(headers or {}), **session}, **kw)
if 'X-Sondahub-Session' in r.headers:
session['X-Sondahub-Session'] = r.headers['X-Sondahub-Session']
return r
requests.post = _sandbox
auth = apicontractsv1.merchantAuthenticationType()
auth.name, auth.transactionKey = 'sondahub', 'SandboxKey123456'
card = apicontractsv1.creditCardType()
card.cardNumber, card.expirationDate = '4111111111111111', '2030-12'
payment = apicontractsv1.paymentType()
payment.creditCard = card
tr = apicontractsv1.transactionRequestType()
tr.transactionType, tr.amount, tr.payment = 'authCaptureTransaction', Decimal('12.50'), payment
req = apicontractsv1.createTransactionRequest()
req.merchantAuthentication, req.transactionRequest = auth, tr
ctrl = createTransactionController(req) # the SDK's sandbox endpoint; the hook swaps the host
ctrl.execute()
r = ctrl.getresponse()
print(r.messages.resultCode, r.transactionResponse.transId)
Node (authorizenet)
const { APIContracts: C, APIControllers: K } = require('authorizenet')
// the axios the SDK itself loads
const axios = require(require.resolve('axios', { paths: [require.resolve('authorizenet')] }))
// send the SDK to the sandbox, and carry its session from each answer to the next request
let session = null
axios.interceptors.request.use((cfg) => {
cfg.baseURL = cfg.baseURL.replace('https://apitest.authorize.net', 'https://api.sondahub.com')
if (session) cfg.headers.set('X-Sondahub-Session', session)
return cfg
})
axios.interceptors.response.use((res) => {
session = res.headers['x-sondahub-session'] ?? session
return res
})
const auth = new C.MerchantAuthenticationType()
auth.setName('sondahub')
auth.setTransactionKey('SandboxKey123456')
const card = new C.CreditCardType()
card.setCardNumber('4111111111111111')
card.setExpirationDate('2030-12')
const payment = new C.PaymentType()
payment.setCreditCard(card)
const tr = new C.TransactionRequestType()
tr.setTransactionType(C.TransactionTypeEnum.AUTHCAPTURETRANSACTION)
tr.setAmount('12.50')
tr.setPayment(payment)
const req = new C.CreateTransactionRequest()
req.setMerchantAuthentication(auth)
req.setTransactionRequest(tr)
const ctrl = new K.CreateTransactionController(req.getJSON()) // the SDK's sandbox endpoint; the interceptor swaps the host
ctrl.execute(() => {
const res = new C.CreateTransactionResponse(ctrl.getResponse())
console.log(res.getMessages().getResultCode(), res.getTransactionResponse().getTransId())
})
Try it here
curl -X POST https://api.sondahub.com/xml/v1/request.api \
-H "Content-Type: application/json" \
-d '{
"createTransactionRequest": {
"merchantAuthentication": {"name":"sondahub","transactionKey":"SandboxKey123456"},
"refId": "order-1001",
"transactionRequest": {
"transactionType": "authCaptureTransaction",
"amount": "24.00",
"payment": {"creditCard":{"cardNumber":"4111111111111111","expirationDate":"2030-12","cardCode":"999"}},
"order": {"invoiceNumber":"INV-1001","description":"Sandbox socks"},
"billTo": {"firstName":"Ada","lastName":"Lovelace","address":"14 Main Street","city":"Pecan Springs","state":"TX","zip":"44628","country":"US"}
}
}
}'
curl -X POST https://api.sondahub.com/xml/v1/request.api \
-H "Content-Type: application/json" \
-d '{
"createTransactionRequest": {
"merchantAuthentication": {"name":"sondahub","transactionKey":"SandboxKey123456"},
"transactionRequest": {
"transactionType": "authCaptureTransaction",
"amount": "9.00",
"payment": {"creditCard":{"cardNumber":"4111111111111111","expirationDate":"2030-12","cardCode":"999"}},
"billTo": {"firstName":"Ada","lastName":"Lovelace","address":"14 Main Street","city":"Pecan Springs","state":"TX","zip":"46282","country":"US"}
}
}
}'
curl -X POST https://api.sondahub.com/xml/v1/request.api \
-H "Content-Type: application/json" \
-d '{
"createTransactionRequest": {
"merchantAuthentication": {"name":"sondahub","transactionKey":"SandboxKey123456"},
"transactionRequest": {
"amount": "5.00",
"transactionType": "authCaptureTransaction",
"payment": {"creditCard":{"cardNumber":"4111111111111111","expirationDate":"2030-12","cardCode":"999"}}
}
}
}'
curl -X POST https://api.sondahub.com/xml/v1/request.api \
-H "Content-Type: application/json" \
-d '{
"createTransactionRequest": {
"merchantAuthentication": {"name":"sondahub","transactionKey":"SandboxKey123456"},
"transactionRequest": {
"transactionType": "refundTransaction",
"amount": "5.00",
"payment": {"creditCard":{"cardNumber":"1111","expirationDate":"XXXX"}},
"refTransId": "67433922005"
}
}
}'
curl -X POST https://api.sondahub.com/xml/v1/request.api \
-H "Content-Type: application/json" \
-d '{
"getUnsettledTransactionListRequest": {
"merchantAuthentication": {"name":"sondahub","transactionKey":"SandboxKey123456"},
"sorting": {"orderBy":"submitTimeUTC","orderDescending":true},
"paging": {"limit":"10","offset":"1"}
}
}'
curl -X POST https://api.sondahub.com/xml/v1/request.api \
-H "Content-Type: application/json" \
-d '{
"createCustomerProfileRequest": {
"merchantAuthentication": {"name":"sondahub","transactionKey":"SandboxKey123456"},
"profile": {
"merchantCustomerId": "CUST-5001",
"description": "Grace Hopper",
"email": "[email protected]",
"paymentProfiles": [{"customerType":"individual","billTo":{"firstName":"Ada","lastName":"Lovelace","address":"14 Main Street","city":"Pecan Springs","state":"TX","zip":"44628","country":"US"},"payment":{"creditCard":{"cardNumber":"370000000000002","expirationDate":"2031-09"}}}]
},
"validationMode": "liveMode"
}
}'
curl -X POST https://api.sondahub.com/xml/v1/request.api \
-H "Content-Type: application/json" \
-d '{
"getHostedPaymentPageRequest": {
"merchantAuthentication": {"name":"sondahub","transactionKey":"SandboxKey123456"},
"transactionRequest": {
"transactionType": "authCaptureTransaction",
"amount": "20.00",
"order": {"invoiceNumber":"INV-2001","description":"Workshop seat"}
},
"hostedPaymentSettings": {
"setting": [{"settingName":"hostedPaymentReturnOptions","settingValue":"{\"showReceipt\": true, \"url\": \"https://example.com/receipt\", \"urlText\": \"Back to the shop\"}"}]
}
}
}'
From the shell, keep the token yourself: curl -i shows X-Sondahub-Session; send it back with -H "X-Sondahub-Session: …". XML works the same way, with Content-Type: application/xml and the AnetApi/xml/v1/schema/AnetApiSchema.xsd namespace. How sessions work.
Test cards and the values that trigger results
The testing guide’s cards work with any future expiration date; any other number that passes the Luhn check works too, if its brand is one Authorize.net takes. A number that fails it is reason 6, a bad date reason 7, a past one reason 8.
| Card | Brand |
|---|---|
| 370000000000002 | American Express |
| 6011000000000012 | Discover |
| 3088000000000017 | JCB |
| 38000000000006 | Diners Club / Carte Blanche |
| 4007000000027 | Visa |
| 4012888818888 | Visa |
| 4111111111111111 | Visa |
| 5424000000000015 | Mastercard |
| 2223000010309703 | Mastercard (2-series) |
| 2223000010309711 | Mastercard (2-series) |
| 6221499053360818 | China UnionPay (processed as Discover) |
| 6262320002000067 | China UnionPay (processed as Discover) |
| 6284480000000008 | China UnionPay (processed as Discover) |
Billing ZIP (billTo.zip)
| ZIP | What happens |
|---|---|
| 46282 | Declines: responseCode 2, reason 2. |
| 46201 | A The street address matched, but the ZIP code did not. |
| 46203 | E The AVS data provided is invalid or AVS is not allowed for the card type that was used. Declines (reason 27). |
| 46204 | G The card was issued by a bank outside the U.S. and does not support AVS. |
| 46205 | N Neither the street address nor ZIP code matched. Declines (reason 27). |
| 46207 | R AVS was unavailable at the time the transaction was processed. Retry transaction. |
| 46208 | S The U.S. card issuing bank does not support AVS. |
| 46209 | U The address information for the cardholder is unavailable. |
| 46211 | W The nine digit ZIP code matched, but the street address did not. |
| 46214 | X Both the street address and nine-digit ZIP code matched. |
| 46217 | Z The ZIP code matched, but the street address did not. |
| any other | Y with a billing address, P without one. |
Card code (cardCode)
| Code | cvvResultCode |
|---|---|
| 900 | M Successful match. |
| 901 | N Does NOT match. Declines (reason 65). |
| 902 | S Should have been present. |
| 903 | U Issuer unable to process request. |
| 904 | P Not processed. |
| any other | P |
More
- An eCheck over $100 declines; the routing number must pass the ABA check (reason 9), the account number be 5–17 digits (reason 10). Routing number
121042882works. - Over $5,000.00 the sandbox’s Amount Filter authorizes the payment and holds it for review: responseCode 4, reason 252, status
FDSAuthorizedPendingReview, untilupdateHeldTransactionapproves or declines it. - The same charge again — type, amount, card and invoice number — within two minutes is a duplicate (responseCode 3, reason 11). The
duplicateWindowsetting changes the window; 0 turns it off. - Card
4222222222222with an amount equal to a reason code answers that reason ($2.00 → 2, $27.00 → 27): the old test-mode trick, kept as a sandbox extra. - The
testRequestsetting validates the payment and keeps nothing: transId 0.
Statuses and settlement
A charge is capturedPendingSettlement until its batch closes, then settledSuccessfully; an authorization is authorizedPendingCapture for 30 days, then expired. The sandbox closes a batch every minute, so a capture settles about a minute after it is made — in the first batch closing at least 30 seconds later — and cards and eChecks settle in batches of their own. Prior-auth captures and voids act on the original transaction and answer with its id; a refund is a new transaction that needs a settled one (reason 54 otherwise) and can’t give back more than was captured (reason 55). Voiding a settled payment is reason 16.
getSettledBatchList answers the last 24 hours without dates, up to 31 days with them, and includeStatistics counts charges, refunds, voids, declines and errors per card type. The seed account has two weeks of daily batches (they close at 02:00 UTC) and a few open payments: an authorization of $129.00 to capture (63486784401), one to void (66973568802), a capture waiting for its batch (60460353203) and $6,250.00 held for review (63947137604).
Customer profiles and subscriptions
Customer Information Manager keeps profiles, their payment profiles — cards and bank accounts, masked on the way out (XXXX1111, XXXX for the date unless unmaskExpirationDate) — and shipping addresses, with the real service’s rules: one of merchantCustomerId, description or email; no duplicates (E00039, naming the existing id); ten payment profiles at most; nothing in use by an active subscription can be deleted. validationMode testMode checks the card; liveMode runs a zero-dollar authorization that the triggers above apply to, and answers the comma-delimited direct response. Charge a profile with profile.customerProfileId and paymentProfile.paymentProfileId, or save a card as you charge it with profile.createProfile.
Recurring Billing checks a subscription’s schedule as the real service does — start date not in the past, interval 7–365 days or 1–12 months, trial occurrences and amounts together, no duplicates (E00012) — and, paid by card or bank account, makes a customer profile for it (Profile created by Subscription: …). The sandbox keeps and reports subscriptions but doesn’t run their payments. The seed has 8 customer profiles, 11 payment profiles and 3 subscriptions.
Accept: nonces and the hosted form
securePaymentContainerRequest — what Accept.js calls from the browser — takes card details with the API Login ID and a public client key (any; getMerchantDetails hands one out) and answers opaqueData with COMMON.ACCEPT.INAPP.PAYMENT: pay with it once, within 15 minutes, as payment.opaqueData (E00114 the second time, E00115 when it has expired).
getHostedPaymentPageRequest answers a token; post it from the browser in a form field named token to https://api.sondahub.com/sandbox/authorizenet/payment/payment instead of https://test.authorize.net/payment/payment. The form is sondahub’s own: a card number, date, code and ZIP, the same checks and triggers as the API, the receipt with your hostedPaymentReturnOptions link (or straight there when showReceipt is false), and the webhooks your account had when the token was made. That payment isn’t in your session — the browser doesn’t carry your token.
Webhooks, signed
Register endpoints with the REST API — POST /rest/v1/webhooks with name, url, eventTypes and status, Basic auth with your login and key — and every call that raises one of net.authorize.payment.authorization.created, net.authorize.payment.authcapture.created, net.authorize.payment.capture.created, net.authorize.payment.refund.created, net.authorize.payment.priorAuthCapture.created, net.authorize.payment.void.created, net.authorize.payment.fraud.held, net.authorize.payment.fraud.approved, net.authorize.payment.fraud.declined, net.authorize.customer.created, net.authorize.customer.updated, net.authorize.customer.deleted, net.authorize.customer.paymentProfile.created, net.authorize.customer.paymentProfile.updated, net.authorize.customer.paymentProfile.deleted, net.authorize.customer.subscription.created, net.authorize.customer.subscription.updated, net.authorize.customer.subscription.suspended, net.authorize.customer.subscription.terminated, net.authorize.customer.subscription.cancelled, net.authorize.customer.subscription.expiring, net.authorize.customer.subscription.expired, net.authorize.customer.subscription.failed posts your URL a notification: notificationId, eventType, eventDate, webhookId and the payload (for a payment: responseCode, authCode, avsResponse, authAmount, entityName, id). The sandbox sends each one once, while it answers the call that raised it (it waits up to 3 s), and keeps the outcome in your session: GET /rest/v1/notifications lists them as Delivered or Failed, and …/notifications/{id} has the payload and the attempt. POST /rest/v1/webhooks/{id}/pings sends a sample and answers 200 when your URL answered 2xx. The subscription events that come from running payments — suspended, terminated, expiring, expired, failed — can be subscribed to but never arrive, since the sandbox doesn’t run them.
Each notification carries X-ANET-Signature: sha512=…, the HMAC-SHA512 of the raw body keyed with the Signature Key. The same key, as bytes, signs each payment’s transHashSha2 over ^login^transId^amount^:
Python
import hashlib, hmac
SIGNATURE_KEY = 'B1224AC959AB2E41CF56404EBDC5677F804E9CDD88DA26E9D8DEB2B91E8466F116F66A580DC0120ABB662C672C4C76D51E360B893327D077FD6EFA95F5EA439C'
def valid_notification(raw_body: bytes, header: str) -> bool:
want = 'sha512=' + hmac.new(SIGNATURE_KEY.encode(), raw_body, hashlib.sha512).hexdigest()
return hmac.compare_digest(want.lower(), (header or '').lower())
def valid_trans_hash(login: str, trans_id: str, amount: str, trans_hash_sha2: str) -> bool:
message = f'^{login}^{trans_id}^{amount}^' # amount as sent back: 12.50
want = hmac.new(bytes.fromhex(SIGNATURE_KEY), message.encode(), hashlib.sha512).hexdigest()
return hmac.compare_digest(want.upper(), trans_hash_sha2.upper())
Node
import crypto from 'node:crypto'
const SIGNATURE_KEY = 'B1224AC959AB2E41CF56404EBDC5677F804E9CDD88DA26E9D8DEB2B91E8466F116F66A580DC0120ABB662C672C4C76D51E360B893327D077FD6EFA95F5EA439C'
// express.raw({ type: 'application/json' }) — the signature covers the exact bytes
const want = 'sha512=' + crypto.createHmac('sha512', SIGNATURE_KEY).update(req.body).digest('hex')
const got = (req.get('X-ANET-Signature') || '').toLowerCase()
const ok = got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))
The hub-wide X-Sondahub-Webhook header on a call also gets its notifications, signed the same way (the webhook tester).
What it answers
createTransaction: authCaptureTransaction, authOnlyTransaction, priorAuthCaptureTransaction, captureOnlyTransaction, refundTransaction, voidTransaction — by creditCard, bankAccount, opaqueData or profile; updateHeldTransaction, sendCustomerTransactionReceipt.getTransactionDetails, getUnsettledTransactionList, getSettledBatchList, getTransactionList, getBatchStatistics, getTransactionListForCustomer, getMerchantDetails, authenticateTest.createCustomerProfileFromTransaction.ARBCreateSubscription, ARBGetSubscription, ARBGetSubscriptionStatus, ARBUpdateSubscription, ARBCancelSubscription, ARBGetSubscriptionList. Accept: securePaymentContainer, getHostedPaymentPage.XML or JSON in, the same out, every answer HTTP 200 with messages.resultCode Ok or Error and the real API’s codes and texts (E00003, E00007, E00027, E00039, E00040…). A request the schema refuses — out of order, an unknown element, a value too long — is answered with an ErrorResponse. Also at https://api.sondahub.com/sandbox/authorizenet/xml/v1/request.api.
Questions
Is this Authorize.net?
No — an independent imitation of Authorize.net’s API for testing, not affiliated with or endorsed by Authorize.net, Visa or Cybersource. Nothing is charged, no card network is involved and no Authorize.net account is needed.
Will the official SDKs work against it?
Yes, with the few lines above. The Python SDK (authorizenet, which posts XML and reads answers with PyXB) and the Node SDK (authorizenet, which posts JSON through axios) were both run against the sandbox, along with Authorize.net’s own sample-code runners: every sample passes except the ones for PayPal, Visa Click to Pay and split tender, which the sandbox does not model, the few that use ids from Authorize.net’s own test account, and one that expects a brand-new subscription to have payments already. The Python SDK gives up validating any answer that holds an address with two or more fields and reads it with lxml instead — its schema adds wildcards that make PyXB’s check ambiguous — and it does the same with the real API; the values read the same either way.
Why do I get E00003 when my JSON looks right?
Because the API checks elements in schema order, JSON included: transactionType before amount, amount before payment, refTransId after payment, and so on. The error names the element that is out of place and the ones it expected there, as the real one does. The SDKs build requests in order; hand-written JSON is where it bites.
Why does JSON.parse fail on the answer?
Every answer starts with a UTF-8 byte order mark, as the real API’s do. fetch’s .json() and axios drop it; Python’s requests keeps it (the header says utf-8), so r.json() raises “Unexpected UTF-8 BOM” — decode with r.content.decode('utf-8-sig'). In other languages, strip \uFEFF before parsing.
When can I refund a payment?
Once it has settled. A capture settles in the batch that closes at least 30 seconds after it, and the sandbox closes a batch every minute — so about a minute after you charge, getTransactionDetails says settledSuccessfully, the batch shows in getSettledBatchList, and a refundTransaction works. Before that, void it. The seed account’s history settles once a day, and its payment 67433922005 ($49.99, card 1111) is settled and refundable.
How are webhooks signed?
As Authorize.net signs them: X-ANET-Signature: sha512= followed by the HMAC-SHA512 of the raw body, keyed with the Signature Key, in hex. The sandbox’s Signature Key is published above; the same key makes each payment’s transHashSha2. It proves your verification code and protects nothing.
What is not modelled?
PayPal, Visa Click to Pay (Visa Checkout), Apple Pay and Google Pay payment data, split tender, track and EMV data, the hosted customer profile page (Accept Customer), mobile device and invoicing calls, Account Updater, partial authorization, and running subscriptions’ payments — subscriptions are kept and reported, but no transactions are made for them. Those calls answer E00004 (or E00013 for a payment kind) saying so. Receipts are answered Ok and no email is sent.