Direct API gives you complete control over the payment experience. Your backend handles all API calls, your frontend renders card fields natively using your own UI, and card data is submitted directly to the MoneyHash Vault from your server. No MoneyHash-hosted UI is involved at any point.

PCI DSS Level 1 certification is required to use Direct API.

You are responsible for collecting, handling, and transmitting cardholder data. Attempting to use this integration without PCI certification violates card network rules. Contact MoneyHash before enabling this integration.

Prerequisites

Before starting your integration:

  1. Access your MoneyHash Organization and Account from the dashboard
  2. Connect your payment providers to the account
  3. Configure your Payment Defaults and Flow
  4. Retrieve your API keys from the dashboard

Authentication: Include your API key in the x-api-key header on every request.

Base URL: https://web.moneyhash.io/api/v1.4/


How it works

Direct API is state-driven. Every API response returns a state field and a state_details object that tells you exactly what to do next. You advance the payment by reading state and acting on it until the intent reaches a terminal state.

StateWhat it meansWhat to do
METHOD_SELECTIONNo method was pre-selectedRender available methods, let customer choose, call update-method
FORM_FIELDSCard and/or billing fields requiredRender your form, collect data, submit to vault
URL_TO_RENDER3DS or redirect requiredRender state_details.embed_url to the customer
INTENT_PROCESSEDPayment complete - terminalConfirm via webhook and fulfill order
TRANSACTION_FAILEDTransaction failedCheck intent status - retry may be possible
TRANSACTION_WAITING_USER_ACTIONWaiting for customer external actionRender pending UI with externalActionMessage
PROCESSINGPayment being processedRender processing screen
EXPIREDIntent expired - terminalRender expired UI
CLOSEDIntent closed - terminalRender closed UI
Note

METHOD_SELECTION only occurs if you did not pass payment_method or payment_provider in the intent creation request. Passing "payment_method": "CARD" upfront skips directly to FORM_FIELDS.


Step 1 - Create a payment intent

Create an intent on your backend to initiate the payment. API Reference

POST /api/v1.4/direct/payments/intent/

Required parameters

ParameterTypeDescription
amountstringThe amount to charge. Max 14 digits, max 2 decimal places.
amount_currencystringISO 4217 currency code e.g. AED, USD, SAR.
webhook_urlstringYour backend endpoint that receives payment event notifications.
operationstringpurchase for immediate charge · authorize for auth-only. Required if flow_id is not provided.

Recommended parameters

ParameterPurpose
payment_methodPre-select CARD to skip METHOD_SELECTION state
billing_dataCustomer billing details passed to the provider automatically
ip_addressCustomer IP - used in flow routing and risk rules
threeds.enabledSet to true to enable 3DS authentication
customerAssociate with an existing MoneyHash customer ID
card_tokenPay with a saved token directly - may skip to INTENT_PROCESSED
allow_tokenize_cardSet to true to allow saving the card for future use
payment_typeSet to recurring for non-present cardholder payments
merchant_initiatedSet to true for merchant-initiated transactions
successful_redirect_urlRedirect after successful payment
failed_redirect_urlRedirect after failed payment
pending_external_action_redirect_urlRedirect when external action is pending
time_expired_redirect_urlRedirect when intent expires
closed_redirect_urlRedirect when intent is closed

Example request

curl --request POST \
  --url https://web.moneyhash.io/api/v1.4/direct/payments/intent/ \
  --header 'x-api-key: <your_api_key>' \
  --header 'Content-Type: application/json' \
  --data '{
    "amount": "50",
    "amount_currency": "AED",
    "operation": "purchase",
    "webhook_url": "https://yourbackend.com/webhooks/moneyhash",
    "payment_method": "CARD"
  }'
const response = await fetch('https://web.moneyhash.io/api/v1.4/direct/payments/intent/', {
  method: 'POST',
  headers: { 'x-api-key': '<your_api_key>', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    amount: '50',
    amount_currency: 'AED',
    operation: 'purchase',
    webhook_url: 'https://yourbackend.com/webhooks/moneyhash',
    payment_method: 'CARD'
  })
});
const data = await response.json();
import requests

response = requests.post(
    'https://web.moneyhash.io/api/v1.4/direct/payments/intent/',
    headers={'x-api-key': '<your_api_key>', 'Content-Type': 'application/json'},
    json={
        'amount': '50',
        'amount_currency': 'AED',
        'operation': 'purchase',
        'webhook_url': 'https://yourbackend.com/webhooks/moneyhash',
        'payment_method': 'CARD'
    }
)
data = response.json()
$response = file_get_contents('https://web.moneyhash.io/api/v1.4/direct/payments/intent/', false, stream_context_create([
    'http' => [
        'method' => 'POST',
        'header' => "x-api-key: <your_api_key>\r\nContent-Type: application/json",
        'content' => json_encode([
            'amount' => '50',
            'amount_currency' => 'AED',
            'operation' => 'purchase',
            'webhook_url' => 'https://yourbackend.com/webhooks/moneyhash',
            'payment_method' => 'CARD'
        ])
    ]
]));
$data = json_decode($response, true);

Check the response state

  • FORM_FIELDS - card was pre-selected, proceed to Step 3.
  • METHOD_SELECTION - no method was pre-selected, proceed to Step 2.
  • INTENT_PROCESSED - payment already completed (e.g. paying with a saved card_token). Proceed to Step 5.

Retain intent.id for all subsequent calls.


Step 2 - Set the payment method

If the response state was METHOD_SELECTION, the customer has not yet selected a payment method. Call the update-method endpoint with the method the customer chooses - CARD is used here as an example, but any payment method returned in state_details is valid. API Reference

POST /api/v1.4/direct/payments/intents/{intent_id}/update-method/
curl --request POST \
  --url https://web.moneyhash.io/api/v1.4/direct/payments/intents/{intent_id}/update-method/ \
  --header 'x-api-key: <your_api_key>' \
  --header 'Content-Type: application/json' \
  --data '{"payment_method": "CARD"}'
const response = await fetch(`https://web.moneyhash.io/api/v1.4/direct/payments/intents/${intentId}/update-method/`, {
  method: 'POST',
  headers: { 'x-api-key': '<your_api_key>', 'Content-Type': 'application/json' },
  body: JSON.stringify({ payment_method: 'CARD' })
});
response = requests.post(
    f'https://web.moneyhash.io/api/v1.4/direct/payments/intents/{intent_id}/update-method/',
    headers={'x-api-key': '<your_api_key>', 'Content-Type': 'application/json'},
    json={'payment_method': 'CARD'}
)
$response = file_get_contents(
    "https://web.moneyhash.io/api/v1.4/direct/payments/intents/{$intentId}/update-method/",
    false,
    stream_context_create(['http' => [
        'method' => 'POST',
        'header' => "x-api-key: <your_api_key>\r\nContent-Type: application/json",
        'content' => json_encode(['payment_method' => 'CARD'])
    ]])
);

The response moves to state: FORM_FIELDS and returns:

  • state_details.card.fields - card fields to collect: card_number, card_holder_name, expiry_month, expiry_year, cvv
  • state_details.billing.fields - any additional billing fields required by the provider
  • state_details.meta.url - the Vault URL to use in Step 3
Warning

Retain state_details.meta.url - it is a JWT-authenticated URL valid for 5 minutes. If it expires before submission, call update-method again to get a fresh URL.


Step 3 - Collect card data and submit to the vault

Render the card fields in your frontend using your own UI. Once the customer fills in their details, submit the data from your backend to the vault URL returned in state_details.meta.url.

POST {state_details.meta.url}

Include x-api-key in the header.

curl --request POST \
  --url '{state_details.meta.url}' \
  --header 'x-api-key: <your_api_key>' \
  --header 'Content-Type: application/json' \
  --data '{
    "native_form": {
      "billing_fields": {
        "first_name": "John",
        "last_name": "Doe",
        "email": "[email protected]"
      },
      "card_details": {
        "card_holder_name": "John Doe",
        "card_number": "4111111111111111",
        "expiry_month": "05",
        "expiry_year": "30",
        "cvv": "123",
        "save_card": false
      }
    }
  }'
const response = await fetch(vaultUrl, {
  method: 'POST',
  headers: { 'x-api-key': '<your_api_key>', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    native_form: {
      billing_fields: { first_name: 'John', last_name: 'Doe', email: '[email protected]' },
      card_details: {
        card_holder_name: 'John Doe',
        card_number: '4111111111111111',
        expiry_month: '05',
        expiry_year: '30',
        cvv: '123',
        save_card: false
      }
    }
  })
});
response = requests.post(
    vault_url,
    headers={'x-api-key': '<your_api_key>', 'Content-Type': 'application/json'},
    json={
        'native_form': {
            'billing_fields': {'first_name': 'John', 'last_name': 'Doe', 'email': '[email protected]'},
            'card_details': {
                'card_holder_name': 'John Doe',
                'card_number': '4111111111111111',
                'expiry_month': '05',
                'expiry_year': '30',
                'cvv': '123',
                'save_card': False
            }
        }
    }
)
$response = file_get_contents($vaultUrl, false, stream_context_create(['http' => [
    'method' => 'POST',
    'header' => "x-api-key: <your_api_key>\r\nContent-Type: application/json",
    'content' => json_encode([
        'native_form' => [
            'billing_fields' => ['first_name' => 'John', 'last_name' => 'Doe', 'email' => '[email protected]'],
            'card_details' => [
                'card_holder_name' => 'John Doe',
                'card_number' => '4111111111111111',
                'expiry_month' => '05',
                'expiry_year' => '30',
                'cvv' => '123',
                'save_card' => false
            ]
        ]
    ])
]]));
Important

Billing fields are not fixed - they vary per provider and are returned dynamically in state_details.billing.fields from the update-method response. Always map your billing fields from that response rather than hardcoding a fixed set. If no billing fields are required, send billing_fields as an empty object {}.

Check the response state

  • INTENT_PROCESSED - no 3DS required, payment is complete. Proceed to Step 5.
  • URL_TO_RENDER - 3DS is required. Proceed to Step 4.

Step 4 - Handle 3DS authentication

When state is URL_TO_RENDER, render the URL in state_details.embed_url to the customer to complete the 3DS challenge with their bank.

{
  "state": "URL_TO_RENDER",
  "state_details": {
    "embed_url": "https://embed.moneyhash.io/embed/payment/<intent_id>"
  },
  "transaction": { "status": "PENDING_AUTHENTICATION" },
  "payment_status": { "status": "AUTHORIZE_ATTEMPT_PENDING" }
}
StrategyHow
Redirect (recommended)window.location.href = embed_url
IframeEmbed the URL in an iframe on your page
PopupOpen the URL in a popup window
Recommendation

Redirect is strongly recommended. It lands the customer on the bank's actual domain, the URL is visible in the browser bar, and the customer can verify the page before entering their OTP. See Redirects for full details.

After the customer completes 3DS, the intent proceeds to its final state automatically - delivered via webhooks.


Step 5 - Handle webhooks and fulfill the order

When the payment completes, webhooks are delivered to your webhook_url in the following order:

  1. transaction.purchase.pending_authentication - if 3DS was triggered (informational only, no action needed)
  2. card_token.created - only if allow_tokenize_card: true was set and save_card: true was passed in the vault submission
  3. transaction.purchase.successful or transaction.purchase.failed
  4. intent.processed - terminal, intent is done

What to check:

FieldExpected valuePurpose
typetransaction.purchase.successfulConfirms the event type
intent.payment_status.statusCAPTUREDConfirms money was collected
intent.idYour order's intent IDLinks the webhook to your order

On order fulfillment: Always wait for transaction.purchase.successful with payment_status.status: CAPTURED before releasing an order. Do not fulfill based on the API response alone - always wait for webhook confirmation.

On failures: A transaction.purchase.failed webhook does not mean the intent is done. Check intent.status - if it is still UNPROCESSED, MoneyHash may automatically retry on another provider. Only mark an order as permanently failed when the intent reaches CLOSED or EXPIRED without a successful transaction.

On source of truth: Always use payment_status.status combined with operations[].latest_status.value as your source of truth - not active_transaction.status, which can be misleading after 3DS flows.

For the full webhook reference - event types, signatures, and retry behavior - see Webhooks.


Paying with a saved card token

If the customer has a previously saved card token, call the card token endpoint directly - no vault submission or update-method call needed. API Reference

POST /api/v1.4/direct/payments/intents/{intent_id}/card_token/
curl --request POST \
  --url https://web.moneyhash.io/api/v1.4/direct/payments/intents/{intent_id}/card_token/ \
  --header 'x-api-key: <your_api_key>' \
  --header 'Content-Type: application/json' \
  --data '{"card_token_id": "<hashid>", "cvv": "123"}'
const response = await fetch(`https://web.moneyhash.io/api/v1.4/direct/payments/intents/${intentId}/card_token/`, {
  method: 'POST',
  headers: { 'x-api-key': '<your_api_key>', 'Content-Type': 'application/json' },
  body: JSON.stringify({ card_token_id: '<hashid>', cvv: '123' })
});
response = requests.post(
    f'https://web.moneyhash.io/api/v1.4/direct/payments/intents/{intent_id}/card_token/',
    headers={'x-api-key': '<your_api_key>', 'Content-Type': 'application/json'},
    json={'card_token_id': '<hashid>', 'cvv': '123'}
)
$response = file_get_contents(
    "https://web.moneyhash.io/api/v1.4/direct/payments/intents/{$intentId}/card_token/",
    false,
    stream_context_create(['http' => [
        'method' => 'POST',
        'header' => "x-api-key: <your_api_key>\r\nContent-Type: application/json",
        'content' => json_encode(['card_token_id' => '<hashid>', 'cvv' => '123'])
    ]])
);
Note

card_token_id must be the hashid - available as data.card_token.hashid in the card_token.created webhook, or as saved_cards[].id in the intent response. The UUID from card_token.created is not accepted by this endpoint.

Note

CVV is required if requires_cvv: true was returned in the card_token.created webhook. If false, it can be omitted.

Important

URL_TO_RENDER may be returned even when paying with a saved token if the provider requires 3DS. Handle the embed URL the same way as Step 4.


Saving a card for future use

To save a card during a payment, both conditions must be met:

  1. Set allow_tokenize_card: true on the intent at creation
  2. Pass save_card: true inside card_details in the vault submission

When both are present and the payment succeeds, a card_token.created webhook is delivered:

{
  "type": "card_token.created",
  "data": {
    "intent_id": "<intent_id>",
    "card_token": {
      "id": "<uuid>",
      "hashid": "<hashid>",
      "brand": "MasterCard",
      "card_holder_name": "John Doe",
      "bin": "512345",
      "last_4": "2346",
      "expiry_month": "05",
      "expiry_year": "30",
      "requires_cvv": true
    }
  }
}

Store data.card_token.hashid against the customer record - this is the value used for future payments. Check data.card_token.requires_cvv to determine whether CVV will be required on future payments.


Redirect and wallet flows

For payment methods that require a redirect - such as wallets, bank redirects, or other provider-hosted flows - the response after update-method will return state: URL_TO_RENDER directly, without a FORM_FIELDS step.

{
  "state": "URL_TO_RENDER",
  "state_details": {
    "embed_url": "https://embed.moneyhash.io/embed/payment/<intent_id>"
  }
}

Redirect the customer to embed_url to complete the payment on the provider's page. After completion, the intent proceeds to its final state via webhooks - the same flow as Step 5.


Error handling

Errors in Direct API surface across three layers:

Field validation (pre-submission)
Field schemas are returned in state_details.card.fields and state_details.billing.fields from the update-method response. Each field includes label, required, min_length, max_length, and error_messages — use these to validate client-side before vault submission. Error messages are already translated per the language set on the intent.

Transaction errors (post-submission)
After vault submission, errors appear in latest_status on the operation:

FieldPurpose
latest_status.codeMoneyHash unified status code - use for programmatic handling
latest_status.localized_messageTranslated message - display to customer
latest_status.provider_error_codeRaw provider error - backend debugging only, never display to customer

Soft vs hard declines
Use latest_status.code to determine whether a retry makes sense:

  • Codes 7000–7099 (e.g. insufficient funds, bank decline, timeout) - soft declines, retry or alternate method may succeed
  • Codes 7300+ (e.g. expired card, fraud flag, invalid card) - hard declines, do not retry

For the full error code reference, soft vs hard decline classification, and localization strategy - see Status Codes.


API reference

EndpointMethodPurpose
/api/v1.4/direct/payments/intent/POSTCreate payment intent
/api/v1.4/direct/payments/intents/{intent_id}/GETGet intent state
/api/v1.4/direct/payments/intents/{intent_id}/update-method/POSTSet payment method
{state_details.meta.url}POSTSubmit card to vault
/api/v1.4/direct/payments/intents/{intent_id}/card_token/POSTPay with saved token
/api/v1.4/direct/payments/intents/{intent_id}/receipt/POSTSubmit native pay receipt

Next steps

I want to...Go here
Understand Intent, Transaction, and Operation statusesPayment Components
Set up webhook signature verificationWebhook Signature
Configure redirect URLsRedirects
See full error codes and decline classificationStatus Codes

Did this page help you?