Skip to main content

Resolving 'Invalid Country Code' & Data Validation Errors in Payoneer API

 Few things destroy developer velocity like a generic 400 Bad Request from a financial API. You have authenticated successfully, structured your JSON payload, and initiated the POST request, only to receive an opaque validation error.

When integrating with the Payoneer Mass Payouts or Onboarding API, the most common friction point is strict location data compliance. Sending "USA" instead of "US", or exceeding address line character limits, will immediately trigger rejection errors such as INVALID_COUNTRY or SchemaValidationException.

This guide provides a rigorous, frontend-first solution to normalize geographical data and enforce schema validation before your request ever touches the network.

The Root Cause: ISO 3166 Standards and Banking Legacy

To fix the error, you must understand the constraints of the receiving system. Financial institutions do not parse geographical data loosely; they adhere to strict international standards for Anti-Money Laundering (AML) and Know Your Customer (KYC) compliance.

Payoneer, like most payment processors, requires the ISO 3166-1 alpha-2 format for country codes.

The Mismatch

Most frontend UI libraries or autocomplete components return one of the following:

  • Full Name: "United States"
  • Alpha-3 Code: "USA"

However, the Payoneer API strictly expects:

  • Alpha-2 Code: "US"

If you send the following JSON payload, the API will reject it:

// ❌ WRONG: Causes 400 Bad Request
{
  "payeeId": "12345",
  "address": {
    "country": "USA", 
    "city": "New York"
  }
}

The payload must be normalized to:

// ✅ CORRECT
{
  "payeeId": "12345",
  "address": {
    "country": "US",
    "city": "New York"
  }
}

Additionally, address lines often have strict character limits (typically 30–60 characters) to fit legacy banking wire formats.

The Solution: Zod Validation and ISO Normalization

We will implement a robust solution using ReactTypeScriptZod (for schema validation), and i18n-iso-countries (for data normalization). This ensures valid data structure and correct codes are guaranteed prior to the API call.

Step 1: Install Dependencies

We need utilities to handle the heavy lifting of ISO conversion and schema enforcement.

npm install zod react-hook-form @hookform/resolvers i18n-iso-countries
npm install --save-dev @types/i18n-iso-countries

Step 2: Create the Country Normalizer

Do not write your own switch statement for country codes. It is unmaintainable. Use i18n-iso-countries to accept various inputs (names, alpha-3) and output the required alpha-2 format.

// utils/location-helpers.ts
import countries from 'i18n-iso-countries';
import enLocale from 'i18n-iso-countries/langs/en.json';

// Register locale to support full name parsing (e.g., "Germany")
countries.registerLocale(enLocale);

/**
 * Normalizes input to ISO 3166-1 alpha-2 (e.g., "United States" -> "US")
 * Returns undefined if invalid, allowing Zod to catch the error.
 */
export const normalizeCountryCode = (input: string): string | undefined => {
  if (!input) return undefined;

  // clean input
  const cleanInput = input.trim();

  // If already 2 chars, validate it exists
  if (cleanInput.length === 2) {
    return countries.isValid(cleanInput) ? cleanInput.toUpperCase() : undefined;
  }

  // Attempt to convert from Alpha-3 (USA) or Name (United States) to Alpha-2 (US)
  const alpha2 = countries.getAlpha2Code(cleanInput, 'en');
  
  return alpha2; // Returns "US" or undefined
};

Step 3: Define the Zod Schema

We create a schema that mirrors Payoneer's constraints. We use z.preprocess to run our normalizer before validation occurs. This allows the user to type "USA" while the system validates "US".

// schemas/payee-schema.ts
import { z } from 'zod';
import { normalizeCountryCode } from '../utils/location-helpers';

export const PayeeAddressSchema = z.object({
  addressLine1: z
    .string()
    .min(1, "Address is required")
    .max(60, "Address line must be 60 characters or less") // Payoneer specific limit
    .regex(/^[\x20-\x7E]*$/, "Address contains invalid characters"), // ASCII only check

  city: z.string().min(1, "City is required"),

  zipCode: z.string().min(1, "Zip code is required"),

  country: z.preprocess(
    (val) => (typeof val === 'string' ? normalizeCountryCode(val) : val),
    z.string({ required_error: "Country is required" })
      .length(2, "Invalid country code. System requires ISO Alpha-2 (e.g., US)")
  ),
});

export type PayeeAddressPayload = z.infer<typeof PayeeAddressSchema>;

Step 4: Implement the Form

Here is the React component integrating react-hook-form. This component handles the user input, normalizes it automatically via the Zod resolver, and prepares a clean payload for the API.

// components/PayoneerOnboardingForm.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { PayeeAddressSchema, type PayeeAddressPayload } from '../schemas/payee-schema';

export const PayoneerOnboardingForm = () => {
  const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<PayeeAddressPayload>({
    resolver: zodResolver(PayeeAddressSchema),
    defaultValues: {
      country: '', 
      addressLine1: '',
    }
  });

  const onSubmit = async (data: PayeeAddressPayload) => {
    setStatus('submitting');
    
    try {
      // The 'data.country' here is GUARANTEED to be ISO Alpha-2 (e.g., "US")
      // thanks to the Zod pre-processor.
      
      const response = await fetch('/api/proxy/payoneer/payee', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          address: data
        }),
      });

      if (!response.ok) throw new Error('API Request Failed');
      
      setStatus('success');
      console.log('Payload sent successfully:', data);
      
    } catch (error) {
      console.error(error);
      setStatus('error');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto p-4 border rounded">
      <div>
        <label className="block text-sm font-medium">Address Line 1</label>
        <input 
          {...register('addressLine1')} 
          className="mt-1 block w-full border p-2 rounded" 
          placeholder="123 Market St"
        />
        {errors.addressLine1 && <p className="text-red-500 text-xs mt-1">{errors.addressLine1.message}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium">Country</label>
        <input 
          {...register('country')} 
          className="mt-1 block w-full border p-2 rounded" 
          placeholder="United States, USA, or US"
        />
        <p className="text-gray-500 text-xs mt-1">Accepts full names or codes.</p>
        {errors.country && <p className="text-red-500 text-xs mt-1">{errors.country.message}</p>}
      </div>

      <div className="grid grid-cols-2 gap-4">
        <div>
          <label className="block text-sm font-medium">City</label>
          <input {...register('city')} className="mt-1 block w-full border p-2 rounded" />
          {errors.city && <p className="text-red-500 text-xs mt-1">{errors.city.message}</p>}
        </div>
        <div>
          <label className="block text-sm font-medium">Zip Code</label>
          <input {...register('zipCode')} className="mt-1 block w-full border p-2 rounded" />
          {errors.zipCode && <p className="text-red-500 text-xs mt-1">{errors.zipCode.message}</p>}
        </div>
      </div>

      <button 
        type="submit" 
        disabled={status === 'submitting'}
        className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700 transition-colors"
      >
        {status === 'submitting' ? 'Validating...' : 'Submit to Payoneer'}
      </button>
    </form>
  );
};

Deep Dive: Why Zod preprocess is Critical

In the code above, the magic happens inside the Zod schema definition:

z.preprocess((val) => normalizeCountryCode(val), ...)

Standard validation libraries check if data is valid, but they rarely transform it during the validation phase. By using preprocess, we decouple the user input experience from the API requirements.

  1. User Experience: The user types "USA" (common mental model).
  2. Preprocessing: The function converts "USA" to "US".
  3. Validation: Zod checks if the result is exactly 2 uppercase letters.
  4. Output: The onSubmit handler receives strict, API-ready data.

This prevents the need for messy transformation logic inside your onSubmit handler or backend controllers.

Handling Edge Cases and Pitfalls

Even with country codes resolved, Payoneer APIs (and similar SOAP/REST XML-based legacy wrappers) often have other strict validation rules.

1. State/Province Codes (ISO 3166-2)

Payoneer often requires a specific State format for the US, Canada, and Australia.

  • Problem: Sending "New York" as the state for "US".
  • Requirement: Usually strictly 2-letter codes (e.g., "NY").
  • Fix: Extend the schema logic to cross-reference the Country code. If Country is "US", validate the State against a list of valid US state codes.

2. Character Set Encoding (UTF-8 vs ASCII)

Some banking rails still operate on mainframes that do not support extensive UTF-8 characters (like emojis or certain accents).

  • The Fix: Use the regex provided in the schema (/^[\x20-\x7E]*$/) to enforce printable ASCII characters if your specific endpoint requires it.

3. Address Line Separation

Payoneer provides addressLine1 and addressLine2.

  • Pitfall: Concatenating them into a single long string.
  • Impact: Truncation on the bank statement side.
  • Best Practice: Force the user to split strict apartment/suite numbers into the second field if strict limits apply.

Conclusion

Resolving INVALID_COUNTRY_CODE errors is rarely about fixing a typo; it is about bridging the gap between flexible user input and rigid financial compliance standards.

By moving the normalization logic into a Zod schema using preprocess, you create a self-documenting, type-safe validation layer. This ensures that your frontend application serves as a strict gatekeeper, preventing malformed data from ever wasting an API request quota.