Loyalty
Loyalty lets your customers pay with points and collect points on what they buy, without you building a separate integration for each programme. It has two halves. Redemption ("burn") spends points as a payment method on the standard intent lifecycle. Earn awards points after a purchase, as an asynchronous call that never blocks your checkout.
Loyalty Redemption (Burn)
A burn is not a separate product surface. It produces a transaction that appears in your transactions list, fires the usual webhooks (transaction.purchase.successful, intent.processed), and is refunded, reported and investigated exactly like a card payment. The one thing it adds is an authentication step: the loyalty programme sends a one-time password (OTP) to the customer, and the intent waits in an OTP state until that code is submitted. Most of this page is about driving that state correctly.
Choose where the OTP is collected
You have two ways to run a burn. The choice is about who renders and submits the OTP, not about what happens behind it - both paths hit the same intent and the same programme calls.
| Let checkout handle it | Server-side redemption | |
|---|---|---|
| You use | The MoneyHash JavaScript SDK (v3.0.0+) or the Embedded Experience | The External API |
| Who renders the OTP input | MoneyHash, or your UI from the SDK state | You |
| Who submits the OTP | The SDK, with submitOtp() from the OTP_FORM state | Your backend, with POST .../otp-authorization/ |
| Resend | Built into the embed | Your backend, with POST .../otp-resend/ |
| Use it when | You already render MoneyHash checkout and want loyalty to appear as another method | You cannot run the SDK - a backend microservice, a POS, or a server-to-server integration |
If you are already on the JavaScript SDK, redemption needs one addition: handle the OTP_FORM state and pass the code to submitOtp().
if (intentDetails.state === "OTP_FORM") {
const { otpLength, intentOtpData } = intentDetails.stateDetails;
// Render an input sized to otpLength, then:
await moneyHash.submitOtp({
intentId,
otp: "<CODE_THE_CUSTOMER_ENTERED>",
intentOtpData,
});
}The rest of this page documents the server-side path.
Start a redemption
There is no separate "burn" endpoint. You start a redemption by creating an External API intent with the loyalty method already selected and the customer's mobile number attached - the same create call you already use, with two extra fields.
{
"amount": 50,
"amount_currency": "SAR",
"operation": "purchase",
"payment_method": "<LOYALTY_METHOD>",
"billing_data": {
"mobile_wallet_number": "+966500000000"
},
"webhook_url": "https://merchant.example.com/webhooks/moneyhash"
}| Field | Why it matters |
|---|---|
payment_method | The loyalty method to redeem against. This is what makes the intent a burn rather than an ordinary payment, and it skips method selection. The code differs per programme. |
billing_data.mobile_wallet_number | The customer's mobile number - see Identify the customer. |
amount_currency | Must be a currency the programme supports - see Currency. |
MoneyHash asks the programme to send an OTP to that number, and the intent reports the OTP_FORM state described next. Because the method is already chosen, the intent skips METHOD_SELECTION entirely - there is no update-method call to make. For the rest of the External API state model, see External API.
The OTP intent state
Once the redemption starts, the intent reports OTP_FORM and the transaction waits for authentication. The OTP payload is the contract for your OTP screen - and its shape depends on which API surface you are on. New integrations should use the External API. The older Payment API returns the same state under a different container, and is still supported for existing integrations.
External API
{
"state": "OTP_FORM",
"state_details": {
"otp_length": 4,
"expires_after_seconds": 180,
"previous_submission_status": null
}
}| Field | Meaning |
|---|---|
otp_length | How many digits the code has. Size your input to this value. |
expires_after_seconds | How long the code is valid. Drive your countdown and your resend button from this. |
previous_submission_status | null on the first prompt. On a retry prompt, a unified OTP error code - see Retry after a bad code. |
Payment API
On the Payment API you start the redemption the same way - create the intent at POST /api/v1.4/payments/intent/ with payment_method and billing_data.mobile_wallet_number - and it goes straight to the OTP form. The state comes back under a different container, with different field names:
{
"next_action": "OTP_FORM",
"intent_sdk_state": "OTP_FORM",
"action_data": {
"otp_data": {
"length": 4,
"expiry_minutes": 3,
"intent_id": "<INTENT_ID>",
"reset_otp_url": "https://web.moneyhash.io/api/v1/client/otp-resend/<INTENT_ID>/",
"otp_retry": true,
"previous_submission_status": null
},
"amount": "50.00SAR"
}
}| Field | Meaning |
|---|---|
length | How many digits the code has. Size your input to this value. |
expiry_minutes | How long the code is valid, in minutes (the External API reports the same value in seconds). |
reset_otp_url | The client-side resend URL. Returned only when the programme supports resend - its absence is the signal that resend is unavailable. |
otp_retry | Whether the customer may request another code for this transaction. false means don't offer a resend. |
previous_submission_status | null on the first prompt. On a retry prompt, a unified OTP error code - see Retry after a bad code. |
These values differ by loyalty programme, and a programme can change them without a MoneyHash release. An integration that hard-codes "6 digits, 5 minutes" will silently reject valid codes the day it is pointed at a different programme, and every one of those redemptions fails.
This is the single most important rule on this page: your OTP screen is rendered from the state payload, not from constants in your code. The same goes for otp_retry and reset_otp_url - take them from the response rather than assuming or constructing them.
The redemption flow
From the customer choosing loyalty to the confirmation screen, a server-side burn is two calls from your backend: create the intent, then submit the code.
Here is the same flow in motion, including the path most integrations forget to build - a wrong code that re-prompts on the same transaction.
Identify the customer
A redemption is keyed on the customer's mobile number - not the card, and not your own customer ID. Whatever identity you hold for the customer elsewhere, a burn needs a mobile number that is registered with the loyalty programme.
Redemption (burn) takes it in billing_data.mobile_wallet_number, in international format with a leading +, for example "+966500000000".
Earn and the eligibility check take it as loyalty.identifier / identifier, in international format without the +, for example "966500000000".
A malformed number is rejected at validation before any OTP is sent, so normalise per flow rather than storing one canonical shape and reusing it.
{
"billing_data": {
"mobile_wallet_number": "+966500000000"
}
}{
"loyalty": {
"provider_id": "<LOYALTY_CONNECTION_ID>",
"identifier": "966500000000",
"identifier_type": "PHONE_NUMBER"
}
}Earn and eligibility are not limited to phone numbers. identifier_type accepts PHONE_NUMBER, NATIONAL_ID or EMAIL, and each programme expects one of them. With the JavaScript SDK, getLoyaltyProviders() returns each connected programme's id, name and identifierType, so your UI can ask for the right thing.
A number that is not enrolled in the programme is a normal, expected case, not an edge case. Design the checkout so the customer can correct the number or fall back to another payment method without losing the intent.
Check eligibility first
Some programmes let you ask whether a customer identifier is enrolled and eligible before you start. Others do not. Treat the check as an optimisation for your UI, never as a gate. The check authenticates with your account API key.
curl -X POST https://web.moneyhash.io/api/v1.4/loyalty/eligibility/ \
-H "x-api-key: <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"provider_id": "<LOYALTY_CONNECTION_ID>",
"identifier": "966500000000",
"identifier_type": "PHONE_NUMBER"
}'import requests
response = requests.post(
"https://web.moneyhash.io/api/v1.4/loyalty/eligibility/",
headers={"x-api-key": "<YOUR_API_KEY>"},
json={
"provider_id": "<LOYALTY_CONNECTION_ID>",
"identifier": "966500000000",
"identifier_type": "PHONE_NUMBER",
},
)const res = await fetch("https://web.moneyhash.io/api/v1.4/loyalty/eligibility/", {
method: "POST",
headers: {
"x-api-key": "<YOUR_API_KEY>",
"Content-Type": "application/json",
},
body: JSON.stringify({
provider_id: "<LOYALTY_CONNECTION_ID>",
identifier: "966500000000",
identifier_type: "PHONE_NUMBER",
}),
});The answer is in data.eligible, and it has three possible values. Your code must handle all three:
{
"status": { "code": 200, "message": "success", "errors": [] },
"data": { "eligible": true, "reason": null },
"count": 1,
"next": null,
"previous": null
}{
"status": { "code": 200, "message": "success", "errors": [] },
"data": { "eligible": false, "reason": "customer_not_registered" },
"count": 1,
"next": null,
"previous": null
}{
"status": { "code": 200, "message": "success", "errors": [] },
"data": { "eligible": null, "reason": "This provider does not support eligibility check." },
"count": 1,
"next": null,
"previous": null
}A 400 means the request itself failed - an invalid identifier format, an unknown provider_id, or the programme's own check erroring. Treat it the same as eligible: null and carry on. Full schema: Check loyalty eligibility.
eligible: null is not a "no"
It means the programme has no eligibility API. Proceed with the redemption and let the OTP step surface the truth. Eligibility is advisory: skipping it never blocks a payment, it just means an ineligible customer discovers the problem one step later.
Submit the OTP
Send the code the customer entered. Both surfaces take the same body - just the otp.
{
"otp": "1234"
}{
"otp": "1234"
}What each surface gives you:
| External API | Payment API | |
|---|---|---|
| Submit OTP | Body needs otp only | Body needs otp only |
previous_submission_status on retry | Yes | Yes |
| Server-side resend | Yes | No - client-side reset_otp_url |
| Status | Recommended | Supported for existing integrations |
On a valid OTP, the programme executes the redemption, the transaction moves to SUCCESSFUL, the intent reaches PROCESSED, and the standard webhooks fire:
transaction.purchase.successfulintent.processed
Treat the webhook as the source of truth for fulfilment, exactly as you would for a card payment.
On an invalid OTP, the intent stays in PENDING_AUTHENTICATION and returns OTP_FORM again - see below.
Retry after a bad code
Every response after a submission lands in one of four places. Branch on the state first, then on previous_submission_status.
A wrong code does not end the redemption. The intent returns OTP_FORM on the same transaction, with previous_submission_status populated, and the customer can try again:
{
"next_action": "OTP_FORM",
"action_data": {
"otp_data": {
"length": 4,
"reset_otp_url": "https://web.moneyhash.io/api/v1/client/otp-resend/<INTENT_ID>/",
"otp_retry": true,
"previous_submission_status": "INVALID_OTP"
}
}
}previous_submission_status is the only reliable way to tell a retry prompt from a first prompt - the rest of the payload looks identical. If you branch on anything else, your UI shows a blank OTP screen instead of an error message.
When the redemption fails outright
A wrong code re-prompts. Other problems - most often a number that isn't valid for that programme - end the attempt instead. The HTTP response is still 200, so you must branch on the body:
{
"next_action": "FAILED",
"intent_sdk_state": "TRANSACTION_FAILED",
"action_data": {
"transaction": {
"status": "FAILED",
"operations": [
{
"status": "failed",
"latest_status": {
"code": "7078",
"message": "The payment instrument provided is invalid or unsupported."
}
}
]
}
}
}There is no retry on this path. The attempt is over, and the operation's latest_status carries the reason. On the External API the equivalent is state: "TRANSACTION_FAILED". OTP_FORM means prompt again; a failed state means show the failure and offer another payment method. A checkout that only handles OTP_FORM hangs on a blank OTP screen whenever this happens.
MoneyHash normalises each programme's error into one taxonomy, so you write one set of error copy regardless of which programme the customer chose:
previous_submission_status | What to tell the customer |
|---|---|
INVALID_OTP | The code was wrong. Let them re-enter it. |
OTP_EXPIRED | The code timed out. Offer a resend. |
OTP_ALREADY_USED | The code was submitted before. Offer a resend. |
OTP_STILL_VALID | Returned on a resend request while the previous code is still valid. Don't resend - let them use the code they have. |
OTP_GENERIC_FAILURE | The programme failed for an unspecified reason. Offer a retry, and a fallback method. |
These OTP codes are distinct from MoneyHash payment and payout status codes. Do not map them onto your existing payment error handling.
Request a resend
When the customer says the code never arrived, or it expired, request a fresh one. Server-side integrations use the External API resend endpoint, with an empty body:
curl -X POST https://web.moneyhash.io/api/v1.4/external/payments/intents/<INTENT_ID>/otp-resend/ \
-H "x-api-key: <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{}'import requests
response = requests.post(
f"https://web.moneyhash.io/api/v1.4/external/payments/intents/{intent_id}/otp-resend/",
headers={"x-api-key": "<YOUR_API_KEY>"},
json={},
)const res = await fetch(
`https://web.moneyhash.io/api/v1.4/external/payments/intents/${intentId}/otp-resend/`,
{
method: "POST",
headers: {
"x-api-key": "<YOUR_API_KEY>",
"Content-Type": "application/json",
},
body: "{}",
},
);The response is the full intent response, so re-render your OTP screen from it exactly as you did the first time - re-reading the OTP length and expiry, neither of which the new code is obliged to match.
On the Payment API, resend is the client-side reset_otp_url returned in otp_data. That URL is the embed's mechanism, not a server API - if you are integrating server-side, use the External API endpoint above.
Whether a resend is available depends on the programme. On the Payment API, reset_otp_url is returned only when the programme supports resend - if it is absent, don't offer the button. A programme-side failure on the call itself comes back as 400.
Gate your "Resend code" button on a timer derived from the OTP expiry rather than offering it immediately, and handle a 400 by offering a fallback payment method rather than looping.
Earn: award points after a payment
Earn is the other half of loyalty, and its contract is short but easy to get wrong: it is asynchronous. The payment is already done by the time points are involved, so nothing in your checkout should wait on an earn.
You can trigger an earn two ways:
- Attach it to the payment. With the JavaScript SDK (v2.12.0+), pass
loyaltyData: { providerId, identifierType, identifier }toproceedWith,pay,submitFormorsubmitCvv, and the earn rides on that payment - including its refunds. - Call it yourself. From your backend, call the earn endpoint after the purchase - the right choice for server-side integrations and for purchases that did not go through MoneyHash checkout.
The server call responds immediately with 202 Accepted and a PENDING status. At that point MoneyHash has queued the earn, not completed it.
{
"amount": "150.00",
"currency": "SAR",
"loyalty": {
"provider_id": "<LOYALTY_CONNECTION_ID>",
"identifier": "966500000000",
"identifier_type": "PHONE_NUMBER"
},
"merchant_reference": "ORDER-2026-05-12-00481",
"webhook_url": "https://merchant.example.com/webhooks/moneyhash"
}{
"status": { "code": 202, "message": "success", "errors": [] },
"data": {
"id": "<EARN_ID>",
"status": "PENDING",
"provider_id": "<LOYALTY_CONNECTION_ID>",
"amount": "150.00",
"currency": "SAR",
"merchant_reference": "ORDER-2026-05-12-00481",
"created": "2026-05-12T14:32:11Z"
},
"count": 1,
"next": null,
"previous": null
}amount, currency, loyalty and merchant_reference are required; webhook_url is optional. Full schema: Create loyalty earn.
The result arrives as a webhook - loyalty.earn.successful or loyalty.earn.failed, carrying provider_reference_id and failure_reason. You can also read the current state at any time with GET /api/v1.4/loyalty/earn/{id}/ (Retrieve loyalty earn).
| Status | Meaning |
|---|---|
PENDING | Accepted and queued. The programme has not confirmed yet. |
EARNED | The programme credited the points. provider_reference_id carries its reference. |
FAILED | The programme rejected the earn. failure_reason says why. |
PENDING_REVERSE | You asked for a reversal and MoneyHash is calling the programme. |
REVERSED | The points were taken back. reversed_at and reverse_provider_reference_id are set. |
REVERSE_FAILED | The programme refused the reversal - for example, outside its reversal window. |
What your integration should wait on: nothing. Do not block checkout, order confirmation, or the customer's success screen on the earn result.
What your integration should do: store the returned id, reconcile against the webhook, and send a stable merchant_reference (your order ID) on every call - it is the idempotency key, so a retried request returns the existing record instead of granting points twice.
amount is the loyalty-eligible amount
It is not a payment amount - no money moves through this call. It is the figure the programme calculates points against, normally your order total.
Refunds and reversals
How points come back depends on which half of loyalty you are undoing.
| What happens | Effect on points |
|---|---|
| A redemption (burn) is refunded | The redeemed points are returned to the customer. Refund it like any other payment. |
| A burn fails or the programme times out after redeeming | MoneyHash reverses the redemption on its own. You do nothing extra. |
| A payment with an SDK-attached earn is refunded | The earned points are reversed automatically. You do nothing extra. |
| You need to take back points from a server-side earn | Call the reverse endpoint below. A server-side earn is its own record, not linked to a payment, so a refund cannot reverse it. |
{
"amount": "150.00"
}The rules are strict:
amountmust equal the original earn amount - a partial reversal is rejected with400.- The earn must be in
EARNED. Reversing anything else returns409. - The call is asynchronous. The status moves to
PENDING_REVERSE, thenREVERSEDorREVERSE_FAILED.
Full schema: Reverse loyalty earn.
Not by MoneyHash. A refund or reversal issued long after the original purchase can fall outside the window - you will see it fail (REVERSE_FAILED for an earn) rather than silently succeed.
Currency
Each loyalty programme decides which currencies it accepts, and the programmes available today operate in SAR. An intent in a currency the programme does not support will not offer its loyalty method, and an earn call in one is rejected at validation with a 400 such as "Currency USD is not supported by this provider."
Common errors and troubleshooting
No loyalty method appears on the intent +
Redemption is rejected immediately, before any OTP +
+ prefix, re-prompt for the number, or fall back to another payment method. An eligibility check catches this earlier where the programme supports one.A valid code is rejected by your own UI before submission +
otp_length on the External API, length under action_data.otp_data on the Payment API, otpLength in the JavaScript SDK - and size the input from that.
OTP_EXPIRED returned
+
/otp-resend/, and drive your countdown from the OTP expiry rather than a fixed timer.The retry prompt shows no error message, or the checkout hangs after a bad code +
previous_submission_status - null means first prompt, anything else means a retry. For a hang: you are not handling the failed state (next_action: "FAILED" or state: "TRANSACTION_FAILED"), which some programmes return instead of re-prompting.
400 returned from /otp-resend/
+
OTP_GENERIC_FAILURE, or no response from the programme
+
An earn shows PENDING and never changes in your system
+
loyalty.earn.successful / loyalty.earn.failed, and reconcile with GET /loyalty/earn/{id}/.Points granted twice for one order +
merchant_reference is not stable across retries. Send your order ID as merchant_reference on every call - it is the idempotency key.
400 or 409 when reversing an earn
+
400: the reverse amount is not equal to the original earn amount - partial reversals are not supported. 409: the earn is not in EARNED (it is still pending, already reversed, or failed).Related
- External API - the full state model the redemption flow runs on.
- Customers - attaching a customer record to the intents you create.
- Check loyalty eligibility, Create loyalty earn, Retrieve loyalty earn, Reverse loyalty earn - API reference.
Updated 1 day ago