Security

Overview

Xpend separates server-to-server API keys from merchant console users. API keys authenticate financial routes (/v1/payouts, payment intents, balances). Console users authenticate with email/password (and optional login TOTP) for configuration and credential management.

  • IP allowlists — optional per API key; empty list means no IP restriction.
  • Payout authenticator (TOTP) — 6-digit codes from an authenticator app for manual payouts when email OTP is off or as a fallback path.
  • Email OTP step-up — 6-digit codes emailed to the signed-in console user for high-risk mutations (enabled by default; set MERCHANT_SENSITIVE_EMAIL_OTP_ENABLED=false to disable).

API key IP allowlists

When creating or updating an API credential, you can attach an allowlist of IPv4 addresses, IPv6 addresses, or CIDR ranges. Requests authenticated with that key are rejected unless the client IP matches the list.

  • At most 50 entries per key.
  • Empty or omitted allowed_ips — no restriction (any egress IP allowed).
  • Passing an empty array on update clears restrictions.
  • IPv4-mapped IPv6 addresses are normalized before comparison.
POST/v1/merchant/api-credentialsCreate credential (optional allowed_ips)
PUT/v1/merchant/api-credentials/{credentialId}/ip-allowlistUpdate allowlist (?environment=test|live required)
FieldTypeDescription
allowed_ipsstring[]Example: ["203.0.113.10", "198.51.100.0/24"]. Omit or [] = unrestricted.
email_otp_codestring6-digit code. Required when setting a non-empty allowlist while email OTP is enabled.
curl "$XPEND_BASE_URL/v1/merchant/api-credentials" \
  -H "Authorization: Bearer $CONSOLE_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "environment": "live",
    "name": "Production backend",
    "scopes": ["payouts:create", "payouts:read", "balances:read"],
    "allowed_ips": ["203.0.113.10", "198.51.100.0/24"],
    "email_otp_code": "482910"
  }'

Egress IP planning

Record your production server egress IPs before go-live. Calls from unlisted IPs return API_KEY_IP_NOT_ALLOWED (403) even with a valid secret.

Runtime enforcement

IP checks run during API key verification on every financial request. The error is explicit so operators can fix allowlists without guessing:

{
  "error": {
    "code": "API_KEY_IP_NOT_ALLOWED",
    "message": "This API key is not allowed from your IP address. Update the key IP allowlist in the merchant console.",
    "type": "authentication_error"
  }
}

Email OTP for sensitive actions

When enabled (default), certain console mutations require a short-lived 6-digit code sent to the signed-in merchant user's email. API keys alone cannot send or consume these codes — use a console session (JWT or session cookie).

GET/v1/merchant/security/email-otp/statusCheck if email OTP is enabled
POST/v1/merchant/security/email-otp/sendSend code for a purpose

Purposes

FieldTypeDescription
payoutenumManual payout via POST /v1/payouts (email_otp_code field).
settlement_preferenceenumPUT /v1/merchant/settlement-preference
bank_accountenumPUT /v1/merchant/auto-fiat-payout-settings
api_key_ip_allowlistenumCreate credential or update IP allowlist with non-empty allowed_ips.

Typical flow

  1. GET /v1/merchant/security/email-otp/status — confirm email_otp_enabled.
  2. POST /v1/merchant/security/email-otp/send with { "purpose": "payout" } — user receives email; response includes expires_in_seconds (default TTL 10 minutes).
  3. Submit the protected mutation with email_otp_code in the JSON body.
# 1. Send code
curl "$XPEND_BASE_URL/v1/merchant/security/email-otp/send" \
  -H "Authorization: Bearer $CONSOLE_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "purpose": "payout" }'

# 2. Create payout with code from email
curl "$XPEND_BASE_URL/v1/payouts" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: payout_1001" \
  -d '{
    "asset": "usdc:ethereum",
    "amount": "125000000",
    "rail": "crypto",
    "destination_address": "0x1234...",
    "email_otp_code": "482910"
  }'

Email OTP vs payout TOTP

API key payouts via POST /v1/payouts do not require totp_code or email_otp_code. For merchant console (user JWT), when email OTP is enabled a valid email_otp_code satisfies payout authorization; otherwise enroll and use totp_code via /v1/merchant/payout-otp/*.

Email OTP error codes

  • OTP_REQUIRED (400) — missing or malformed 6-digit code.
  • OTP_INVALID (400) — wrong, expired, or already consumed code.
  • OTP_RATE_LIMITED (429) — send or verify rate limit exceeded.

Rate limits are per merchant user and purpose (defaults: 8 sends per hour, 15 verifies per 15 minutes). Configure via MERCHANT_SENSITIVE_EMAIL_OTP_SEND_* and MERCHANT_SENSITIVE_EMAIL_OTP_VERIFY_* environment variables.

Payout authenticator (TOTP)

Merchants can enroll a TOTP secret for payout authorization. Enrollment is console-only and requires payouts:create scope.

GET/v1/merchant/payout-otp/statusWhether TOTP is configured
POST/v1/merchant/payout-otp/enrollment/startBegin enrollment
POST/v1/merchant/payout-otp/enrollment/confirmConfirm with totp_code

Payout TOTP error codes

  • OTP_SETUP_REQUIRED (400) — payout attempted without TOTP enrollment (when email OTP is off).
  • OTP_REPLAY (400) — same TOTP code reused within the replay window.
  • PAYOUT_OTP_ALREADY_CONFIGURED (409) — enrollment started when already configured.

API credential scopes

Each API key is issued with an explicit scope list. Common scopes:

  • payment_intents:create, payment_intents:read
  • payouts:create, payouts:read
  • balances:read
  • merchant_config:read, merchant_config:write
  • api_keys:manage, users:manage
  • webhooks:read, webhooks:manage

Missing scopes return MERCHANT_SCOPE_DENIED (403). Credential management endpoints require console JWT with api_keys:manage — API keys cannot manage themselves.