sondahub / Stripe sandbox
A Stripe mock API that remembers, and calls you back
Stripe’s API, answered by sondahub: the same paths, test keys, ids, errors and payment statuses, so stripe-node, stripe-python or curl work with the host swapped and the session token carried. Payments decline on Stripe’s own test cards, 3D Secure stops for a browser, and the webhook endpoints you register receive events that Stripe’s own library verifies.
An independent imitation for testing. Not affiliated with, or endorsed by, Stripe, Inc. No money moves and no Stripe account is used.
Connect
- Instead of
- https://api.stripe.com
- Use
- https://api.sondahub.com
- Secret key
- any sk_test_… key — sk_test_sondahub works
- Publishable key
- any pk_test_… (creating payment methods only)
- Also at
- https://api.sondahub.com/sandbox/stripe/v1
- OpenAPI 3
- https://api.sondahub.com/sandbox/stripe/openapi.json
Import it. In Sonda: Import → From a URL, paste the OpenAPI address. You get a folder per resource and a request per operation, with example bodies and ids from the seed account, so each request works as it is. Then set the auth: Bearer token sk_test_sondahub. Any client that imports OpenAPI 3 takes the same address.
Stripe’s SDKs know nothing about sondahub’s sessions, so give them the few lines that carry X-Sondahub-Session from one answer to the next request — otherwise every call starts from the seed account and the customer you just made is “No such customer”.
Node (stripe-node)
import Stripe from 'stripe'
// carry the sandbox session from each answer to the next request
let session = null
const sessionFetch = async (url, init = {}) => {
const headers = new Headers(init.headers)
if (session) headers.set('X-Sondahub-Session', session)
const res = await fetch(url, { ...init, headers })
session = res.headers.get('X-Sondahub-Session') ?? session
return res
}
const stripe = new Stripe('sk_test_sondahub', {
host: 'api.sondahub.com',
httpClient: Stripe.createFetchHttpClient(sessionFetch),
})
const customer = await stripe.customers.create({ email: '[email protected]' })
const pi = await stripe.paymentIntents.create({ amount: 2000, currency: 'usd', customer: customer.id, payment_method: 'pm_card_visa', confirm: true })
Python (stripe-python)
import requests, stripe
# carry the sandbox session from each answer to the next request
session = requests.Session()
def keep(r, *args, **kwargs):
if 'X-Sondahub-Session' in r.headers:
session.headers['X-Sondahub-Session'] = r.headers['X-Sondahub-Session']
session.hooks['response'].append(keep)
client = stripe.StripeClient(
'sk_test_sondahub',
base_addresses={'api': 'https://api.sondahub.com'},
http_client=stripe.RequestsClient(session=session),
)
customer = client.v1.customers.create(params={'email': '[email protected]'})
pi = client.v1.payment_intents.create(params={'amount': 2000, 'currency': 'usd', 'customer': customer.id, 'payment_method': 'pm_card_visa', 'confirm': True})
The module-level style works too:
stripe.api_key = 'sk_test_sondahub' stripe.api_base = 'https://api.sondahub.com' stripe.default_http_client = stripe.RequestsClient(session=session) # the session above
Try it here
curl https://api.sondahub.com/v1/customers \ -u sk_test_sondahub: \ -d "[email protected]" \ -d "name=Ada Lovelace" \ -d "metadata[plan]=pro"
curl https://api.sondahub.com/v1/payment_intents \ -u sk_test_sondahub: \ -d "amount=2000" \ -d "currency=usd" \ -d "payment_method=pm_card_visa" \ -d "confirm=true" \ -d "expand[]=latest_charge"
curl https://api.sondahub.com/v1/payment_intents \ -u sk_test_sondahub: \ -d "amount=2000" \ -d "currency=usd" \ -d "payment_method=pm_card_visa_chargeDeclinedInsufficientFunds" \ -d "confirm=true"
curl "https://api.sondahub.com/v1/payment_intents?limit=3" -u sk_test_sondahub:
curl https://api.sondahub.com/v1/refunds \ -u sk_test_sondahub: \ -d "payment_intent=pi_1Sh1Kbwe5lqI1XPwHWwqG0O8" \ -d "amount=500"
curl "https://api.sondahub.com/v1/events?limit=5" -u sk_test_sondahub:
From the shell, keep the token yourself: curl -i shows X-Sondahub-Session; send it back with -H "X-Sondahub-Session: …". How sessions work.
Test cards
Stripe’s own test numbers and test PaymentMethod ids, doing what Stripe documents them to do. Pass the pm_card_… id as payment_method (a new payment method is made from it), or the number as card[number] with any future expiry date and any CVC. A number that is not a test card is refused — never send a real one anywhere but Stripe.
| Number and test PaymentMethod id | What happens |
|---|---|
4242 4242 4242 4242pm_card_visa | Succeeds |
4000 0566 5566 5556pm_card_visa_debit | Succeeds (debit) |
5555 5555 5555 4444pm_card_mastercard | Succeeds |
| 2223 0031 2200 3222 | Succeeds (2-series) |
5200 8282 8282 8210pm_card_mastercard_debit | Succeeds (debit) |
5105 1051 0510 5100pm_card_mastercard_prepaid | Succeeds (prepaid) |
3782 8224 6310 005pm_card_amex | Succeeds |
| 3714 4963 5398 431 | Succeeds |
6011 1111 1111 1117pm_card_discover | Succeeds |
| 6011 0009 9013 9424 | Succeeds |
3056 9300 0902 0004pm_card_diners | Succeeds |
| 3622 7206 2716 67 | Succeeds (14 digits) |
3566 0020 2036 0505pm_card_jcb | Succeeds |
6200 0000 0000 0005pm_card_unionpay | Succeeds |
4000 0000 0000 0002pm_card_visa_chargeDeclined | Declined: card_declined / generic_decline |
4000 0000 0000 9995pm_card_visa_chargeDeclinedInsufficientFunds | Declined: card_declined / insufficient_funds |
4000 0000 0000 9987pm_card_visa_chargeDeclinedLostCard | Declined: card_declined / lost_card |
4000 0000 0000 9979pm_card_visa_chargeDeclinedStolenCard | Declined: card_declined / stolen_card |
4000 0000 0000 6975pm_card_visa_chargeDeclinedVelocityLimitExceeded | Declined: card_declined / card_velocity_exceeded |
4000 0000 0000 0069pm_card_chargeDeclinedExpiredCard | Declined: expired_card |
4000 0000 0000 0127pm_card_chargeDeclinedIncorrectCvc | Declined: incorrect_cvc |
4000 0000 0000 0119pm_card_chargeDeclinedProcessingError | Declined: processing_error |
4000 0000 0000 0341pm_card_chargeCustomerFail | Attaches to a customer, then every charge is declined |
4100 0000 0000 0019pm_card_radarBlock | Blocked as too risky (outcome type blocked) |
4000 0025 0000 3155pm_card_authenticationRequiredOnSetup | Asks for 3D Secure |
4000 0027 6000 3184pm_card_authenticationRequired | Always asks for 3D Secure |
| 4000 0000 0000 3220 | 3D Secure 2 required |
4000 0084 0000 0027pm_card_threeDSecure2Required | 3D Secure 2 required |
4000 0084 0000 1629pm_card_threeDSecureRequiredChargeDeclined | 3D Secure, then declined |
4000 0082 6000 3178pm_card_authenticationRequiredChargeDeclinedInsufficientFunds | 3D Secure, then insufficient funds |
How a payment moves
| Status | When |
|---|---|
requires_payment_method | Created without a card, or the last card was declined (last_payment_error says why). |
requires_confirmation | Has a card; waiting for /confirm. |
requires_action | A 3D Secure card: next_action.redirect_to_url opens the sandbox’s authentication page. |
requires_capture | capture_method=manual: authorised, waiting for /capture (all or part of it). |
succeeded | Paid; latest_charge is the charge, refunds change it. |
canceled | After /cancel; a held amount is released. |
3D Secure. The authentication page stands in for the bank’s: approve or fail, and the browser returns to your return_url with payment_intent and redirect_status. The page can’t write to your session (the browser doesn’t carry your token), so the PaymentIntent waits in requires_action: on redirect_status=succeeded the server finishes it by confirming once more — where Stripe would have moved it to succeeded on its own — and on failed the server cancels it or confirms with another card (Stripe would have moved it to requires_payment_method). To test a card that authenticates and is then declined, use pm_card_threeDSecureRequiredChargeDeclined.
Errors. Card errors answer 402 with type: card_error, code, decline_code and the PaymentIntent inside; the rest answer 400 invalid_request_error naming the parameter — an unknown parameter, an amount under the currency’s minimum, a status that does not allow the call — or 404 resource_missing.
Webhooks
Register an endpoint and every event your calls cause is POSTed to it, signed with its secret the way Stripe signs: Stripe-Signature: t=…,v1=…. Deliveries go out within a second or two of the answer, retried after 1, 3 and 8 seconds when your endpoint can’t be reached or answers 408, 429 or a 5xx (any other answer is final).
curl https://api.sondahub.com/v1/webhook_endpoints \ -u sk_test_sondahub: \ -d "url=https://your-app.example/stripe/webhooks" \ -d "enabled_events[]=payment_intent.succeeded" \ -d "enabled_events[]=checkout.session.completed"
// Express: the raw body, not the parsed one
app.post('/stripe/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], endpointSecret)
if (event.type === 'payment_intent.succeeded') fulfil(event.data.object)
res.sendStatus(200)
})
Events: customer.created / updated / deleted · payment_method.attached / detached / updated · payment_intent.created / requires_action / amount_capturable_updated / succeeded / payment_failed / canceled / updated · charge.succeeded / failed / captured / refunded / updated · refund.created / updated · product.created / updated / deleted · price.created / updated · checkout.session.completed / expired. GET /v1/events keeps the last 30 of your session. For a one-off target, the hub-wide X-Sondahub-Webhook header on any call works too (the webhook tester).
Checkout
POST /v1/checkout/sessions (mode payment, line_items by price or price_data, success_url) answers a session whose url opens a test checkout page on sondahub — plainly a sandbox page, never a copy of Stripe’s. Paying sends charge.succeeded, payment_intent.succeeded and checkout.session.completed to the endpoints you had registered and sends the browser to your success_url, {CHECKOUT_SESSION_ID} filled in.
The browser’s payment does not reach your API session: retrieving the session afterwards still says open and unpaid, and the pi_ and ch_ ids in those three events can’t be retrieved. Fulfil from the event’s own payload — the paid session is in checkout.session.completed, which is how Stripe tells you to fulfil orders anyway.
What it answers
Lists take limit, starting_after, ending_before and, except /line_items, created; every call takes expand[], Idempotency-Key and Stripe-Version (echoed back). The seed account has 24 customers with cards, 8 products and 40 payments — succeeded, refunded, declined, canceled and held for capture.
Questions
Is this Stripe?
No — an independent imitation of Stripe’s API for testing, not affiliated with or endorsed by Stripe, Inc. No money moves, no Stripe account is involved, and a real card number is refused: use Stripe’s published test cards.
Will my Stripe SDK work against it?
Yes, by changing the host — stripe-node’s host option, stripe-python’s base_addresses or api_base. The calls on this page were run with the official stripe-python SDK against the sandbox; the Node set-up follows stripe-node’s own host and fetch-client options. Add the session hook, or each call starts from the seed account and what you created is not found.
How is it different from stripe-mock?
stripe-mock, Stripe’s own mock server, answers each request from Stripe’s OpenAPI description and keeps no state. This sandbox runs the flow: a payment you confirm is there on the next retrieve, a refund changes the charge, a declining test card declines, and webhooks reach your endpoint signed with its secret.
Do webhooks verify with the Stripe library?
Yes. Deliveries carry Stripe-Signature: t=…,v1=… made with the endpoint’s whsec_ secret, the way Stripe makes them, and stripe.webhooks.constructEvent / stripe.Webhook.construct_event accept them — checked on every delivery of a full run with stripe-python.
What is not modelled?
Subscriptions and invoices, SetupIntents, Connect, tokens and sources, disputes, Radar rules, the search endpoints and anything else outside the list above: those paths answer a Stripe-shaped 404 saying so (an unknown /v1 path does too, as long as a Stripe key comes with it). Checkout runs mode=payment.