Skip to content

Synthetic merchant phone verification

The optional sandbox module records synthetic contact verification for a merchant Owner. It does not send real SMS or establish real phone ownership. phone_verified stays false, including when the local status is SYNTHETIC_VERIFIED. The historical enrollment receipt also stays unchanged. These commands never approve KYB, activate live payments, change permissions or move money.

Every route requires the server-held merchant identity JWT with verified email, required password and OTP assurance, and a lifetime of at most 15 minutes. The current membership must be Owner. Supply X-Futa-Membership-Version on reads and writes. Another subject, tenant, revoked Owner or changed membership epoch cannot consume the original proof. Keep these JWTs in the backend/BFF; never expose them to browser JavaScript.

Operation Route
Read current status GET /portal/merchants/{merchant}/phone-verification
Start a challenge POST /portal/merchants/{merchant}/phone-verification/challenges
Verify a challenge POST /portal/merchants/{merchant}/phone-verification/challenges/{id}/verify

Start with { "phone": "+243810000001" } only when that synthetic number is explicitly configured in your isolated fixture. The service returns a challenge ID, masked number and expiry. The random code and full number are encrypted in the synthetic delivery outbox; there is no public code or delivery endpoint. Local acceptance consumes that outbox using its private fixture key. A real SMS adapter requires a separate provider agreement and implementation.

Verify with a six-digit string in code. This is a Futa contact code, never an issuer PIN. A challenge lasts five minutes and locks after five distinct failed verification commands. Issuance has a sixty-second phone/merchant-subject cooldown and rolling limits of five per phone, ten per subject and ten per merchant per hour. New issuance supersedes the previous current challenge for that Owner. Starting or reading a challenge does not confirm ownership.

Preserve one Idempotency-Key and the exact body for each command. An ambiguous start or verification response may already have committed; retry that same command after a network failure or restart. A replay returns the original bytes, while GET shows current status. Repeating a failed code with its original key does not consume another attempt. Changing the code, phone or target while reusing a retained command key conflicts. Fresh-key rejections of already terminal challenges are read-only and do not reserve that key. Never reuse an old successful receipt as proof for a new phone; inspect current status and challenge ID. Nothing retries for you.

The same call in Futa’s generated TypeScript client, kept for its own use and not published:

const pending = await merchantPhone.startMerchantPhoneVerification({
merchant,
xFutaMembershipVersion: currentMembershipVersion,
idempotencyKey: retainedStartKey,
merchantPhoneStart: { phone: configuredSyntheticNumber },
});
const result = await merchantPhone.verifyMerchantPhone({
merchant,
xFutaMembershipVersion: currentMembershipVersion,
id: pending.challengeId!,
idempotencyKey: retainedVerificationKey,
merchantPhoneVerify: { code: enteredContactCode },
});

Requests use strict JSON of at most 1,024 UTF-8 bytes. Duplicate/unknown fields, coerced numbers, queries, caller identity headers and encoded/chunked bodies are rejected. GET has no body. All responses are private no-store; do not log codes, phone inputs, tokens or request bodies. Clear the entered code after submission or backgrounding; UI lifecycle integration is a separate slice.

A wrong code returns 400 and commits its attempt; 410 means expired; 409 covers changed authority, conflicting keys and unavailable challenges; 429 requires waiting for the issuance budget. Restore current authorized access for 401/403. A 404 may mean the module is disabled, the challenge is absent, or it belongs to another tenant.

Deployment defaults to disabled. Enable only in an owned sandbox with FUTA_MERCHANT_PHONE_ENABLED=true, independent 64-hex-character FUTA_MERCHANT_PHONE_WRAPPING_KEY and FUTA_MERCHANT_PHONE_HMAC_KEY, and an explicit comma-separated FUTA_MERCHANT_PHONE_SYNTHETIC_PHONES allowlist (1–10 numbers). Retain the same keys across restarts; store them outside the database. This foundation provides no production key rotation or contact-recovery policy. A production SMS adapter, real contact ownership, signup/recovery abuse controls and portal acceptance remain separate gates.