Direct API
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.
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:
- Access your MoneyHash Organization and Account from the dashboard
- Connect your payment providers to the account
- Configure your Payment Defaults and Flow
- 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.
| State | What it means | What to do |
|---|---|---|
METHOD_SELECTION | No method was pre-selected | Render available methods, let customer choose, call update-method |
FORM_FIELDS | Card and/or billing fields required | Render your form, collect data, submit to vault |
URL_TO_RENDER | 3DS or redirect required | Render state_details.embed_url to the customer |
INTENT_PROCESSED | Payment complete - terminal | Confirm via webhook and fulfill order |
TRANSACTION_FAILED | Transaction failed | Check intent status - retry may be possible |
TRANSACTION_WAITING_USER_ACTION | Waiting for customer external action | Render pending UI with externalActionMessage |
PROCESSING | Payment being processed | Render processing screen |
EXPIRED | Intent expired - terminal | Render expired UI |
CLOSED | Intent closed - terminal | Render closed UI |
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
Required parameters
| Parameter | Type | Description |
|---|---|---|
amount | string | The amount to charge. Max 14 digits, max 2 decimal places. |
amount_currency | string | ISO 4217 currency code e.g. AED, USD, SAR. |
webhook_url | string | Your backend endpoint that receives payment event notifications. |
operation | string | purchase for immediate charge · authorize for auth-only. Required if flow_id is not provided. |
Recommended parameters
| Parameter | Purpose |
|---|---|
payment_method | Pre-select CARD to skip METHOD_SELECTION state |
billing_data | Customer billing details passed to the provider automatically |
ip_address | Customer IP - used in flow routing and risk rules |
threeds.enabled | Set to true to enable 3DS authentication |
customer | Associate with an existing MoneyHash customer ID |
card_token | Pay with a saved token directly - may skip to INTENT_PROCESSED |
allow_tokenize_card | Set to true to allow saving the card for future use |
payment_type | Set to recurring for non-present cardholder payments |
merchant_initiated | Set to true for merchant-initiated transactions |
successful_redirect_url | Redirect after successful payment |
failed_redirect_url | Redirect after failed payment |
pending_external_action_redirect_url | Redirect when external action is pending |
time_expired_redirect_url | Redirect when intent expires |
closed_redirect_url | Redirect 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 savedcard_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
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,cvvstate_details.billing.fields- any additional billing fields required by the providerstate_details.meta.url- the Vault URL to use in Step 3
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.
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
]
]
])
]]));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" }
}| Strategy | How |
|---|---|
| Redirect (recommended) | window.location.href = embed_url |
| Iframe | Embed the URL in an iframe on your page |
| Popup | Open the URL in a popup window |
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:
transaction.purchase.pending_authentication- if 3DS was triggered (informational only, no action needed)card_token.created- only ifallow_tokenize_card: truewas set andsave_card: truewas passed in the vault submissiontransaction.purchase.successfulortransaction.purchase.failedintent.processed- terminal, intent is done
What to check:
| Field | Expected value | Purpose |
|---|---|---|
type | transaction.purchase.successful | Confirms the event type |
intent.payment_status.status | CAPTURED | Confirms money was collected |
intent.id | Your order's intent ID | Links 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
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'])
]])
);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.
CVV is required if requires_cvv: true was returned in the card_token.created webhook. If false, it can be omitted.
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:
- Set
allow_tokenize_card: trueon the intent at creation - Pass
save_card: trueinsidecard_detailsin 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:
| Field | Purpose |
|---|---|
latest_status.code | MoneyHash unified status code - use for programmatic handling |
latest_status.localized_message | Translated message - display to customer |
latest_status.provider_error_code | Raw 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
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1.4/direct/payments/intent/ | POST | Create payment intent |
/api/v1.4/direct/payments/intents/{intent_id}/ | GET | Get intent state |
/api/v1.4/direct/payments/intents/{intent_id}/update-method/ | POST | Set payment method |
{state_details.meta.url} | POST | Submit card to vault |
/api/v1.4/direct/payments/intents/{intent_id}/card_token/ | POST | Pay with saved token |
/api/v1.4/direct/payments/intents/{intent_id}/receipt/ | POST | Submit native pay receipt |
Next steps
| I want to... | Go here |
|---|---|
| Understand Intent, Transaction, and Operation statuses | Payment Components |
| Set up webhook signature verification | Webhook Signature |
| Configure redirect URLs | Redirects |
| See full error codes and decline classification | Status Codes |
Updated about 1 month ago