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=falseto 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.
/v1/merchant/api-credentialsCreate credential (optional allowed_ips)/v1/merchant/api-credentials/{credentialId}/ip-allowlistUpdate allowlist (?environment=test|live required)| Field | Type | Description |
|---|---|---|
| allowed_ips | string[] | Example: ["203.0.113.10", "198.51.100.0/24"]. Omit or [] = unrestricted. |
| email_otp_code | string | 6-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
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).
/v1/merchant/security/email-otp/statusCheck if email OTP is enabled/v1/merchant/security/email-otp/sendSend code for a purposePurposes
| Field | Type | Description |
|---|---|---|
| payout | enum | Manual payout via POST /v1/payouts (email_otp_code field). |
| settlement_preference | enum | PUT /v1/merchant/settlement-preference |
| bank_account | enum | PUT /v1/merchant/auto-fiat-payout-settings |
| api_key_ip_allowlist | enum | Create credential or update IP allowlist with non-empty allowed_ips. |
Typical flow
GET /v1/merchant/security/email-otp/status— confirmemail_otp_enabled.POST /v1/merchant/security/email-otp/sendwith{ "purpose": "payout" }— user receives email; response includesexpires_in_seconds(default TTL 10 minutes).- Submit the protected mutation with
email_otp_codein 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
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.
/v1/merchant/payout-otp/statusWhether TOTP is configured/v1/merchant/payout-otp/enrollment/startBegin enrollment/v1/merchant/payout-otp/enrollment/confirmConfirm with totp_codePayout 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:readpayouts:create,payouts:readbalances:readmerchant_config:read,merchant_config:writeapi_keys:manage,users:managewebhooks: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.