WhatsApp Web endpoints¶
Prerequisites¶
- A test user with KYC passed preferable a whatsapp registered user. @shivi can help explain how to create.
- Access to the internal testing API on staging
Test Setup (shortcut WhatsApp bot)¶
1. Create transaction intent¶
POST https://stage-internaltesting.minority.com/v1/remittance/transaction-intent/submit/{userId}
{
"senderId": "{userId}",
"sourceAmount": 20,
"totalAmount": 20,
"countryCode": "US",
"currencyCode": "USD",
"channel": "WebTest",
"beneficiary": {
"firstName": "Test",
"lastName": "SelfFund"
}
}
Returns
transactionIntentId. This also triggers PaymentIdentifier creation in be-pspfunding via TransactionIntentCreatedEvent — processed asynchronously. Step 4 (exchange) reads this PaymentIdentifier from be-pspfunding; if the event has not been processed yet, the exchange fails and no token is issued. In practice the lag is negligible on staging.
2. Get 2FA token¶
POST https://stage-whatsapp.minority.com/v1/internal-testing/user/signin-app-token
{
"msisdn": "{user-phone-number}",
"whatsAppId": "{user-phone-number}"
}
Anonymous endpoint (no auth required).
msisdn and whatsAppId are the same value — the user's phone number in E.164 format (e.g. 12015282430).
Returns:
{
"userId": "{guid}",
"accessToken": "{2fa-token}"
}
Use
accessToken as the Authorization: Bearer token in Step 3.
3. Create transfer token¶
POST https://stage-whatsapp.minority.com/v1/auth/transfer-token
Authorization: Bearer {2fa-or-multifactor-token}
{
"intentId": "{transactionIntentId}"
}
Requires a valid WhatsApp 2FA or multi-factor authorization token (
[TwoFactorOrMultiFactorAuthorization]), obtained from Step 2. userId is read from the authenticated request context, not the body, so the caller cannot request a token for another user. The endpoint still validates via ValidatePendingIntent(userId, intentId) that the intent exists, belongs to the caller, and is in Pending status before issuing the transfer token.
Returns:
{
"transferToken": "{token-string}"
}
The returned transfer token carries
TokenIssuerType=WhatsApp, userId, and IntentId claims, and is consumed by majority-web's exchange endpoint in Step 4.
4. Exchange token¶
GET https://stage-majorityweb.minority.com/v1/auth/exchange-token?token={transferToken}
AllowAnonymous. Returns:
{
"token": "{majority-web-token}"
}
Use this as
Authorization: Bearer {token} for all remaining calls.
5. Get intent info¶
GET https://stage-majorityweb.minority.com/v1/remittance/intent
Authorization: Bearer {majority-web-token from Step 4}
Requires PaymentRequestV2-scoped token (same as SubmitCardPaidRemittance). The same exchanged majority-web-token from Step 4 works — the exchange embeds all three claims (userId, IntentId, PaymentIdentifier) atomically, so any token issued by Step 4 satisfies this scope. userId and intentId are read from the token claims (not from query params), so the caller cannot ask for another user's intent. Internally the endpoint proxies to ITransactionIntentService.GetPendingByIntentId, which validates that the intent exists, belongs to the user, and is still Pending — otherwise returns 404.
Response:
{
"transactionIntentId": "{guid}",
"details": {
"beneficiary": { "firstName": "Pedro", "lastName": "Rojas", ... },
"countryCode": "MX",
"currencyCode": "MXN",
"sourceAmount": 100.00,
"destinationAmount": 1846.12,
"fee": 0.00,
"totalAmount": 100.00,
"fxRateToken": "{guid}",
"exchangeRate": 18.46,
"expectedDeliveryTimeInMinutes": 60,
"termsUrl": "https://www.majority.com/en/terms-of-use/majority-payment-services/",
"license": "MAJORITY Payment Services, LLC",
"transactionType": "Bank",
"channel": "WhatsApp"
},
"transactionIntentDeepLink": "majority://send/international/{guid}",
"transactionType": "Bank"
}
Note:
exchangeRate,expectedDeliveryTimeInMinutes,license, andtermsUrlare not yet implemented — the current response contains only the fields stored on the intent (amount, currency, beneficiary, FX token, etc.). These fields are planned for a future backend ticket. Do not build against them until the ticket is done and this note is removed.
6. Submit card-paid remittance (includes OFAC checks)¶
POST https://stage-majorityweb.minority.com/v1/remittance/submit-card-paid
Authorization: Bearer {majority-web-token from Step 4}
Requires PaymentRequestV2-scoped token with claims userId, IntentId, PaymentIdentifier (all populated by Step 4 exchange). User reviews the remittance summary and presses "Continue". Core transaction data (amount, currency, payer, beneficiary, FX) is loaded from the intent — the web client does NOT resend it.
Request body — all fields optional. First-time submit is {}. Populate a field ONLY on the specific resubmit path that requires it. Every property carries a pure DELTA — only the fields the user just entered on the previous exception screen. be-remittance owns the authoritative Sender (from its DB) and Beneficiary (from the intent) and merges these non-null fields on top before validation.
{
"senderAdditionalInfo": { // ONLY after SenderOfacAdditionalDataRequiredException or SenderAdditionalInfoRequiredException
"dateOfBirth": "1985-06-20",
"cityOfBirth": "New York",
"nationality": "US",
"idType": "Passport",
"idNumber": "P1234567"
},
"beneficiaryAdditionalInfo": { // ONLY after BeneficiaryOfacAdditionalDataRequiredException or BeneficiaryAdditionalInfoRequiredException
"firstName": "Pedro", // only if the OFAC requirements list asked for name fields (new-beneficiary flow)
"lastName": "Rojas",
"dateOfBirth": "1990-01-15",
"nationality": "MX",
"cityOfBirth": "Mexico City"
},
"refreshedFxRateToken": "{guid}" // ONLY after RemittanceTransactionExchangeRateException
}
Note:
senderAdditionalInfodoes NOT acceptfirstName/lastName— the sender's identity is always authoritative onbe-remittance's Sender table. Only the fields listed inerrorData.accountRequirementsfrom the last response should be sent back.beneficiaryAdditionalInfomay carryfirstName/lastNameonly when the previous response explicitly asked for them (this happens for new inline beneficiaries where the intent stored no name yet).
Success response (200 OK):
{
"transactionId": "{guid}",
"pendingReview": false
}
-
pendingReview: false — OFAC passed, no manual review needed. Intent transitions directly to AwaitingCardPayment (6). PSP can charge card immediately.-
pendingReview: true — OFAC match found. Intent is now AwaitingOfacReview (5). PSP must hold card data until a TransactionIntentStatusEvent with AwaitingCardPayment (6) arrives (after Hydra clears) — or Failed (8) (release card data).
Error response shape (HTTP 4xx):
{
"errorId": "{guid}",
"message": "...",
"errorData": { "accountRequirements": [ /* dynamic field list */ ] }
}
Frontend branches on
errorId, renders any fields from errorData.accountRequirements, and resubmits the same endpoint with the collected data in the optional fields above. No separate "update" endpoint.
| Error ID | Exception | Meaning | Frontend action |
|---|---|---|---|
E2BBC919-324A-467B-A26F-E435215930D1 |
SenderOfacAdditionalDataRequiredException |
Sender OFAC data incomplete (DOB/CityOfBirth/Nationality) | Show "Enter Your Information" screen; resubmit with senderAdditionalInfo |
604FF34A-8AB9-4206-82FE-880C460964FE |
BeneficiaryOfacAdditionalDataRequiredException |
Beneficiary OFAC data incomplete | Show "Enter Recipients Information" screen; resubmit with beneficiaryAdditionalInfo |
ee19a1fd-f8a4-478d-a0ec-932f6d1d6440 |
SenderAdditionalInfoRequiredException |
Country-specific sender fields missing (e.g., Venezuela — IdType/IdNumber) | Show sender fields form; resubmit with senderAdditionalInfo |
3D431DB4-613B-4465-903E-B5D85CB172DF |
TermsNotAcceptedException |
Terms not accepted | Show terms screen; resubmit (real validation only fires when AppVersion is sent — the card-paid web flow never sends it, so this error will appear in mock only) |
4a6f213a-f1c7-4358-b70d-182e6bb40864 |
RemittanceTransactionExchangeRateException |
FX rate changed since quote | Re-fetch quote; resubmit with refreshedFxRateToken |
2c9d8e67-ef6f-4b77-9b40-3a7a8ccaa2b3 |
RemittanceTransactionDuplicateForbiddenException |
Duplicate submission | Show duplicate alert (not triggered by mock — only fires in real flow) |
Note: OFAC data updates are handled by resubmitting Step 6 with the collected fields — there is no separate "update OFAC" endpoint. Matches existing iOS
/submitpattern.
Mock simulation triggers (staging/dev only):
The endpoint includes mock simulation in non-production so the web team can develop all screens before real validation is wired. The simulation resolves the authoritative Sender (from be-remittance's Sender DB row) and Beneficiary (from the intent's details.beneficiary) first, then overlays any optional deltas from the Step 6 request body before running its checks. So sender/beneficiary names live on the DB / intent, not on the request body — to hit a given trigger you set the name on the appropriate authoritative source and then resubmit with the optional delta to clear the exception.
| Trigger | Authoritative source (set once) | Step 6 body (first submit → resubmit) | Expected outcome |
|---|---|---|---|
| Sender OFAC — missing data | DB sender firstName="Nicolas", lastName="Maduro" (or "Matt Toyle", or any lastName="Watchlist"), missing DOB/CityOfBirth/Nationality |
{} |
SenderOfacAdditionalDataRequiredException |
| Sender OFAC — match (pending review) | Same DB sender as above | { senderAdditionalInfo: { dateOfBirth, cityOfBirth, nationality } } |
200 OK pendingReview: true |
| Beneficiary OFAC — missing data | Intent beneficiary firstName="Christian", lastName="Bale" (or "Matt Toyle", "Nicolas Maduro", or any lastName="Watchlist"), no DOB/City/Nationality |
{} |
BeneficiaryOfacAdditionalDataRequiredException |
| Beneficiary OFAC — match (pending review) | Same intent beneficiary as above | { beneficiaryAdditionalInfo: { dateOfBirth, cityOfBirth, nationality } } |
200 OK pendingReview: true |
| Sender additional info (Venezuela) | Intent countryCode="VE", DB sender missing idType/idNumber |
{} → clear with { senderAdditionalInfo: { idType, idNumber } } |
SenderAdditionalInfoRequiredException → 200 OK |
| Exchange rate changed | Intent countryCode="HN", currencyCode="HNL", sourceAmount=30 |
{} → clear with { refreshedFxRateToken: "{new-guid}" } |
RemittanceTransactionExchangeRateException → 200 OK |
| Terms not accepted | Intent beneficiary lastName="Terms" |
{} |
TermsNotAcceptedException (termStage: "RemittanceMps") |
| Happy path | Normal DB sender + intent beneficiary, default country/amount | {} |
200 OK pendingReview: false |
Staging setup: the DB sender name is the
firstName/lastNameregistered on the test user's account (changeable via the existing user-profile tooling). The intent beneficiary name is whatever was passed in the Step 1 beneficiary object ("firstName": "Nicolas", "lastName": "Maduro"to trigger the beneficiary-OFAC row,"lastName": "Terms"to trigger terms, etc.). The Step 6 body always stays a pure delta — no identity fields.Production note: these triggers only work in staging/dev. In production, real OFAC screening and validation run instead.
7. Get saved cards¶
GET https://stage-majorityweb.minority.com/v1/external-card-data/cards/by-payment-identifier
Authorization: Bearer {token}
Response (empty when no saved cards):
[]
Response (when cards exist):
[
{
"cardId": "ea01f99c-4e6a-4e3d-93a3-00244ab74a1d",
"lastFourDigits": "6919",
"cardBrand": "Visa",
"cardStatus": "active",
"expiry": "04/29"
}
]
If cards exist, user can select one and skip to step 11 with the
cardId.
8. Get public key¶
GET https://stage-majorityweb.minority.com/v1/external-card-data/public-key
AllowAnonymous. Response:
{
"publicKey": "MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQCLioQG..."
}
9. Encrypt card data¶
In the card iframe (web-payment-hub), encrypt card number, expiry date, and CVV individually using RSA with the public key from step 8.
10. Submit transaction¶
POST https://stage-majorityweb.minority.com/v2/payment-link/transactions
Authorization: Bearer {token}
New card request:
{
"amount": 20.00,
"attemptId": "{generate-uuid}",
"cardId": "00000000-0000-0000-0000-000000000000",
"message": "Self-fund",
"card": {
"cardNumber": "{rsa-encrypted}",
"expirationDate": "{rsa-encrypted}",
"cvv": "{rsa-encrypted}"
},
"cardHolderFirstName": "Test",
"cardHolderLastName": "User",
"cardDataSource": "manual",
"storeCard": false,
"billingAddress": {
"addressLine1": "123 Test Street",
"addressLine2": "Apt 1",
"city": "Austin",
"region": "TX",
"zip": "78701",
"country": "US"
}
}
Existing card request:
{
"amount": 20.00,
"attemptId": "{generate-uuid}",
"cardId": "{cardId-from-step-7}"
}
Response (initial status is Pending):
{
"status": "Pending",
"shortName": null,
"attemptId": "122f0725-5330-4da4-938f-5dc86d6f648a",
"recipientPhone": null,
"phoneNumber": null,
"lastFourDigits": null,
"amount": 0.0,
"transactionTime": "0001-01-01T00:00:00",
"cardBrand": "Unknown",
"cardType": null,
"isResumable": false,
"errorMessage": null
}
11. Poll for status¶
GET https://stage-majorityweb.minority.com/v2/payment-link/transactions/{attemptId}
Authorization: Bearer {token}
Response (successful):
{
"status": "Successful",
"shortName": null,
"attemptId": "122f0725-5330-4da4-938f-5dc86d6f648a",
"recipientPhone": null,
"phoneNumber": "12015282430",
"lastFourDigits": "6919",
"amount": 20.00,
"transactionTime": "2026-04-15T13:02:37.360Z",
"cardBrand": "Visa",
"cardType": null,
"isResumable": false,
"errorMessage": null
}