sondahub

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

These examples share one session: run them in order and each sees what the one before it did.
Create a customer
curl https://api.sondahub.com/v1/customers \
  -u sk_test_sondahub: \
  -d "[email protected]" \
  -d "name=Ada Lovelace" \
  -d "metadata[plan]=pro"
Take a payment with Stripe’s test card pm_card_visa
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"
A decline: insufficient funds — 402
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"
The newest PaymentIntents
curl "https://api.sondahub.com/v1/payment_intents?limit=3" -u sk_test_sondahub:
Refund part of a payment from the seed account
curl https://api.sondahub.com/v1/refunds \
  -u sk_test_sondahub: \
  -d "payment_intent=pi_1Sh1Kbwe5lqI1XPwHWwqG0O8" \
  -d "amount=500"
What happened, as events
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 idWhat happens
4242 4242 4242 4242
pm_card_visa
Succeeds
4000 0566 5566 5556
pm_card_visa_debit
Succeeds (debit)
5555 5555 5555 4444
pm_card_mastercard
Succeeds
2223 0031 2200 3222Succeeds (2-series)
5200 8282 8282 8210
pm_card_mastercard_debit
Succeeds (debit)
5105 1051 0510 5100
pm_card_mastercard_prepaid
Succeeds (prepaid)
3782 8224 6310 005
pm_card_amex
Succeeds
3714 4963 5398 431Succeeds
6011 1111 1111 1117
pm_card_discover
Succeeds
6011 0009 9013 9424Succeeds
3056 9300 0902 0004
pm_card_diners
Succeeds
3622 7206 2716 67Succeeds (14 digits)
3566 0020 2036 0505
pm_card_jcb
Succeeds
6200 0000 0000 0005
pm_card_unionpay
Succeeds
4000 0000 0000 0002
pm_card_visa_chargeDeclined
Declined: card_declined / generic_decline
4000 0000 0000 9995
pm_card_visa_chargeDeclinedInsufficientFunds
Declined: card_declined / insufficient_funds
4000 0000 0000 9987
pm_card_visa_chargeDeclinedLostCard
Declined: card_declined / lost_card
4000 0000 0000 9979
pm_card_visa_chargeDeclinedStolenCard
Declined: card_declined / stolen_card
4000 0000 0000 6975
pm_card_visa_chargeDeclinedVelocityLimitExceeded
Declined: card_declined / card_velocity_exceeded
4000 0000 0000 0069
pm_card_chargeDeclinedExpiredCard
Declined: expired_card
4000 0000 0000 0127
pm_card_chargeDeclinedIncorrectCvc
Declined: incorrect_cvc
4000 0000 0000 0119
pm_card_chargeDeclinedProcessingError
Declined: processing_error
4000 0000 0000 0341
pm_card_chargeCustomerFail
Attaches to a customer, then every charge is declined
4100 0000 0000 0019
pm_card_radarBlock
Blocked as too risky (outcome type blocked)
4000 0025 0000 3155
pm_card_authenticationRequiredOnSetup
Asks for 3D Secure
4000 0027 6000 3184
pm_card_authenticationRequired
Always asks for 3D Secure
4000 0000 0000 32203D Secure 2 required
4000 0084 0000 0027
pm_card_threeDSecure2Required
3D Secure 2 required
4000 0084 0000 1629
pm_card_threeDSecureRequiredChargeDeclined
3D Secure, then declined
4000 0082 6000 3178
pm_card_authenticationRequiredChargeDeclinedInsufficientFunds
3D Secure, then insufficient funds

How a payment moves

StatusWhen
requires_payment_methodCreated without a card, or the last card was declined (last_payment_error says why).
requires_confirmationHas a card; waiting for /confirm.
requires_actionA 3D Secure card: next_action.redirect_to_url opens the sandbox’s authentication page.
requires_capturecapture_method=manual: authorised, waiting for /capture (all or part of it).
succeededPaid; latest_charge is the charge, refunds change it.
canceledAfter /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).

Register an endpoint (the answer holds its whsec_ secret, once)
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

GET/v1/balanceAvailable funds, from the charges and refunds of the seed account and your session (pending is always 0).
POST/v1/customersAnd GET, list (email, created), update, DELETE; GET /v1/customers/{id}/payment_methods.
POST/v1/payment_methodstype=card from a test card; retrieve, update, list (customer); /attach, /detach.
POST/v1/payment_intentsAnd retrieve, update, list (customer); /confirm, /capture, /cancel.
GET/v1/chargesList (customer, payment_intent), retrieve, update.
POST/v1/refundsBy payment_intent or charge, whole or partial; retrieve, update, list.
POST/v1/productsWith default_price_data; retrieve, update, list, DELETE (refused while it has prices).
POST/v1/pricesOne-time or recurring, lookup_key; retrieve, update, list.
POST/v1/checkout/sessionsmode=payment; retrieve, list, /line_items, /expire.
GET/v1/eventsYour session’s last 30 events; retrieve by id.
POST/v1/webhook_endpointsurl and enabled_events (exact types, or * for every event — partial wildcards are refused, as Stripe refuses them); retrieve, update, list, DELETE.

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.