---
url: https://laranex.vercel.app/go-myanmar-payments/drivers/wave-pay.md
description: >-
  Integrate Wave Pay in Go. Redirect payments with typed line items and verified
  callbacks.
---

# Wave Pay

Wave Pay (Pay with Wave API v1.3) sends the customer to Wave's payment page to pay with their WavePay wallet.

| Call | What it does | Returns |
|---|---|---|
| `wave.Initiate(ctx, &data)` | Redirect to Wave's payment page | [`*RedirectPayment`](#initiate-response) |
| `wave.HandleCallback(request)` | Verify the callback | [`*PaymentCallback`](#handlecallback-response) |

Wave Pay has no status API in this package: the callback is the only payment result.

[Responses](#responses) shows what Wave Pay puts in each result.

## How it works

Wave sends the customer back to your return URL and posts the result to your callback URL separately.

## Initiating a Payment

```go
import (
	"fmt"
	"net/http"

	myanmarpayments "github.com/laranex/go-myanmar-payments/v4"
	"github.com/laranex/go-myanmar-payments/v4/wavepay"
)

wave, err := wavepay.New(wavepay.Config{
	MerchantID:        "...",
	SecretKey:         "...",
	MerchantName:      "My Shop",
	BaseURL:           wavepay.ProductionURL,
	BaseRedirectURL:   wavepay.ProductionURL,
	TimeToLiveSeconds: 300,
}, nil)
if err != nil {
	return err
}

data := wavepay.PaymentData{
	OrderID:     fmt.Sprintf("ORDER_%d", order.ID),
	CallbackURL: "https://shop.test/payments/wave/callback",
	ReturnURL:   fmt.Sprintf("https://shop.test/orders/%d", order.ID),
	Description: fmt.Sprintf("Order #%d", order.ID),
	Items: []wavepay.Item{
		{Name: "Product A", Amount: myanmarpayments.Kyat(6000)},
		{Name: "Product B", Amount: myanmarpayments.Kyat(4000)},
	},
}

payment, err := wave.Initiate(ctx, &data)
if err != nil {
	return err
}

// Store data.MerchantReferenceID with the order: Initiate filled it in.

http.Redirect(w, r, payment.URL, http.StatusFound)
```

Payments post as JSON, in MMK, to `{BaseURL}/payment`; the customer goes to `{BaseRedirectURL}/authenticate?transaction_id=…`.

### wavepay.PaymentData

| Field | Type | Required | Rules |
|---|---|---|---|
| `OrderID` | `string` | Yes | Your order ID, at most 100 characters: letters, digits, `_` and `-` only. One order can have several payment attempts |
| `CallbackURL` | `string` | Yes | Absolute http or https URL that Wave posts the result to, at most 255 characters |
| `ReturnURL` | `string` | Yes | Absolute http or https URL Wave sends the customer back to, at most 255 characters. Not proof of payment |
| `Description` | `string` | Yes | Shown to the customer, at most 255 characters |
| `Items` | `[]wavepay.Item` | Yes | At least one item |
| `Amount` | `myanmarpayments.Amount` | No | Whole kyat, greater than 0 (Wave doesn't accept decimals). Unset charges the sum of the items. Wave only accepts MMK |
| `MerchantReferenceID` | `string` | No | Unique ID of this attempt, at most 100 characters: letters, digits, `_` and `-` only. Empty means a random ID |
| `MerchantName` | `string` | No | Business name shown on Wave's page for this payment. Empty uses `Config.MerchantName`; a name made only of whitespace is rejected |
| `TimeToLiveSeconds` | `*int` | No | Seconds the customer has to pay this payment, greater than 0, e.g. `seconds := 600` then `TimeToLiveSeconds: &seconds`. `nil` uses `Config.TimeToLiveSeconds`; `0` is rejected |
| `AdditionalField3`, `AdditionalField4`, `AdditionalField5` | `string` | No | Extra information, at most 50 characters each, sent as `additional_field_3`–`5` and echoed in the callback's `additionalField3`–`5`. Empty is not sent |

Length limits count UTF-8 bytes. `wavepay.Item` has a `Name` and an `Amount` in whole kyat, greater than 0. The items are summed with exact integer arithmetic, never floats; `data.ResolvedAmount()` returns the total that will be charged. Item names and amounts and the total are checked after the other fields; without items or `Amount`, `amount` reports `The amount field is required.`, a negative amount reports `The amount field must not be negative.` (`items.0.amount` for an item), and an item amount that is invalid or has decimals skips the total check.

### Per-Payment Merchant Name and Time to Live

`MerchantName` and `TimeToLiveSeconds` override the `wavepay.Config` values for one payment, including in the request hash.

```go
seconds := 600
data := wavepay.PaymentData{
	OrderID:           fmt.Sprintf("ORDER_%d", order.ID),
	CallbackURL:       "https://shop.test/payments/wave/callback",
	ReturnURL:         fmt.Sprintf("https://shop.test/orders/%d", order.ID),
	Description:       fmt.Sprintf("Order #%d", order.ID),
	Items:             []wavepay.Item{{Name: "Concert ticket", Amount: myanmarpayments.Kyat(25000)}},
	MerchantName:      "My Shop Events",
	TimeToLiveSeconds: &seconds,
}
```

### Merchant Reference ID

Wave rejects a reused `merchant_reference_id` (`409 Record already exists`), so every attempt, including a retry of the same order, needs a new one. Leave it empty to get a fresh random ID, and **store it**: every callback carries it as `merchantReferenceId`. `Initiate` takes a pointer and writes the generated ID to `data.MerchantReferenceID` once `data` passes validation.

## Handling Callbacks

```go
import (
	"net/http"

	myanmarpayments "github.com/laranex/go-myanmar-payments/v4"
)

// POST /payments/wave/callback
request, err := myanmarpayments.NewCallbackRequestFromHTTP(r)
if err != nil {
	http.Error(w, "invalid callback", http.StatusBadRequest)
	return
}
callback, err := wave.HandleCallback(request)
if err != nil {
	http.Error(w, "invalid callback", http.StatusBadRequest)
	return
}

if callback.IsSuccessful() {
	// callback.OrderID is your OrderID
	// callback.Raw["merchantReferenceId"] is the attempt's reference
	// callback.GatewayReference is Wave's transactionId
}

callback.Acknowledgement.Write(w)
```

`callback.OrderID` falls back to `merchantReferenceId` when Wave's `orderId` is missing, null or empty.

Wave hashes the values joined with no separator, so a matching `hashValue` alone doesn't pin down where one value ends and the next begins. After the hash matches, `amount` must be a decimal (e.g. `10000` or `10000.00`), at least one of `orderId` and `merchantReferenceId` must be present, and each one sent may only hold letters, digits, `_` and `-`; otherwise `HandleCallback` returns `*myanmarpayments.SignatureVerificationError` with `Wave Pay callback is malformed.`. New payments are held to the same ID rules, so every callback for a payment made with this package passes them.

## Responses

What Wave Pay puts in each field. See [Results](/go-myanmar-payments/references/results) and [PaymentCallback & Status](/go-myanmar-payments/references/payment-callback) for the full structs.

### `Initiate()` → `*myanmarpayments.RedirectPayment` {#initiate-response}

| Field / Method | Wave Pay value |
|---|---|
| `Flow()` | `FlowRedirect` |
| `OrderID` | Your `data.OrderID` |
| `URL` | `{BaseRedirectURL}/authenticate?transaction_id=…` (URL-encoded), e.g. `https://payments.wavemoney.io/authenticate?transaction_id=…` |
| `GatewayReference` | Wave `transaction_id`. Always set |
| `Raw` | Wave's `/payment` response: `message` (`success`), `transaction_id` |

The attempt's `MerchantReferenceID` is not on the result: read it from `data.MerchantReferenceID`.

### `HandleCallback()` → `*myanmarpayments.PaymentCallback` {#handlecallback-response}

| Field | Wave Pay value |
|---|---|
| `OrderID` | Wave `orderId`, falling back to `merchantReferenceId` when it is missing, null or empty |
| `Status` | `status` mapped, see [Statuses](#statuses) |
| `GatewayStatus` | Wave `status`, trimmed, e.g. `PAYMENT_CONFIRMED` |
| `GatewayReference` | Wave `transactionId` |
| `Amount` | Wave `amount`, e.g. `10000` |
| `Raw` | The verified body: `status`, `merchantId`, `orderId`, `merchantReferenceId`, `frontendResultUrl`, `backendResultUrl`, `initiatorMsisdn`, `amount`, `timeToLiveSeconds`, `paymentDescription`, `currency`, `additionalField1`–`5`, `transactionId`, `paymentRequestId`, `requestTime`, `hashValue` |
| `Acknowledgement` | HTTP `200`, empty body, `Content-Type: text/plain` |

## Statuses

Only `PAYMENT_CONFIRMED` means the customer paid.

| Wave `status` | `PaymentStatus` |
|---|---|
| `PAYMENT_CONFIRMED` | `StatusSuccessful` |
| `BILL_COLLECTION_FAILED`, `INSUFFICIENT_BALANCE`, `ACCOUNT_LOCKED` | `StatusFailed` |
| `PAYMENT_REQUEST_CANCELLED` | `StatusCanceled` |
| `TRANSACTION_TIMED_OUT`, `SCHEDULER_TRANSACTION_TIMED_OUT` | `StatusExpired` |
| anything else | `StatusUnknown` |

`SCHEDULER_TRANSACTION_TIMED_OUT` arrives up to 15 minutes after the time-to-live ends.

## Errors

| Call | Returns | When |
|---|---|---|
| `Initiate()` | `*InvalidPaymentDataError` | `data.Validate()` fails. Nothing is sent and `data` is left untouched. Item errors use `items.0.amount` keys |
| `Initiate()` | `*APIError` | Wave answers with an HTTP error, a `message` other than `success`, or no `transaction_id`. `GatewayMessage` is Wave's `errors` object as `field: messages` pairs sorted by field (each entry a list or a single message), else its `message`; `GatewayCode` is `VALIDATION_ERROR` whenever `errors` is present |
| `HandleCallback()` | `*SignatureVerificationError` | `Wave Pay callback hash verification failed.`: `hashValue` is missing, not a string or doesn't match, or a hashed field holds an object or array. `Wave Pay callback is malformed.`: the hash matches but `amount` isn't a decimal, both `orderId` and `merchantReferenceId` are empty, or either holds anything but letters, digits, `_` and `-` |

`HTTPStatus` tells Wave's rejections apart: `400` invalid hash, `404` unknown merchant, `409` reused reference, `422` validation (`GatewayCode` is `VALIDATION_ERROR`).
