Skip to main content
This guide covers common issues encountered when integrating @vaultsaas/core and how to resolve them.

Provider Authentication Failures

Symptom: VaultProviderError with code PROVIDER_AUTH_FAILED, or VaultConfigError with code INVALID_CONFIGURATION at startup.Cause: The API key passed to the provider adapter is empty, malformed, or does not match the provider environment.Solution: Verify that the correct environment variable is set and that it is not empty or whitespace-only.
// Each adapter validates its credentials at construction time.
// Stripe requires `apiKey`:
const vault = new VaultClient({
  providers: {
    stripe: {
      adapter: StripeAdapter,
      config: {
        apiKey: process.env.STRIPE_API_KEY!, // must be non-empty
      },
    },
  },
  routing: {
    rules: [{ match: { default: true }, provider: 'stripe' }],
  },
});

// dLocal requires three credentials:
// config.xLogin, config.xTransKey, config.secretKey

// Paystack requires:
// config.secretKey
If you see VaultConfigError: Stripe adapter requires config.apiKey. the key is missing or blank. Double-check that your .env file is loaded before the VaultClient constructor runs.
Symptom: VaultProviderError with code PROVIDER_AUTH_FAILED at charge time (not at construction). The error context will include httpStatus: 401 or httpStatus: 403.Cause: The key was valid when the client was created but has since been revoked or rotated on the provider dashboard.Solution: Rotate the key in your environment and restart the application. The SDK does not cache provider sessions — a fresh key takes effect on the next API call.
Symptom: Charges succeed in development but fail with PROVIDER_AUTH_FAILED in production (or vice versa).Cause: Stripe test keys (sk_test_...) only work against test mode. Live keys (sk_live_...) only work against live mode. Similarly, dLocal sandbox credentials do not work against the production endpoint.Solution:
  • Ensure you are loading the correct key for the target environment.
  • For dLocal, also set baseUrl to https://sandbox.dlocal.com when using sandbox credentials:
dlocal: {
  adapter: DLocalAdapter,
  config: {
    xLogin: process.env.DLOCAL_X_LOGIN!,
    xTransKey: process.env.DLOCAL_X_TRANS_KEY!,
    secretKey: process.env.DLOCAL_SECRET_KEY!,
    baseUrl: process.env.DLOCAL_BASE_URL, // 'https://sandbox.dlocal.com' for test
  },
},

Webhook Signature Verification Failures

Symptom: WebhookVerificationError with message ending in “signature verification failed.”Cause: The webhookSecret in the adapter config does not match the secret configured on the provider dashboard.Solution: Copy the webhook signing secret from the provider dashboard and pass it in config.
stripe: {
  adapter: StripeAdapter,
  config: {
    apiKey: process.env.STRIPE_API_KEY!,
    webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, // whsec_...
  },
},
For dLocal, the adapter falls back to secretKey if webhookSecret is not set. Make sure whichever value you use matches what dLocal sends. For Paystack, the adapter falls back to secretKey if webhookSecret is not set.
Symptom: WebhookVerificationError: Stripe webhook secret is not configured.Cause: The webhookSecret field was not provided in the Stripe adapter config. Stripe verification requires this value.Solution: Add the webhookSecret field to your Stripe provider config:
config: {
  apiKey: process.env.STRIPE_API_KEY!,
  webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
},
Symptom: WebhookVerificationError — signature verification fails even though the secret is correct.Cause: Express’s express.json() middleware parses the request body into a JavaScript object. When the SDK re-serializes it, the result differs from the original raw bytes, which breaks the HMAC signature.Solution: Capture the raw body before Express parses it. Use the verify callback to stash the raw buffer:
import express from 'express';
import { VaultClient, WebhookVerificationError } from '@vaultsaas/core';

const app = express();

// Capture raw body for webhook routes
app.use(
  '/webhooks',
  express.json({
    verify: (req, _res, buf) => {
      (req as any).rawBody = buf;
    },
  }),
);

app.post('/webhooks/stripe', async (req, res) => {
  try {
    const rawBody = (req as any).rawBody as Buffer;
    const headers = req.headers as Record<string, string>;
    const event = await vault.handleWebhook('stripe', rawBody, headers);
    console.log('Event:', event.type, event.providerEventId);
    res.sendStatus(200);
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return res.status(400).send(error.message);
    }
    res.sendStatus(500);
  }
});
Symptom: WebhookVerificationError: Stripe webhook timestamp is too old (exceeds 5-minute tolerance). Possible replay attack.Cause: The Stripe adapter enforces a 5-minute tolerance between the webhook timestamp and the server’s current time. If the server clock is skewed, legitimate webhooks will be rejected.Solution:
  • Synchronize the server clock with NTP.
  • If running in Docker or a VM, ensure the host clock is accurate.
  • In CI/testing, make sure the test generates a recent timestamp.

Routing Mismatches

Symptom: VaultRoutingError with code NO_ROUTING_MATCH or a charge that falls through to an unexpected default provider.Cause: The request’s currency, country, payment method, or amount did not match any specific routing rule, and the request was either handled by the default rule or no rule matched at all.Solution: Review your routing rules. Rules are evaluated top-to-bottom and the first match wins. Add more specific rules above the default:
routing: {
  rules: [
    // Specific rules first
    {
      provider: 'dlocal',
      match: { country: 'BR', paymentMethod: ['pix', 'boleto'] },
    },
    {
      provider: 'paystack',
      match: { currency: 'NGN' },
    },
    // Default fallback last (required)
    {
      provider: 'stripe',
      match: { default: true },
    },
  ],
},
Check result.routing.reason on a successful charge to see which rule was selected and at which index.
Symptom: VaultConfigError: Routing configuration must include a default fallback rule. at construction time, or VaultRoutingError: Routing rules must include a default fallback rule. from the Router.Cause: The SDK requires at least one routing rule with match: { default: true }. This ensures every request can be routed even if no specific rule matches.Solution: Add a default rule at the end of your rules array:
routing: {
  rules: [
    // ... your specific rules ...
    { provider: 'stripe', match: { default: true } },
  ],
},
Symptom: The charge is routed to a provider that rejects it with INVALID_REQUEST because the currency or payment method is unsupported.Cause: Routing matched a rule, but the target provider does not support the request’s currency or method. For example, routing a pix payment to Stripe, or an NGN charge to dLocal.Solution: Add match constraints so rules only fire for supported combinations. Each adapter exposes a metadata object with supported currencies, countries, and methods. Use them as a reference:
// Stripe supports: card, bank_transfer, wallet
// Stripe currencies: USD, EUR, GBP, CAD, AUD, JPY, etc.

// dLocal supports: card, pix, boleto, bank_transfer
// dLocal currencies: BRL, MXN, ARS, CLP, COP, PEN, UYU, etc.

// Paystack supports: card, bank_transfer, wallet
// Paystack currencies: NGN, GHS, ZAR, KES, USD
Symptom: VaultRoutingError with code ROUTING_PROVIDER_EXCLUDED.Cause: The request specifies both routing.provider and routing.exclude, and the forced provider appears in the exclude list.Solution: Do not exclude a provider that you are explicitly forcing:
// This will throw ROUTING_PROVIDER_EXCLUDED:
await vault.charge({
  // ...
  routing: {
    provider: 'stripe',
    exclude: ['stripe'], // conflict
  },
});

// Fix: remove the exclusion or remove the override

Idempotency Conflicts

Symptom: VaultIdempotencyConflictError with code IDEMPOTENCY_CONFLICT and message “Idempotency key was reused with a different payload.”Cause: A previous request with the same idempotencyKey was recorded with a different payload hash. The SDK hashes the operation type and the full request object to detect mismatches.Solution: Either reuse the exact same request payload for that key, or generate a new idempotency key:
// First call stores the result
await vault.charge({
  amount: 2500,
  currency: 'USD',
  paymentMethod: { type: 'card', token: 'tok_visa' },
  idempotencyKey: 'order-1001-v1',
});

// Same key, same payload -> returns cached result (OK)
await vault.charge({
  amount: 2500,
  currency: 'USD',
  paymentMethod: { type: 'card', token: 'tok_visa' },
  idempotencyKey: 'order-1001-v1',
});

// Same key, different amount -> throws IDEMPOTENCY_CONFLICT
await vault.charge({
  amount: 3000, // changed
  currency: 'USD',
  paymentMethod: { type: 'card', token: 'tok_visa' },
  idempotencyKey: 'order-1001-v1', // reused
});

// Fix: use a new key when the payload changes
await vault.charge({
  amount: 3000,
  currency: 'USD',
  paymentMethod: { type: 'card', token: 'tok_visa' },
  idempotencyKey: 'order-1001-v2', // new key
});
Symptom: A replayed request does not return the cached result and instead creates a new charge.Cause: The idempotency record expired. The default TTL is 24 hours. After expiration, the SDK treats the key as new.Solution: If you need a longer window, increase idempotency.ttlMs:
idempotency: {
  ttlMs: 7 * 24 * 60 * 60 * 1000, // 7 days
},
For production, implement a persistent IdempotencyStore (backed by Redis or a database) so that records survive process restarts. The default MemoryIdempotencyStore loses all records when the process exits.
Symptom: VaultConfigError: Idempotency store is missing required methods.Cause: A custom idempotency store was provided but does not implement all four required methods: get, set, delete, clearExpired.Solution: Ensure your store implements the full IdempotencyStore interface:
import type { IdempotencyRecord, IdempotencyStore } from '@vaultsaas/core';

class RedisIdempotencyStore implements IdempotencyStore {
  async get(key: string): Promise<IdempotencyRecord | null> { /* ... */ }
  async set(record: IdempotencyRecord): Promise<void> { /* ... */ }
  async delete(key: string): Promise<void> { /* ... */ }
  async clearExpired(): Promise<void> { /* ... */ }
}

Platform Connector Issues

Symptom: Warning log: “Platform routing unavailable. Falling back to local routing.” with cause ECONNREFUSED or ENOTFOUND.Cause: The platform API at platform.baseUrl is unreachable, or the URL is incorrect.Solution:
  • Verify the platformApiKey and platform.baseUrl values.
  • Check network connectivity and firewall rules from the application host.
  • The SDK gracefully falls back to local routing rules when the platform is unreachable, so charges will continue to work.
Symptom: Charges complete successfully but routing.source is "local" instead of "platform", and you see timeout warnings in logs.Cause: The platform routing request exceeded platform.timeoutMs (default varies). The SDK falls back to local routing silently.Solution: Tune platform.timeoutMs based on network latency to the platform API. Keep it low (e.g., 100-300 ms) to avoid adding latency to the payment critical path:
platform: {
  timeoutMs: 150,    // low timeout for routing decisions
  batchSize: 100,    // report batch size
  flushIntervalMs: 2000,
  maxRetries: 2,
  initialBackoffMs: 100,
},
Symptom: Charges continue to process after the platform goes down, but routing decisions differ from expectations.Cause: This is expected behavior. When the platform connector is unavailable or times out, the SDK falls back to evaluating local routing rules defined in your config.Solution: Always maintain a complete set of local routing rules as a fallback. Check result.routing.source to monitor whether charges are being routed by the platform ("platform") or locally ("local"):
const result = await vault.charge(request);
if (result.routing.source === 'local') {
  console.warn('Charge was routed locally (platform may be unavailable)');
}
Symptom: VaultConfigError: platformApiKey cannot be empty when provided.Cause: platformApiKey was set to an empty or whitespace-only string.Solution: Either provide a valid API key or omit platformApiKey entirely to disable the connector.

Common TypeScript Errors

Symptom: TypeScript error: “Property ‘number’ does not exist on type with card and token” (or similar).Cause: PaymentMethodInput is a discriminated union. When type is "card", the payload must be either { type: 'card', token: string } or { type: 'card', number: string, expMonth: number, expYear: number, cvc: string }. Mixing fields from both variants causes a type error.Solution: Use exactly one variant:
// Token-based card
const tokenCard: PaymentMethodInput = {
  type: 'card',
  token: 'pm_card_visa',
};

// Raw card details
const rawCard: PaymentMethodInput = {
  type: 'card',
  number: '4242424242424242',
  expMonth: 12,
  expYear: 2030,
  cvc: '123',
};
Symptom: TypeScript error on VaultConfig or VaultClient constructor, such as “Property ‘providers’ is missing in type…”Cause: The VaultConfig type requires providers with at least one entry, and each provider requires adapter and config.Solution: Provide all required fields:
import type { VaultConfig } from '@vaultsaas/core';

const config: VaultConfig = {
  providers: {
    stripe: {
      adapter: StripeAdapter,       // required: constructor function
      config: {                     // required: plain object
        apiKey: 'sk_test_...',
      },
      // optional fields:
      // priority: 1,
      // enabled: true,
    },
  },
  // routing is optional but recommended:
  routing: {
    rules: [{ match: { default: true }, provider: 'stripe' }],
  },
};
Symptom: Charges create payments with unexpected amounts (e.g., 25.00becomes25.00 becomes 2500.00).Cause: The amount field uses the smallest currency unit (cents for USD, kobo for NGN). This is a number type in the SDK, not a float.Solution: Always convert to the smallest unit before calling the SDK:
// $25.00 USD = 2500 cents
await vault.charge({ amount: 2500, currency: 'USD', /* ... */ });

// 500 NGN = 50000 kobo
await vault.charge({ amount: 50000, currency: 'NGN', /* ... */ });

// 1000 JPY = 1000 (JPY is zero-decimal)
await vault.charge({ amount: 1000, currency: 'JPY', /* ... */ });

Build and Import Issues

Symptom: ERR_REQUIRE_ESM, SyntaxError: Cannot use import statement in a module, or ReferenceError: exports is not defined.Cause: @vaultsaas/core ships both ESM (.js) and CJS (.cjs) builds via the exports field in package.json. The wrong format can be loaded if your project’s module system configuration conflicts.Solution:
  • ESM project ("type": "module" in your package.json): imports resolve to dist/index.js automatically. No changes needed.
  • CJS project (no "type": "module"): imports resolve to dist/index.cjs automatically via the require condition.
  • Mixed project: ensure your bundler respects the exports map. For TypeScript, set "moduleResolution": "Bundler" or "NodeNext" in tsconfig.json.
If you are using ts-node, set "esm": true in tsconfig.json or use tsx instead:
# Instead of ts-node
npx tsx quickstart.ts
Symptom: SyntaxError on optional chaining, nullish coalescing, or other ES2022+ syntax. Or fetch is not defined.Cause: The SDK targets ES2022 and requires Node.js 20 or later. Node 18 is missing native fetch support in some builds; Node 16 and earlier lack required syntax support.Solution: Upgrade to Node.js 20+:
node --version
# Must be v20.0.0 or higher

# If using nvm:
nvm install 20
nvm use 20
The SDK relies on the built-in fetch API available in Node.js 18+ (experimental) and Node.js 20+ (stable).
Symptom: Type errors on SDK types that work on other machines, or “cannot find module” errors.Cause: The SDK uses TypeScript 5.x features. Older TypeScript versions may fail to resolve the exports map or parse newer type syntax.Solution: Use TypeScript 5.0 or later and set appropriate tsconfig.json options:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

Payment-Specific Issues

Symptom: VaultProviderError with code CARD_DECLINED. The context object includes declineCode and providerMessage.Cause: The issuing bank declined the card. Common reasons include insufficient funds, incorrect card details, or fraud prevention.Solution: Present a user-friendly error based on the decline code, and ask the customer to try a different card:
try {
  await vault.charge(request);
} catch (error) {
  if (error instanceof VaultError && error.code === 'CARD_DECLINED') {
    console.log('Decline code:', error.context.declineCode);
    console.log('Provider message:', error.context.providerMessage);
    // Show: "Your card was declined. Please try another payment method."
  }
}
Common Stripe test cards for simulating declines:
  • 4000000000000002 — generic decline
  • 4000000000009995 — insufficient funds
  • 4000000000009987 — do not honor
Symptom: VaultProviderError with code AUTHENTICATION_REQUIRED, or the PaymentResult has status: 'requires_action'.Cause: The card issuer requires Strong Customer Authentication (SCA / 3D Secure). This is common in Europe and increasingly elsewhere.Solution: When you receive status: 'requires_action', redirect the customer to complete authentication. The exact flow depends on your frontend integration:
const result = await vault.charge(request);

if (result.status === 'requires_action') {
  // The provider metadata may include a redirect URL or client secret
  // Use it to trigger 3DS on the frontend
  console.log('3DS required:', result.providerMetadata);
}
For Stripe, handle requires_action by using stripe.confirmCardPayment() on the client side with the PaymentIntent client secret.
Symptom: VaultProviderError with code FRAUD_SUSPECTED.Cause: The provider’s fraud detection system flagged the transaction.Solution: Do not retry automatically. Route the payment through manual review, or ask the customer to verify their identity:
if (error instanceof VaultError && error.code === 'FRAUD_SUSPECTED') {
  // Do NOT retry -- flag for manual review
  await flagForFraudReview(request, error.context);
}
Symptom: Provider rejects the request with an invalid currency error, or the amount is interpreted incorrectly.Cause: The SDK expects ISO 4217 currency codes in uppercase (e.g., "USD", "BRL", "NGN"). Lowercase values are normalized internally by some adapters, but passing non-ISO codes will fail.Solution: Always use uppercase ISO 4217 codes:
// Correct
await vault.charge({ amount: 2500, currency: 'USD', /* ... */ });

// Also correct (adapters normalize case internally)
await vault.charge({ amount: 2500, currency: 'usd', /* ... */ });

// Wrong -- not a valid ISO 4217 code
await vault.charge({ amount: 2500, currency: 'dollars', /* ... */ });
Remember that amount is always in the smallest currency unit:
  • USD: cents (100 = $1.00)
  • BRL: centavos (100 = R$1.00)
  • JPY: yen (1 = 1 yen, zero-decimal currency)
  • NGN: kobo (100 = 1 NGN)
Symptom: VaultProviderError with code RATE_LIMITED. The error has retriable: true.Cause: The provider returned HTTP 429 or a rate-limit message. Too many requests were sent in a short period.Solution: Implement exponential backoff. The SDK marks this error as retriable:
if (error instanceof VaultError && error.retriable) {
  // Queue for retry with exponential backoff
  await retryWithBackoff(() => vault.charge(request));
}
Symptom: VaultNetworkError with code PROVIDER_TIMEOUT. The error has retriable: true.Cause: The provider did not respond within the adapter’s timeout window (default: 15 seconds).Solution:
  • Retry the request (the SDK marks this as retriable).
  • If timeouts are frequent, increase the adapter’s timeoutMs:
config: {
  apiKey: process.env.STRIPE_API_KEY!,
  timeoutMs: 30_000, // increase from default 15s
},
  • Investigate if the provider is experiencing an outage.

Error Code Quick Reference

CodeCategoryRetriableCommon Cause
INVALID_CONFIGURATIONconfiguration_errorNoMissing or invalid config values
PROVIDER_NOT_CONFIGUREDconfiguration_errorNoProvider referenced but not in config
PROVIDER_AUTH_FAILEDconfiguration_errorNoWrong API key or expired credentials
NO_ROUTING_MATCHrouting_errorNoNo rule matched the request
ROUTING_PROVIDER_EXCLUDEDrouting_errorNoForced provider is in exclude list
ROUTING_PROVIDER_UNAVAILABLErouting_errorNoProvider not configured or disabled
INVALID_REQUESTinvalid_requestNoBad request fields or provider validation failure
IDEMPOTENCY_CONFLICTinvalid_requestNoSame key, different payload
WEBHOOK_SIGNATURE_INVALIDinvalid_requestNoWrong secret or tampered body
CARD_DECLINEDcard_declinedNoIssuer declined the card
AUTHENTICATION_REQUIREDauthentication_requiredNo3DS or SCA challenge needed
FRAUD_SUSPECTEDfraud_suspectedNoProvider fraud check flagged transaction
RATE_LIMITEDrate_limitedYesToo many requests to provider
NETWORK_ERRORnetwork_errorYesConnection refused, DNS failure, etc.
PROVIDER_TIMEOUTnetwork_errorYesProvider did not respond in time
PLATFORM_UNREACHABLEnetwork_errorYesVaultSaaS platform API unreachable
PROVIDER_ERRORprovider_errorYesProvider 5xx or transient failure
PROVIDER_UNKNOWNunknownNoUnmapped provider error

General Debugging Tips

1

Enable debug logging

Enable debug logging to see routing decisions, provider calls, and platform connector activity:
const vault = new VaultClient({
  // ...
  logging: {
    level: 'debug',
    logger: console,
  },
});
2

Inspect the full error object

Every VaultError includes code, category, suggestion, docsUrl, retriable, and context. Log all of them:
catch (error) {
  if (error instanceof VaultError) {
    console.error({
      code: error.code,
      category: error.category,
      retriable: error.retriable,
      suggestion: error.suggestion,
      docsUrl: error.docsUrl,
      context: error.context,
    });
  }
}
3

Check routing metadata

Check result.routing.reason on successful charges to verify the correct rule matched and the expected provider was selected.
4

Use idempotency keys

Use idempotency keys for all charge and authorize requests to prevent duplicate payments during retries.
5

Test webhooks locally

Test webhooks locally with tools like the Stripe CLI (stripe listen --forward-to localhost:3000/webhooks/stripe) or ngrok.

Build docs developers (and LLMs) love