RemitSandbox

RemitSandbox is a simulated international remittance API. Use it to build and test an integration against a realistic provider before connecting to a live system. All data is ephemeral and for testing only.

Overview

Base URL https://remit.palashonanton.in Protocol HTTPS only Format application/json for all request and response bodies Authentication None required in sandbox Rate limit 1,000 requests per minute per IP address

ID format

All resource IDs are prefixed strings of the form prefix_xxxxxxxx (e.g. cus_3a1b2c4d, ben_9f0e1a2b, trf_5c6d7e8f). Always treat them as opaque strings.

Errors

All errors use a consistent envelope. HTTP status codes follow standard conventions: 400 for malformed requests, 404 for unknown resources, 422 for semantic validation failures, 429 for rate-limit hits.

Error shape
{
  "error": {
    "code":    "string — machine-readable error identifier",
    "message": "string — human-readable explanation",
    "field":   "string — present when the error relates to a specific field"
  }
}
Common error codes
codeHTTP statusMeaning
invalid_json400Request body is not valid JSON
missing_field422A required field was omitted
unknown_field422An unrecognised field was sent
validation_error422A field value failed a rule
country_not_supported422Only US customers are accepted
invalid_ifsc422IFSC code is not 11 characters
invalid_currency422Only USD is accepted
amount_exceeds_limit422Transfer amount above maximum
beneficiary_ownership422Beneficiary belongs to a different customer
not_found404Resource ID does not exist
rate_limit_exceeded429Too many requests from this IP

Health

GET/health

Returns {"ok": true} when the service is up. No authentication required.

Response 200
{"ok": true}

Customers

A Customer represents the sender — a person in the United States initiating remittances. Only US-based senders are supported in this sandbox.

POST/customers

Create a new customer. The country field must be "US".

Request fields
FieldTypeDescription
first_namestringrequiredLegal first name
last_namestringrequiredLegal last name
emailstringrequiredValid email address (e.g. alice@example.com)
phonestringrequiredPhone number in E.164 format (e.g. +12025550182)
countrystringrequiredISO 3166-1 alpha-2 country code. Must be "US".
Example request
{
  "first_name": "Alice",
  "last_name":  "Sharma",
  "email":      "alice@example.com",
  "phone":      "+12025550182",
  "country":    "US"
}
Response 201
{
  "id":         "cus_3a1b2c4d",
  "first_name": "Alice",
  "last_name":  "Sharma",
  "email":      "alice@example.com",
  "phone":      "+12025550182",
  "country":    "US",
  "created_at": "2026-01-15T10:30:00.000000+00:00"
}
GET/customers/{id}

Retrieve a customer by ID. Returns 404 if the ID is unknown.

Response 200
{
  "id":         "cus_3a1b2c4d",
  "first_name": "Alice",
  "last_name":  "Sharma",
  "email":      "alice@example.com",
  "phone":      "+12025550182",
  "country":    "US",
  "created_at": "2026-01-15T10:30:00.000000+00:00"
}

Beneficiaries

A Beneficiary is a recipient bank account in India, linked to a specific customer. Each beneficiary must be associated with an existing customer.

POST/beneficiaries

Add a beneficiary for a customer. Returns 404 if customer_id does not exist.

Request fields
FieldTypeDescription
customer_idstringrequiredID of the owning customer
namestringrequiredFull name of the account holder
account_nostringrequiredBank account number at the destination
ifscstringrequired11-character IFSC code identifying the destination branch
bank_namestringrequiredName of the destination bank
Example request
{
  "customer_id": "cus_3a1b2c4d",
  "name":        "Ramesh Kumar",
  "account_no":  "9876543210",
  "ifsc":        "HDFC0001234",
  "bank_name":   "HDFC Bank"
}
Response 201
{
  "id":          "ben_9f0e1a2b",
  "customer_id": "cus_3a1b2c4d",
  "name":        "Ramesh Kumar",
  "account_no":  "9876543210",
  "ifsc":        "HDFC0001234",
  "bank_name":   "HDFC Bank",
  "created_at":  "2026-01-15T10:31:00.000000+00:00"
}
GET/beneficiaries/{id}

Retrieve a beneficiary by ID. Returns 404 if the ID is unknown.

Response 200
{
  "id":          "ben_9f0e1a2b",
  "customer_id": "cus_3a1b2c4d",
  "name":        "Ramesh Kumar",
  "account_no":  "9876543210",
  "ifsc":        "HDFC0001234",
  "bank_name":   "HDFC Bank",
  "created_at":  "2026-01-15T10:31:00.000000+00:00"
}

Transfers

A Transfer moves USD from a customer's account to a linked beneficiary in India. The payout is calculated at a fixed exchange rate and expressed in INR.

Transfers are processed asynchronously. After creation the status will be PROCESSING for a short period, then settle to either COMPLETED or FAILED. Poll GET /transfers/{id} to track progress.

POST/transfers

Initiate a new transfer. The beneficiary must belong to the given customer. Returns 404 if customer_id or beneficiary does not exist.

Request fields
FieldTypeDescription
customer_idstringrequiredID of the sending customer
beneficiarystringrequiredID of the destination beneficiary
amountnumberrequiredTransfer amount in USD. Must be > 0 and ≤ 10,000.
currencystringrequiredMust be "USD"
referencestringrequiredYour internal reference string (not validated)
Example request
{
  "customer_id":  "cus_3a1b2c4d",
  "beneficiary":  "ben_9f0e1a2b",
  "amount":       250.00,
  "currency":     "USD",
  "reference":    "invoice-2026-042"
}
Response 201
{
  "id":                "trf_5c6d7e8f",
  "status":            "PROCESSING",
  "customer_id":       "cus_3a1b2c4d",
  "beneficiary_id":    "ben_9f0e1a2b",
  "amount":            250.0,
  "currency":          "USD",
  "reference":         "invoice-2026-042",
  "fx_rate":           83.5,
  "payout_amount_inr": 20875.0,
  "created_at":        "2026-01-15T10:32:00.000000+00:00"
}

Test trigger

Any transfer whose amount has a cents value of exactly .13 (e.g. 100.13, 250.13, 0.13) will always settle as FAILED with failure_reason: "Beneficiary bank rejected the transfer". Use this to reliably exercise your FAILED-transfer handling without waiting for the random ~10% failure rate.

GET/transfers/{id}

Retrieve the current status of a transfer. Poll this endpoint after creation to detect when processing is complete.

Status values

StatusMeaning
PROCESSING Transfer has been received and is being processed
COMPLETED Funds have been delivered to the beneficiary
FAILED Transfer could not be completed; see failure_reason
Response 200 — still processing
{
  "id":                "trf_5c6d7e8f",
  "status":            "PROCESSING",
  "customer_id":       "cus_3a1b2c4d",
  "beneficiary_id":    "ben_9f0e1a2b",
  "amount":            250.0,
  "currency":          "USD",
  "reference":         "invoice-2026-042",
  "fx_rate":           83.5,
  "payout_amount_inr": 20875.0,
  "created_at":        "2026-01-15T10:32:00.000000+00:00"
}
Response 200 — completed
{
  "id":                "trf_5c6d7e8f",
  "status":            "COMPLETED",
  "customer_id":       "cus_3a1b2c4d",
  "beneficiary_id":    "ben_9f0e1a2b",
  "amount":            250.0,
  "currency":          "USD",
  "reference":         "invoice-2026-042",
  "fx_rate":           83.5,
  "payout_amount_inr": 20875.0,
  "created_at":        "2026-01-15T10:32:00.000000+00:00"
}
Response 200 — failed
{
  "id":                "trf_5c6d7e8f",
  "status":            "FAILED",
  "customer_id":       "cus_3a1b2c4d",
  "beneficiary_id":    "ben_9f0e1a2b",
  "amount":            250.0,
  "currency":          "USD",
  "reference":         "invoice-2026-042",
  "fx_rate":           83.5,
  "payout_amount_inr": 20875.0,
  "failure_reason":    "Beneficiary bank rejected the transfer",
  "created_at":        "2026-01-15T10:32:00.000000+00:00"
}

Starter Code

Save this file as adapter.py and implement every method marked TODO so that test_adapter.py passes. Read the docs above carefully — some request field names differ from the parameter names in the method signatures.

adapter.py
"""
RemitSandbox adapter — starter template.

Your task: implement every method marked TODO so that test_adapter.py passes.

    python test_adapter.py --skip-wait   # fast feedback loop
    python test_adapter.py               # full run including settlement (~65 s)

Read the API documentation at https://remit.palashonanton.in before you start.
Pay close attention to the request field names and response shapes.

Dependencies:
    pip install requests
"""

import time
from typing import Optional

import requests  # https://requests.readthedocs.io


BASE_URL = "https://remit.palashonanton.in"


class RemitSandboxError(Exception):
    """
    Raised whenever the RemitSandbox API returns a non-2xx response.

    Attributes:
        status_code  HTTP status code (e.g. 404, 422)
        code         Machine-readable error identifier from the response body
        message      Human-readable explanation from the response body
        field        The request field that caused the error, if any (may be None)

    Example error response body:
        {
          "error": {
            "code":    "not_found",
            "message": "Customer 'cus_abc123' not found.",
            "field":   "customer_id"
          }
        }
    """

    def __init__(
        self,
        status_code: int,
        code: str,
        message: str,
        field: Optional[str] = None,
    ):
        self.status_code = status_code
        self.code = code
        self.message = message
        self.field = field
        detail = f" (field: {field})" if field else ""
        super().__init__(f"HTTP {status_code} [{code}] {message}{detail}")


class RemitSandboxAdapter:
    """
    Adapter for the RemitSandbox API.

    All methods raise RemitSandboxError on API errors; let the caller decide
    whether to retry or surface the error.

    Usage:
        adapter = RemitSandboxAdapter()
        customer    = adapter.create_customer("Alice", "Sharma", "alice@example.com", "+12025550182")
        beneficiary = adapter.create_beneficiary(customer["id"], "Ramesh", "9876543210", "HDFC0001234", "HDFC Bank")
        transfer    = adapter.create_transfer(customer["id"], beneficiary["id"], 250.0)
        result      = adapter.poll_transfer(transfer["id"])
        print(result["status"])   # "SUCCESS" or "FAILED"
    """

    def __init__(self, base_url: str = BASE_URL):
        self.base_url = base_url.rstrip("/")
        # TODO: create a requests.Session and configure default headers.
        #       All requests and responses use JSON; set Content-Type accordingly.
        raise NotImplementedError("TODO: initialise the HTTP session")

    # ── Internal helper ───────────────────────────────────────────────────────

    def _request(self, method: str, path: str, **kwargs) -> dict:
        """
        Send an HTTP request to `self.base_url + path`.
        Return the parsed JSON body on success (2xx).
        Raise RemitSandboxError on any non-2xx response.

        The error envelope is always:
            {"error": {"code": "...", "message": "...", "field": "..."}}
        where "field" may be absent.
        """
        # TODO: make the request, parse the response, raise on errors
        raise NotImplementedError("TODO: implement _request")

    # ── Customers ─────────────────────────────────────────────────────────────

    def create_customer(
        self,
        first_name: str,
        last_name: str,
        email: str,
        phone: str,
        country: str = "US",
    ) -> dict:
        """
        Create a new customer. Returns the created customer dict.

        The response includes at minimum: id, first_name, last_name, email,
        phone, country, created_at.

        Raises RemitSandboxError on validation failure (e.g. unsupported country,
        invalid email or phone format).
        """
        # TODO: POST /customers
        raise NotImplementedError("TODO: implement create_customer")

    def get_customer(self, customer_id: str) -> dict:
        """
        Retrieve a customer by ID.

        Raises RemitSandboxError(code="not_found") if the ID does not exist.
        """
        # TODO: GET /customers/{customer_id}
        raise NotImplementedError("TODO: implement get_customer")

    # ── Beneficiaries ─────────────────────────────────────────────────────────

    def create_beneficiary(
        self,
        customer_id: str,
        name: str,
        account_number: str,
        ifsc: str,
        bank_name: str,
    ) -> dict:
        """
        Add a beneficiary linked to the given customer.
        Returns the created beneficiary dict.

        Raises RemitSandboxError on validation failure (e.g. IFSC wrong length,
        customer not found).

        Hint: read the API docs carefully — one of the request field names
        may not match the parameter name in this method signature.
        """
        # TODO: POST /beneficiaries
        raise NotImplementedError("TODO: implement create_beneficiary")

    def get_beneficiary(self, beneficiary_id: str) -> dict:
        """
        Retrieve a beneficiary by ID.

        Raises RemitSandboxError(code="not_found") if the ID does not exist.
        """
        # TODO: GET /beneficiaries/{beneficiary_id}
        raise NotImplementedError("TODO: implement get_beneficiary")

    # ── Transfers ─────────────────────────────────────────────────────────────

    def create_transfer(
        self,
        customer_id: str,
        beneficiary_id: str,
        amount: float,
        currency: str = "USD",
        reference: Optional[str] = None,
    ) -> dict:
        """
        Initiate a transfer. Returns the transfer dict with status "PROCESSING".

        The response includes: id, status, customer_id, beneficiary_id, amount,
        currency, reference, fx_rate, payout_amount_inr, created_at.

        Raises RemitSandboxError on validation failure (e.g. amount out of range,
        currency not supported, beneficiary ownership mismatch).

        Hint: check the API docs for the exact field names in the request body —
        one of them may differ from the parameter name in this signature.
        If reference is None, generate a unique string of your choice.
        """
        # TODO: POST /transfers
        raise NotImplementedError("TODO: implement create_transfer")

    def get_transfer(self, transfer_id: str) -> dict:
        """
        Retrieve the current state of a transfer.

        The "status" field will be one of:
          "PROCESSING"  — not yet settled, keep polling
          "SUCCESS"     — funds delivered successfully
          "FAILED"      — failed; check the "failure_reason" field

        Note: the docs may show a different value for the success status —
        trust what the API actually returns.

        Raises RemitSandboxError(code="not_found") if the ID does not exist.
        """
        # TODO: GET /transfers/{transfer_id}
        raise NotImplementedError("TODO: implement get_transfer")

    def poll_transfer(
        self,
        transfer_id: str,
        max_wait: int = 120,
        interval: int = 5,
    ) -> dict:
        """
        Poll GET /transfers/{id} every `interval` seconds until the status
        is no longer "PROCESSING", then return the final transfer dict.

        Raises TimeoutError if the transfer has not settled within `max_wait` seconds.

        Hint:
            deadline = time.time() + max_wait
            while time.time() < deadline:
                ...
                time.sleep(interval)
            raise TimeoutError(...)
        """
        # TODO: implement the polling loop using get_transfer()
        raise NotImplementedError("TODO: implement poll_transfer")