Skip to content

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}

Internal testing token:
aca4f949-8970-4b50-8bb4-12ff800c65a9

{
  "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 TransactionIntentCreatedEventprocessed 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,
    "transactionType": "Bank",
    "channel": "WhatsApp"
  },
  "estimatedDeliveryTime": "Usually 1 hour",
  "additionalInformation": {
    "license": "MAJORITY Payment Services, LLC",
    "maxAmount": 999.99,
    "minAmount": 10.00,
    "termsUrl": "https://www.majority.com/en/terms-of-use/majority-payment-services/",
    "terms": {
      "title": "Terms of Use",
      "description": "These terms include important disclosures related to our services and the services of our Partners. These terms are a binding contract between you and our Partners. By clicking \"Accept\", you confirm you have read, understood, and accepted these terms.",
      "disclaimer": null,
      "logoUrl": "https://cdn.majority.com/brand/majority-logo.svg",
      "links": [
        { "title": "MAJORITY Payment Services", "url": "https://www.majority.com/en/terms-of-use/majority-payment-services/" }
      ]
    }
  },
  "sender": {
    "firstName": "John",
    "lastName": "Doe",
    "address": "123 Main St",
    "addressLine2": null,
    "city": "Austin",
    "state": "TX",
    "zip": "78701"
  },
  "transactionIntentDeepLink": "majority://send/international/{guid}",
  "transactionType": "Bank"
}

  • details.exchangeRate and details.expectedDeliveryTimeInMinutes — stored on the intent at creation time.
  • estimatedDeliveryTime — localized display text derived from details.expectedDeliveryTimeInMinutes using the request Accept-Language; null when the stored minute value is missing.
  • additionalInformation — resolved live from the quote on each call. Use additionalInformation.terms to render the summary and terms screens without an extra quote call. null for MPay transactions.
  • sender — the authenticated user's name and US address from be-remittance's Sender table.

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: senderAdditionalInfo does NOT accept firstName/lastName — the sender's identity is always authoritative on be-remittance's Sender table. Only the fields listed in errorData.accountRequirements from the last response should be sent back. beneficiaryAdditionalInfo may carry firstName/lastName only 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.

TermsNotAcceptedException carries an enriched errorData so the frontend can go straight to the acceptance screen without an extra fetch:

{
  "errorId": "3D431DB4-613B-4465-903E-B5D85CB172DF",
  "message": "Terms Require Acceptance",
  "errorData": {
    "termStage": "RemittanceMps",
    "terms": [
      {
        "termGroup": "MAJORITY Payment Services, LLC",
        "documents": [
          {
            "documentId": 5,
            "documentLink": "https://majority.com/en/terms-of-use/majority-payment-services/",
            "documentName": "MAJORITY Payment Services Terms of Use"
          }
        ]
      }
    ]
  }
}

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 See Step 6a — read errorData.terms to render the acceptance screen, record acceptance with the embedded documentIds, then 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 See Step 6b — re-fetch quote, show updated rate, 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 /submit pattern.

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 } } SenderAdditionalInfoRequiredException200 OK
Exchange rate changed Intent countryCode="HN", currencyCode="HNL", sourceAmount=30 {} → clear with { refreshedFxRateToken: "{new-guid}" } RemittanceTransactionExchangeRateException200 OK
Terms not accepted Intent beneficiary lastName="Terms" {} → after Step 6a (accept terms) resubmit {} TermsNotAcceptedException (termStage: "RemittanceMps") → 200 OK
Happy path Normal DB sender + intent beneficiary, default country/amount {} 200 OK pendingReview: false

Staging setup: the DB sender name is the firstName/lastName registered 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.

6a. Terms acceptance (only when Step 6 returns TermsNotAcceptedException)

The TermsNotAcceptedException response carries everything needed to drive the acceptance flow:
- errorData.termStage — the stage to accept against (e.g. "RemittanceMps").
- errorData.terms — the live document list grouped by termGroup, fetched server-side at the moment of failure (same shape as GET /v1/remittance/terms). Use this to render the acceptance screen and collect documentIds.
- Display content (title, description, disclaimer, logoUrl, links) is already returned by Step 5 as additionalInformation.terms — keep using that for the screen chrome.

1. Record acceptance with the documentIds read from errorData.terms:

PUT https://stage-majorityweb.minority.com/v1/remittance/terms/accept
Authorization: Bearer {majority-web-token from Step 4}

{ "documents": [5] }

Returns 200 OK.

2. Resubmit Step 6 with {} — the terms check will now pass.

No extra fetch needed. A separate GET /v1/remittance/terms?termStage=... endpoint still exists for direct callers, but the resubmit-after-error flow does not require it — be-majorityweb resolves the documents server-side and embeds them in the error so the frontend can render the screen on the same response that signalled the failure. documentIds on errorData.terms are fresh: they are fetched at the moment the exception is raised, not at intent creation time, so legal-driven rotations are picked up automatically.

6b. Re-fetch quote (only when Step 6 returns RemittanceTransactionExchangeRateException)

The FX rate locked to the intent has expired. Re-fetch a live quote, show the user the updated rate and destination amount, then resubmit Step 6 with the new fxRateToken.

GET https://stage-majorityweb.minority.com/v1/remittance/quote/country/{countryCode}/currency/{currencyCode}/transaction-type/{transactionType}/send-amount/{sendAmount}
Authorization: Bearer {majority-web-token from Step 4}

Use the values already on screen (from the intent): countryCode, currencyCode, transactionType (e.g. Bank), sendAmount. transactionType is passed as a plain string and parsed server-side.

Response:

{
  "fxRateValue": 18.75,
  "fxRateToken": "{new-guid}",
  "destinationAmount": 1875.00
}

Show the updated rate and amount to the user. If they confirm, resubmit Step 6 with { "refreshedFxRateToken": "{new-guid}" }.

Channel note: The quote is fetched using WebBasedRemittanceIntentDefaultChannel from config ("WhatsApp"). ITransactionIntentService.GetChannel is not yet callable from be-majorityweb (missing HTTP method attribute on the contract), so the intent's original channel cannot be retrieved at this time. This is a known limitation — tracked for a follow-up.

Response scope: Intentionally returns only fxRateValue, fxRateToken, and destinationAmount. The rate-change path only needs these three fields.

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
}

12. Delete saved card

DELETE https://stage-majorityweb.minority.com/v2/external-card-data/cards/{cardId}
Authorization: Bearer {token}

Requires PaymentRequestV2-scoped token. userId is read from the token claims; the V2 userId resolves to the same external card storage key as the V1 externalUserId, so cards saved via Step 10 (storeCard: true) can be deleted here. cardId is the value returned by Step 7. Returns 200 OK with no body.