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 itServer-side redemption
You useThe MoneyHash JavaScript SDK (v3.0.0+) or the Embedded ExperienceThe External API
Who renders the OTP inputMoneyHash, or your UI from the SDK stateYou
Who submits the OTPThe SDK, with submitOtp() from the OTP_FORM stateYour backend, with POST .../otp-authorization/
ResendBuilt into the embedYour backend, with POST .../otp-resend/
Use it whenYou already render MoneyHash checkout and want loyalty to appear as another methodYou 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.

POST /api/v1.4/external/payments/intent/
{
  "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"
}
FieldWhy it matters
payment_methodThe 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_numberThe customer's mobile number - see Identify the customer.
amount_currencyMust 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
  }
}
FieldMeaning
otp_lengthHow many digits the code has. Size your input to this value.
expires_after_secondsHow long the code is valid. Drive your countdown and your resend button from this.
previous_submission_statusnull 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"
  }
}
FieldMeaning
lengthHow many digits the code has. Size your input to this value.
expiry_minutesHow long the code is valid, in minutes (the External API reports the same value in seconds).
reset_otp_urlThe client-side resend URL. Returned only when the programme supports resend - its absence is the signal that resend is unavailable.
otp_retryWhether the customer may request another code for this transaction. false means don't offer a resend.
previous_submission_statusnull on the first prompt. On a retry prompt, a unified OTP error code - see Retry after a bad code.
Read the OTP length and expiry from the response. Never hard-code them.

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.

Burn and earn format the number differently

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.

POST /api/v1.4/loyalty/eligibility/
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.

POST /api/v1.4/external/payments/intents/{intent_id}/otp-authorization/
{
  "otp": "1234"
}
POST /api/v1.4/payments/intents/{intent_id}/otp-authorization/
{
  "otp": "1234"
}

What each surface gives you:

External APIPayment API
Submit OTPBody needs otp onlyBody needs otp only
previous_submission_status on retryYesYes
Server-side resendYesNo - client-side reset_otp_url
StatusRecommendedSupported 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.successful
  • intent.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_statusWhat to tell the customer
INVALID_OTPThe code was wrong. Let them re-enter it.
OTP_EXPIREDThe code timed out. Offer a resend.
OTP_ALREADY_USEDThe code was submitted before. Offer a resend.
OTP_STILL_VALIDReturned on a resend request while the previous code is still valid. Don't resend - let them use the code they have.
OTP_GENERIC_FAILUREThe programme failed for an unspecified reason. Offer a retry, and a fallback method.
A separate taxonomy

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:

POST /api/v1.4/external/payments/intents/{intent_id}/otp-resend/
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.

Resend is not guaranteed

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 } to proceedWith, pay, submitForm or submitCvv, 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.

POST /api/v1.4/loyalty/earn/
{
  "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).

StatusMeaning
PENDINGAccepted and queued. The programme has not confirmed yet.
EARNEDThe programme credited the points. provider_reference_id carries its reference.
FAILEDThe programme rejected the earn. failure_reason says why.
PENDING_REVERSEYou asked for a reversal and MoneyHash is calling the programme.
REVERSEDThe points were taken back. reversed_at and reverse_provider_reference_id are set.
REVERSE_FAILEDThe 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 happensEffect on points
A redemption (burn) is refundedThe redeemed points are returned to the customer. Refund it like any other payment.
A burn fails or the programme times out after redeemingMoneyHash reverses the redemption on its own. You do nothing extra.
A payment with an SDK-attached earn is refundedThe earned points are reversed automatically. You do nothing extra.
You need to take back points from a server-side earnCall the reverse endpoint below. A server-side earn is its own record, not linked to a payment, so a refund cannot reverse it.
POST /api/v1.4/loyalty/earn/{id}/reverse/
{
  "amount": "150.00"
}

The rules are strict:

  • amount must equal the original earn amount - a partial reversal is rejected with 400.
  • The earn must be in EARNED. Reversing anything else returns 409.
  • The call is asynchronous. The status moves to PENDING_REVERSE, then REVERSED or REVERSE_FAILED.

Full schema: Reverse loyalty earn.

Reversal windows are set by the programme

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 +
The intent currency is not one the programme supports, or no loyalty connection is active on the account. Check the intent currency, then confirm the connection in the dashboard.
Redemption is rejected immediately, before any OTP +
The mobile number is malformed or not enrolled in the programme. Check the + 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 +
Hard-coded OTP length. Read it from the OTP state - 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 +
The code timed out before submission. Offer a resend with /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 +
Two causes. For a missing message: you are branching on something other than 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/ +
The programme rejected the resend, or resend is unavailable for this transaction. Do not loop - offer a fallback payment method.
OTP_GENERIC_FAILURE, or no response from the programme +
A programme-side timeout or technical error. Retry once; if the payment does not complete, the redemption is reversed automatically.
An earn shows PENDING and never changes in your system +
You are relying on the API response instead of the webhook. Consume 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


Did this page help you?