<!-- Generated from blog/five-answers-to-an-outbound-call.html at build time. Do not edit. -->

> Canonical: https://antifailure.dev/blog/five-answers-to-an-outbound-call

> Most environments have two: let it through, or break. Neither is right for a payment processor, and the gap is where test runs charge real cards.

# There are five useful answers to an outbound HTTP call in a test environment

Most environments have two: let it through, or break. Neither is right for a payment processor, and the gap is where test runs charge real cards.

A pre-production environment running your real application will try to do the things your real application does. It will charge a card, send a welcome email, post to a webhook, and call whatever else you have integrated. Every one of those is a side effect leaving the boundary, and each needs an answer.

Most environments offer two. Let it through, which occasionally emails a real customer. Or cut the network, which turns every integration into a connection error and every test into a test of your retry logic.

Neither is the right answer for a payment processor. There are five.

## The five

In Antifailure each host gets one mode, chosen per host rather than globally, because a single environment usually needs several of these at once.

- BLOCK. Refuse, with a decision you can read. The distinction from an unplugged network matters: a refusal that names the host and says why is a diagnosis, while a timeout is a mystery that costs somebody an afternoon.

- ALLOW. Let it through, with a rate limit. For the hosts that genuinely need to be real, and the rate limit is there because a load test pointed at somebody else's API is an incident on their side.

- SANDBOX. Swap in test credentials and trip a wire if a live key ever appears. The tripwire is the useful half. Sandbox credentials are easy; noticing that somebody's environment is holding a production key is the thing that prevents the incident.

- CAPTURE. Record the email or SMS into a searchable inbox. This turns a side effect into an assertion: an agent can sign in with a magic link because the link is in an inbox it can read, rather than the flow ending at “check your email.”

- MOCK. Answer from a stateful offline pack. Stateful is the word carrying the weight, and it is the difference between a fixture and a simulator.

There is a sixth mode and this post is not counting it. SYNTH asks a model to invent the response, which is worth having when a provider offers no sandbox and you have no fixture yet and the run would otherwise stop dead. It is not an answer in the sense the other five are. Anything that touches a synthesized response is reported as unverified rather than passed, even when every assertion held, because the reply came from a model and not from the thing under test. The five above give you a result you can rely on. That one gives you a shape to keep moving against, and tells you plainly that nothing you just saw is evidence.

## Why stateful mocking is a different thing

A recorded fixture answers one request with one response. That is enough to test a single call and not enough to test a flow, because a real integration has a state machine in it. A subscription that has been cancelled must answer differently from one that has not. A payment intent moves through statuses. A webhook arrives after the call that caused it, signed, and your handler verifies that signature.

The test for whether a mock is good enough is whether a complete lifecycle runs against it. The Stripe pack is complete enough to run checkout, subscribe, renew and cancel with signed webhooks and no network at all. Signed matters, because a mock that skips signature verification is not exercising the code path that runs in production, so the one part of the handler most likely to be wrong is the part never tested.

## The default is the design

Choosing a mode per host is a configuration question. What happens to a host nobody configured is a design question, and it is the one that determines whether the containment holds.

An unlisted host fails closed. This is inconvenient in exactly the way it should be: adding an integration means the first run stops and tells you there is an unnamed host, and you decide what it should be. The alternative, defaulting to allow, is a system that is contained only for the integrations somebody remembered, and silently uncontained for every one added since.

The enforcement point matters as much as the default. Every environment gets a sidecar that owns its network namespace, and nothing leaves except through it. Not a configured proxy the application is asked to use, which is a request the application can decline. A namespace it cannot route around, so a library making its own connection is subject to the same rules as everything else.

## What this buys

A test suite that can run a real checkout, receive the signed webhook, read the confirmation email, and finish, all without a network connection or a single real charge. The flows most worth testing are the ones with side effects, and they are exactly the ones most environments cannot test at all.

## Also here

- Staging cannot tell you how long a lock is held A migration that runs instantly against a seeded table can hold an exclusive lock for minutes against a real one. The difference is row count, and staging does not have it.

- Masking data is easy. Proving it worked is the product. Any UPDATE statement can overwrite an email column. The hard part is showing that nothing identifying survived anywhere, and that nobody can skip the check.

## Know what happens before you deploy.

Create a disposable production twin for every risky change. Catch migration failures before they reach customers.
