Testing and the sandbox
The sandbox is the hosted API addressed with a test-mode key. It is not a separate deployment: PayInference never moves money and never calls a real PSP, so the same service answers test and live traffic and the key decides how a decision is recorded.
Test mode#
API keys carry a mode. A pi_test_… key marks every decision it creates with
"mode": "test", keeping test traffic separate in the decision log, the
analytics, and billing. Use a test key everywhere except production.
| Item | Value |
|---|---|
| API | https://api.payinference.com |
| Dashboard | https://dashboard.payinference.com |
| Key mode | test |
Create a test key under Developers → API keys in the dashboard. The raw secret is shown once, at creation, and never again.
What the sandbox does and does not do#
Every provider is a mock driven by synthetic probes. No real PSP credentials exist, and no payment can occur in either mode — PayInference returns an instruction, and your stack executes it. What test mode changes is bookkeeping, not behaviour: the decision path, policies, provider health and reason codes are identical, so a handler proven against a test key behaves the same against a live one.
Testing decision handling#
Exercise every instruction path in your handler. Instructions are driven by policy rules, retry context, and provider health, so drive them deliberately:
blockandstep_up: these come from matched policy rules, so publish a test policy with low thresholds, for example{ "when": { "amount_gte": 1 }, "then": { "type": "step_up" } }, or a rule onrisk_score_gtecombined with atransaction.risk_scorein your request. Sendingrisk_signalsalone raises the advisoryriskobject in the response but does not change the instruction.hold: publish a rule with{ "then": { "type": "hold" } }; hold outranks step_up when both match.retry: publish aretry_on_timeoutrule and includeprevious_attemptwithoutcome: "timeout"in the request.failover: includeprevious_attemptwithoutcome: "timeout"naming a provider whose cached health state is degraded or worse.blockvia retry suppression: publish ano_retryrule listing a reason code, then sendprevious_attempt.provider_reason_codematching it.use_default_route: make every offered provider ineligible (for example arequire_min_healthrule with a score of 100) while the policy defines adefault_provider.
Verify each handled decision lands in the decision log with the outcome you reported: GET /v1/decisions/:decisionId.
Simulating policies#
Test a policy against sample transactions without publishing or affecting live decisions:
curl -s https://api.payinference.com/v1/policies/simulate \
-H "content-type: application/json" \
-H "x-api-key: $PAYINFERENCE_API_KEY" \
-d '{
"merchant_id": "m_123",
"transaction": { "amount": 75000, "currency": "EUR", "country": "DE", "payment_method": "card" },
"available_providers": ["stripe", "adyen"]
}'
The response shows the resulting action, matched rules, excluded providers, and per-provider evaluations, using the same engine and cached health as the live path.
Unit testing your integration#
Inject a fake fetch into the Node SDK to test your handler without any network:
import { PayInferenceClient } from '@payinference/sdk';
const client = new PayInferenceClient({
apiKey: 'pi_test_x',
fetch: async () =>
new Response(
JSON.stringify({
decision_id: 'dec_test',
merchant_id: 'm_123',
mode: 'test',
action: 'route',
route: { primary_provider: 'stripe', fallback_provider: null },
provider_health: {},
policy: { policy_id: null, policy_version: null, matched_rules: [] },
model: { model_version: 'test', route_scores: {} },
reason_codes: [],
decision_latency_ms: 1,
ttl_ms: 3000,
}),
{ status: 200 },
),
});
Test your outage path too: point baseUrl at an unroutable address and assert that your fallback executes.
Latency verification#
You do not need a load-testing harness to check the budget: every decision
reports its own cost. decision_latency_ms is the server-side compute time
for that decision — policy, provider health, scoring — and excludes network
transit and the persistence that happens after the response is sent.
curl -s -w '\ntotal %{time_total}s | connect %{time_connect}s\n' \
https://api.payinference.com/v1/decision \
-H "content-type: application/json" \
-H "x-api-key: $PAYINFERENCE_API_KEY" \
-d '{ "merchant_id": "m_123", "order_id": "lat_1",
"transaction": { "amount": 14900, "currency": "USD", "country": "US", "payment_method": "card" },
"available_providers": ["stripe", "adyen"] }'
The decision prints first, then the timing line — so you see both numbers from one call. Read them together:
| Measurement | What it tells you |
|---|---|
decision_latency_ms (in the body) |
PayInference's own compute. Design budget: under 50 ms p95 |
your wall clock (time_total) |
Compute plus your network round trip and TLS |
| the difference | Your distance from the API — the part you can act on |
That difference is usually the larger number, and it is the one worth
optimising: colocate your backend near the API, keep connections warm, and set
the SDK's timeoutMs against your observed total, not against the compute
budget.
For a distribution rather than a single sample, point any HTTP load tool
(hey, oha, k6, autocannon) at /v1/decision with a test key. Discard
the first few requests: policy and provider health are served from short-lived
caches that a cold process has to populate once.
Track the same figure in production from the decision_latency_ms you already
receive on every call — see Observability.

