EPaySe
AI Tools
New

Claude Code Integration Skill

Paste this file into your project and Claude Code will automatically know how to integrate EPaySe — including authentication, transaction creation, webhook processing, refunds, and payment methods. Supports PHP, Node.js, Python, and Go.

Get Started in 3 Steps

1

Copy the skill content

Click the copy button on the code block below to copy the entire skill file to your clipboard.

2

Create the file in your project

Create the file .claude/skills/epayse-integration/SKILL.md in your project root directory (a folder named epayse-integration with a SKILL.md file inside).

Bash
mkdir -p .claude/skills/epayse-integration && touch .claude/skills/epayse-integration/SKILL.md
3

Start using Claude Code

Paste the content and start using Claude Code. It will automatically detect the skill and use it when you ask about EPaySe integration.

Skill Content

Copy the entire content below and paste it into .claude/skills/epayse-integration/SKILL.md

MARKDOWN
epayse-integration/SKILL.md
---
name: epayse-integration
description: 'ALWAYS use this skill when the user mentions EPaySe, epayse, or EPaySe payment gateway — it contains critical integration details you cannot know otherwise, including the exact HMAC signature format (signature version 3: METHOD|PATH|TIMESTAMP|NONCE|BODY with NO leading slash, plus the headers X-Nonce and X-Signature-Version: 3), webhook verification (timestamp+payload with NO DOT separator), correct sandbox URL (sandbox-gateway.epayse.com), amount format (dollars not cents), and ready-to-use client classes for PHP, Node.js, Python, and Go. You MUST consult this skill before writing any EPaySe integration code, generating HMAC signatures for EPaySe, building EPaySe webhook handlers, processing EPaySe refunds, or answering questions about EPaySe test cards, checkout modes (redirect/iframe), or payment method filtering. Without this skill you will get subtle details wrong (like using a dot in webhook signatures, or a leading slash in HMAC paths) that cause hard-to-debug 401 errors. Not for other payment providers or for general payment/HMAC concepts unrelated to EPaySe.'
---

# EPaySe Payment Gateway Integration

## Success Criteria

A successful EPaySe integration should:

- Generate correct HMAC-SHA256 signatures (signature version 3: METHOD|PATH|TIMESTAMP|NONCE|BODY, path WITHOUT leading slash)
- Handle all webhook events with proper signature verification (timestamp + payload, NO DOT separator)
- Use `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` for PHP JSON encoding
- Send `Idempotency-Key` header on all POST/PUT/PATCH requests
- Validate timestamps within 5-minute drift window
- Use dollar amounts (49.99) not cents (4999) for the `amount` field
- Send customer's real IP (not server IP) in `customerDetails.ip`

## Reference Documentation

For complete, up-to-date API specs, fetch: https://docs.epayse.com/llms.txt

## Quick Start (5 minutes)

1. Get API credentials from EPaySe dashboard (Client ID + Secret Key)
2. Implement HMAC-SHA256 signature generation
3. Create transaction via POST /api/v1/transaction/create
4. Redirect customer to the returned checkoutUrl
5. Set up webhook endpoint to receive payment notifications

## Base URLs

| Environment | URL                                  |
| ----------- | ------------------------------------ |
| Sandbox     | `https://sandbox-gateway.epayse.com` |
| Production  | `https://gateway.epayse.com`         |

API prefix: `/api/v1`

---

## Step 1: Authentication (HMAC-SHA256)

Every API request requires these headers:

| Header                | Description                                                                                                        |
| --------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `Content-Type`        | `application/json`                                                                                                 |
| `X-Api-Key-Id`        | Your API key id                                                                                                    |
| `X-Timestamp`         | Unix timestamp (seconds)                                                                                           |
| `X-Nonce`             | A fresh random value for EVERY request (a UUID is fine); it is part of the signature                               |
| `X-Signature`         | HMAC-SHA256 signature (lowercase hex, no prefix)                                                                   |
| `X-Signature-Version` | Always `3`. Requests without it, or with any other value, are rejected with `401` `SIGNATURE_VERSION_UNSUPPORTED`. |
| `Idempotency-Key`     | Required for POST/PUT/PATCH requests. One key per logical operation (see "Idempotency-Key" below).                 |

### Signature Format

```
HMAC-SHA256(secret_key, "METHOD|PATH|TIMESTAMP|NONCE|BODY")
```

**Critical rules:**

- PATH must NOT have a leading slash (use `api/v1/transaction/create` not `/api/v1/transaction/create`); if the request has a query string, append it as `?a=1&b=2` with the keys sorted alphabetically and values percent-encoded per RFC 3986 (a space is `%20`, never `+`)
- Query strings are signed the way the server normalises them, not as you typed them: keys sorted, values RFC 3986-encoded, an array like `a[]=1&a[]=2` becomes `a%5B0%5D=1&a%5B1%5D=2` (re-indexed), a key without `=` (`flag`) becomes `flag=`, and a repeated key keeps only its last value. Sign that normalised form (PHP: `Symfony\Component\HttpFoundation\Request::normalizeQueryString($query)`); if you send arrays or valueless keys and sign them exactly as sent, the request gets 401 `Invalid signature`. Simple `key=value` parameters need no special handling
- NONCE is the exact value you send in `X-Nonce`; changing it after signing invalidates the signature (this is what stops a captured request from being replayed)
- BODY is empty string for GET requests
- BODY numbers must survive a decode/encode round trip: the server re-encodes your JSON, so `"amount":100.00` is signed as `"amount":100` and a body signed as `100.00` is rejected with 401 `Invalid signature`. Send amounts as strings (`"amount":"100.00"`, as the examples do) or sign the already-normalised JSON (PHP `json_encode(json_decode($body), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)`)
- BODY is the JSON string for POST/PUT requests. The server parses it and re-encodes it compactly with unescaped slashes and unescaped unicode before verifying, so sign that compact form: no extra whitespace, no `\/`, no `\uXXXX` for non-ASCII characters, and no `<`/`>`/`&` escapes. Numbers must round-trip unchanged through a decode and encode (send `99.99` or `100`, not `100.00`; or send amounts as the form your language prints back identically)
- Timestamp must be within 5 minutes of server time
- JSON body should use unescaped slashes and unescaped unicode for consistency

### Idempotency-Key

One key per **logical operation**, not per request body:

- A retry of the SAME operation (timeout, 5xx, network drop) must reuse the SAME key, so the server returns the first result instead of acting twice. A deterministic hash of method + endpoint + body + a salt that identifies the operation works well.
- Two DIFFERENT operations that happen to have an identical body must get different keys. The classic case is refunds: two partial refunds of the same amount on the same transaction with the same reason hash to the same key, and the second one is silently answered with the first one's result (response header `X-Idempotency-Replayed: true`) while no second refund is created. Put your own refund sequence number or refund record id into the hash.
- Treat `X-Idempotency-Replayed: true` as "already done earlier", never as a fresh success: do not record a second refund/payment on your side because of it.

### PHP

```php
class EPaySeClient
{
    private string $baseUrl;
    private string $clientId;
    private string $secretKey;

    public function __construct(string $baseUrl, string $clientId, string $secretKey)
    {
        $this->baseUrl = rtrim($baseUrl, '/');
        $this->clientId = $clientId;
        $this->secretKey = $secretKey;
    }

    /**
     * Generate HMAC-SHA256 signature.
     *
     * @param string $method HTTP method (GET, POST, etc.)
     * @param string $path   API path WITHOUT leading slash (e.g., "api/v1/transaction/create")
     * @param int    $timestamp Unix timestamp
     * @param string $nonce  The exact value sent in X-Nonce
     * @param string $body   JSON body (empty string for GET)
     * @return string Hex-encoded HMAC signature
     */
    public function generateSignature(string $method, string $path, int $timestamp, string $nonce, string $body = ''): string
    {
        $dataToSign = sprintf(
            '%s|%s|%s|%s|%s',
            strtoupper($method),
            ltrim($path, '/'),
            $timestamp,
            $nonce,
            $body
        );

        return hash_hmac('sha256', $dataToSign, $this->secretKey);
    }

    /**
     * Make an authenticated API request.
     */
    public function request(string $method, string $path, array $data = []): array
    {
        $timestamp = time();
        $body = ($method === 'GET') ? '' : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $nonce = bin2hex(random_bytes(16));
        $signature = $this->generateSignature($method, $path, $timestamp, $nonce, $body);

        $headers = [
            'Content-Type: application/json',
            'X-Api-Key-Id: ' . $this->clientId,
            'X-Timestamp: ' . $timestamp,
            'X-Nonce: ' . $nonce,
            'X-Signature: ' . $signature,
            'X-Signature-Version: 3',
        ];

        if ($method !== 'GET') {
            $headers[] = 'Idempotency-Key: ' . hash('sha256', $this->clientId . $path . $body);
        }

        $url = $this->baseUrl . '/' . ltrim($path, '/');

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_TIMEOUT => 30,
        ]);

        if ($method === 'POST') {
            curl_setopt($ch, CURLOPT_POST, true);
            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        }

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($error) {
            throw new \RuntimeException("cURL error: $error");
        }

        $decoded = json_decode($response, true);

        if ($httpCode >= 400) {
            throw new \RuntimeException(
                sprintf('API error %d: %s', $httpCode, $decoded['message'] ?? $response)
            );
        }

        return $decoded;
    }
}
```

### Node.js

```javascript
const crypto = require('crypto');

class EPaySeClient {
  constructor(baseUrl, clientId, secretKey) {
    this.baseUrl = baseUrl.replace(/\/$/, '');
    this.clientId = clientId;
    this.secretKey = secretKey;
  }

  /**
   * Generate HMAC-SHA256 signature.
   * @param {string} method - HTTP method
   * @param {string} path - API path WITHOUT leading slash
   * @param {number} timestamp - Unix timestamp (seconds)
   * @param {string} nonce - The exact value sent in X-Nonce
   * @param {string} body - JSON body (empty string for GET)
   * @returns {string} Hex-encoded signature
   */
  generateSignature(method, path, timestamp, nonce, body = '') {
    const dataToSign = [
      method.toUpperCase(),
      path.replace(/^\//, ''),
      timestamp.toString(),
      nonce,
      body,
    ].join('|');

    return crypto.createHmac('sha256', this.secretKey).update(dataToSign).digest('hex');
  }

  /**
   * Make an authenticated API request.
   */
  async request(method, path, data = null) {
    const timestamp = Math.floor(Date.now() / 1000);
    const body = method === 'GET' ? '' : JSON.stringify(data);
    const nonce = crypto.randomBytes(16).toString('hex');
    const signature = this.generateSignature(method, path, timestamp, nonce, body);

    const url = new URL(`/${path.replace(/^\//, '')}`, this.baseUrl);

    const headers = {
      'Content-Type': 'application/json',
      'X-Api-Key-Id': this.clientId,
      'X-Timestamp': timestamp.toString(),
      'X-Nonce': nonce,
      'X-Signature': signature,
      'X-Signature-Version': '3',
    };

    if (method !== 'GET') {
      headers['Idempotency-Key'] = crypto
        .createHash('sha256')
        .update(this.clientId + path + body)
        .digest('hex');
    }

    const response = await fetch(url.toString(), {
      method,
      headers,
      body: method === 'GET' ? undefined : body,
    });

    const result = await response.json();

    if (!response.ok) {
      throw new Error(`API error ${response.status}: ${result.message || JSON.stringify(result)}`);
    }

    return result;
  }
}

module.exports = EPaySeClient;
```

### Python

```python
import hashlib
import hmac
import json
import time
import uuid
from typing import Any, Optional

import requests


class EPaySeClient:
    def __init__(self, base_url: str, client_id: str, secret_key: str):
        self.base_url = base_url.rstrip("/")
        self.client_id = client_id
        self.secret_key = secret_key

    def generate_signature(
        self, method: str, path: str, timestamp: int, nonce: str, body: str = ""
    ) -> str:
        """
        Generate HMAC-SHA256 signature.

        Args:
            method: HTTP method (GET, POST, etc.)
            path: API path WITHOUT leading slash
            timestamp: Unix timestamp (seconds)
            nonce: The exact value sent in X-Nonce
            body: JSON body (empty string for GET)

        Returns:
            Hex-encoded HMAC-SHA256 signature
        """
        data_to_sign = "|".join([
            method.upper(),
            path.lstrip("/"),
            str(timestamp),
            nonce,
            body,
        ])

        return hmac.new(
            self.secret_key.encode("utf-8"),
            data_to_sign.encode("utf-8"),
            hashlib.sha256,
        ).hexdigest()

    def request(
        self, method: str, path: str, data: Optional[dict] = None
    ) -> dict[str, Any]:
        """Make an authenticated API request."""
        timestamp = int(time.time())
        body = "" if method == "GET" else json.dumps(data, separators=(",", ":"), ensure_ascii=False)
        nonce = uuid.uuid4().hex
        signature = self.generate_signature(method, path, timestamp, nonce, body)

        headers = {
            "Content-Type": "application/json",
            "X-Api-Key-Id": self.client_id,
            "X-Timestamp": str(timestamp),
            "X-Nonce": nonce,
            "X-Signature": signature,
            "X-Signature-Version": "3",
        }

        if method != "GET":
            idempotency_input = self.client_id + path + body
            headers["Idempotency-Key"] = hashlib.sha256(
                idempotency_input.encode("utf-8")
            ).hexdigest()

        url = f"{self.base_url}/{path.lstrip('/')}"

        response = requests.request(
            method=method,
            url=url,
            headers=headers,
            data=body.encode("utf-8") if method != "GET" else None,  # bytes: requests would encode a str as Latin-1
            timeout=30,
        )

        result = response.json()

        if not response.ok:
            raise Exception(
                f"API error {response.status_code}: "
                f"{result.get('message', response.text)}"
            )

        return result
```

### Go

```go
package epayse

import (
	"bytes"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"
)

type Client struct {
	BaseURL   string
	ClientID  string
	SecretKey string
	HTTP      *http.Client
}

func NewClient(baseURL, clientID, secretKey string) *Client {
	return &Client{
		BaseURL:   strings.TrimRight(baseURL, "/"),
		ClientID:  clientID,
		SecretKey: secretKey,
		HTTP:      &http.Client{Timeout: 30 * time.Second},
	}
}

// GenerateSignature creates an HMAC-SHA256 signature (signature version 3).
// path must NOT have a leading slash.
// nonce is the exact value sent in X-Nonce.
// body is empty string for GET requests.
func (c *Client) GenerateSignature(method, path string, timestamp int64, nonce, body string) string {
	dataToSign := fmt.Sprintf("%s|%s|%d|%s|%s",
		strings.ToUpper(method),
		strings.TrimLeft(path, "/"),
		timestamp,
		nonce,
		body,
	)

	mac := hmac.New(sha256.New, []byte(c.SecretKey))
	mac.Write([]byte(dataToSign))
	return hex.EncodeToString(mac.Sum(nil))
}

// Request makes an authenticated API request.
func (c *Client) Request(method, path string, data interface{}) (map[string]interface{}, error) {
	timestamp := time.Now().Unix()

	var body string
	var bodyReader io.Reader
	if method != "GET" && data != nil {
		// Sign the compact form the server re-encodes to: Go's default encoder escapes <, > and &
		// (so "?a=1&b=2" would become "\u0026"), which makes the signature mismatch.
		var buf bytes.Buffer
		enc := json.NewEncoder(&buf)
		enc.SetEscapeHTML(false)
		if err := enc.Encode(data); err != nil {
			return nil, fmt.Errorf("marshal error: %w", err)
		}
		jsonBytes := bytes.TrimRight(buf.Bytes(), "\n")
		body = string(jsonBytes)
		bodyReader = bytes.NewReader(jsonBytes)
	}

	nonceBytes := make([]byte, 16)
	if _, err := rand.Read(nonceBytes); err != nil {
		return nil, fmt.Errorf("nonce error: %w", err)
	}
	nonce := hex.EncodeToString(nonceBytes)

	signature := c.GenerateSignature(method, path, timestamp, nonce, body)

	url := fmt.Sprintf("%s/%s", c.BaseURL, strings.TrimLeft(path, "/"))
	req, err := http.NewRequest(method, url, bodyReader)
	if err != nil {
		return nil, fmt.Errorf("request creation error: %w", err)
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Api-Key-Id", c.ClientID)
	req.Header.Set("X-Timestamp", fmt.Sprintf("%d", timestamp))
	req.Header.Set("X-Nonce", nonce)
	req.Header.Set("X-Signature", signature)
	req.Header.Set("X-Signature-Version", "3")

	if method != "GET" {
		h := sha256.New()
		h.Write([]byte(c.ClientID + path + body))
		req.Header.Set("Idempotency-Key", hex.EncodeToString(h.Sum(nil)))
	}

	resp, err := c.HTTP.Do(req)
	if err != nil {
		return nil, fmt.Errorf("request error: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("read response error: %w", err)
	}

	var result map[string]interface{}
	if err := json.Unmarshal(respBody, &result); err != nil {
		return nil, fmt.Errorf("JSON parse error: %w", err)
	}

	if resp.StatusCode >= 400 {
		msg, _ := result["message"].(string)
		return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, msg)
	}

	return result, nil
}
```

---

## Step 2: Create Transaction

**Endpoint:** `POST /api/v1/transaction/create`

### Required Fields

| Field                         | Type   | Description                                                                                                            |
| ----------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------- |
| `ref`                         | string | Unique order reference (max 250 chars). Must be unique per merchant.                                                   |
| `amount`                      | number | Amount in dollars with up to 2 decimal places (e.g., 10.00, NOT 1000 cents)                                            |
| `currency`                    | string | ISO 4217 currency code (e.g., "USD")                                                                                   |
| `websiteUrl`                  | string | Your website URL: publicly accessible, no localhost, AND an approved website of your merchant account (see note below) |
| `redirectUrl`                 | string | Success redirect URL (must be publicly accessible)                                                                     |
| `cancelUrl`                   | string | Cancel redirect URL (must be publicly accessible)                                                                      |
| `description`                 | string | Transaction description (max 3000 chars)                                                                               |
| `customerDetails.firstName`   | string | Customer first name                                                                                                    |
| `customerDetails.lastName`    | string | Customer last name                                                                                                     |
| `customerDetails.email`       | string | Customer email                                                                                                         |
| `customerDetails.country`     | string | ISO 3166-1 alpha-2 country code                                                                                        |
| `customerDetails.ip`          | string | Customer's real IP address (NOT your server IP)                                                                        |
| `customerDetails.phoneCode`   | string | ISO alpha-2 country code for phone                                                                                     |
| `customerDetails.phoneNumber` | string | Customer phone number                                                                                                  |

### Optional Fields

| Field                    | Type   | Description                                                 |
| ------------------------ | ------ | ----------------------------------------------------------- |
| `metadata`               | json   | Custom key-value data, returned in webhooks                 |
| `paymentMethods`         | json   | Whitelist of payment method keys                            |
| `paymentMethodsFilter`   | json   | Blacklist of payment method keys                            |
| `paymentMethodsSorter`   | json   | Custom display order                                        |
| `preferredPaymentMethod` | string | Pre-select a payment method on checkout                     |
| `products`               | array  | Line items: name, quantity, image, amount, description, url |
| `billingDetails`         | object | firstName, lastName, street, city, state, zipCode, country  |
| `shippingDetails`        | object | Same structure as billingDetails                            |
| `brandName`              | string | Custom brand name on checkout                               |
| `colors`                 | object | Custom checkout colors                                      |
| `logoSource`             | string | URL to custom logo                                          |
| `expiresAt`              | string | Expiration datetime (format: Y-m-d H:i:s, default: 3 hours) |

### Validation rules that surprise integrators

- **`websiteUrl` must belong to an approved website.** The domain has to be added under Settings → Websites in the merchant dashboard and approved. Otherwise the call fails with `Domain '<host>' is not in your list of allowed websites…`. Platform plugins send the shop's own home URL here, so the shop's public domain (including an ngrok/staging domain while developing) is the one to register, not `localhost`.
- **Amount range.** `amount` must be at least 1 in the transaction currency and has at most 2 decimal places. There is also a per-transaction maximum that is configured per merchant account (ask support for yours); an amount above it is rejected with `422`. Check the cart total before creating the transaction and show the buyer a clear "order total is outside the accepted range" message instead of a generic payment error.
- **Currency.** Send a currency that is supported for your account (USD is the one exercised in sandbox); an unsupported currency is a `422` on the `currency` field. A shop plugin should hide the payment method for carts in other currencies instead of failing at Place Order.
- **The `message` is generic; the detail is in `errors`.** A validation failure answers `422` with `"message": "Invalid request"` and an `errors` object keyed by field name. Read and log `errors` (field names and messages, never your signature or secret) so you can tell an amount problem from a currency, domain or missing-field problem; showing only `message` hides the cause.

### PHP

```php
$client = new EPaySeClient(
    'https://sandbox-gateway.epayse.com',
    'your-client-id',
    'your-secret-key'
);

$response = $client->request('POST', 'api/v1/transaction/create', [
    'ref' => 'ORDER-' . time(),
    'amount' => 49.99,
    'currency' => 'USD',
    'websiteUrl' => 'https://yourstore.com',
    'redirectUrl' => 'https://yourstore.com/payment/success',
    'cancelUrl' => 'https://yourstore.com/payment/cancel',
    'description' => 'Order #12345',
    'customerDetails' => [
        'firstName' => 'John',
        'lastName' => 'Doe',
        'email' => '[email protected]',
        'country' => 'US',
        'ip' => $_SERVER['REMOTE_ADDR'],
        'phoneCode' => 'US',
        'phoneNumber' => '5551234567',
    ],
    'metadata' => ['orderId' => '12345', 'source' => 'web'],
]);

// Redirect customer to checkout
$checkoutUrl = 'https://sandbox-gateway.epayse.com' . $response['data']['checkoutUrl'];
header("Location: $checkoutUrl");
exit;
```

### Node.js

```javascript
const client = new EPaySeClient(
  'https://sandbox-gateway.epayse.com',
  'your-client-id',
  'your-secret-key'
);

const response = await client.request('POST', 'api/v1/transaction/create', {
  ref: `ORDER-${Date.now()}`,
  amount: 49.99,
  currency: 'USD',
  websiteUrl: 'https://yourstore.com',
  redirectUrl: 'https://yourstore.com/payment/success',
  cancelUrl: 'https://yourstore.com/payment/cancel',
  description: 'Order #12345',
  customerDetails: {
    firstName: 'John',
    lastName: 'Doe',
    email: '[email protected]',
    country: 'US',
    ip: req.ip, // Express.js: customer's real IP
    phoneCode: 'US',
    phoneNumber: '5551234567',
  },
  metadata: { orderId: '12345', source: 'web' },
});

// Redirect customer to checkout
const checkoutUrl = `https://sandbox-gateway.epayse.com${response.data.checkoutUrl}`;
res.redirect(checkoutUrl);
```

### Python

```python
client = EPaySeClient(
    base_url="https://sandbox-gateway.epayse.com",
    client_id="your-client-id",
    secret_key="your-secret-key",
)

response = client.request("POST", "api/v1/transaction/create", {
    "ref": f"ORDER-{int(time.time())}",
    "amount": 49.99,
    "currency": "USD",
    "websiteUrl": "https://yourstore.com",
    "redirectUrl": "https://yourstore.com/payment/success",
    "cancelUrl": "https://yourstore.com/payment/cancel",
    "description": "Order #12345",
    "customerDetails": {
        "firstName": "John",
        "lastName": "Doe",
        "email": "[email protected]",
        "country": "US",
        "ip": request.remote_addr,  # Flask: customer's real IP
        "phoneCode": "US",
        "phoneNumber": "5551234567",
    },
    "metadata": {"orderId": "12345", "source": "web"},
})

# Redirect customer to checkout
checkout_url = f"https://sandbox-gateway.epayse.com{response['data']['checkoutUrl']}"
return redirect(checkout_url)
```

### Go

```go
client := epayse.NewClient(
    "https://sandbox-gateway.epayse.com",
    "your-client-id",
    "your-secret-key",
)

response, err := client.Request("POST", "api/v1/transaction/create", map[string]interface{}{
    "ref":         fmt.Sprintf("ORDER-%d", time.Now().Unix()),
    "amount":      49.99,
    "currency":    "USD",
    "websiteUrl":  "https://yourstore.com",
    "redirectUrl": "https://yourstore.com/payment/success",
    "cancelUrl":   "https://yourstore.com/payment/cancel",
    "description": "Order #12345",
    "customerDetails": map[string]string{
        "firstName":   "John",
        "lastName":    "Doe",
        "email":       "[email protected]",
        "country":     "US",
        "ip":          r.RemoteAddr, // net/http: customer's real IP
        "phoneCode":   "US",
        "phoneNumber": "5551234567",
    },
    "metadata": map[string]string{"orderId": "12345", "source": "web"},
})
if err != nil {
    log.Fatal(err)
}

// Redirect customer to checkout
data := response["data"].(map[string]interface{})
checkoutUrl := "https://sandbox-gateway.epayse.com" + data["checkoutUrl"].(string)
http.Redirect(w, r, checkoutUrl, http.StatusFound)
```

### Success Response

```json
{
  "status": "SUCCESS",
  "message": "Transaction created successfully",
  "data": {
    "transactionId": "01kd096yg112e40pcyfwsahyev",
    "checkoutUrl": "/checkout/01kd096yg112e40pcyfwsahyev?expires=..."
  }
}
```

`checkoutUrl` is a **signed, expiring** URL: keep the whole value including `?expires=...&signature=...`. A bare `/checkout/{transactionId}` without them is rejected with `403 Unauthorized Access`. If the buyer comes back to retry an order that already has a transaction, reuse the stored signed URL while it is still valid and the order total/currency have not changed; otherwise create a new transaction (with a new `ref`) instead of rebuilding the URL yourself.

---

## Step 3: Handle Checkout

### Redirect Mode (recommended)

After creating a transaction, redirect the customer to the returned `checkoutUrl`:

```
https://sandbox-gateway.epayse.com/checkout/{transactionId}?expires=...
```

The customer completes payment on the EPaySe hosted checkout page, then is redirected back to your `redirectUrl` (success) or `cancelUrl` (cancel).

### Iframe Mode

Use `POST /api/v1/transaction/create-with-iframe` (same request body). The response includes iframe embed data:

```json
{
  "status": "SUCCESS",
  "data": {
    "transactionId": "01kd096yg112e40pcyfwsahyev",
    "checkoutUrl": "/checkout/01kd096yg112e40pcyfwsahyev?expires=...",
    "iframe": {
      "fullCheckoutUrl": "https://sandbox-gateway.epayse.com/checkout/01kd096yg112e40pcyfwsahyev?signature=...",
      "paymentMethods": [
        {
          "key": "card_visa",
          "name": "Visa",
          "url": "https://sandbox-gateway.epayse.com/checkout-field/01kd096yg112e40pcyfwsahyev/card_visa?signature=...",
          "embedCode": "<iframe src='...' />"
        }
      ],
      "expiresAt": "2026-03-09T17:30:00Z",
      "postMessageEvents": [
        "epayse:payment:success",
        "epayse:payment:failed",
        "epayse:payment:cancel",
        "epayse:height:change",
        "epayse:theme:sync"
      ]
    }
  }
}
```

Embed the iframe and listen for postMessage events. The widgets post these message types (the `type` field), and `postMessageEvents` in the response lists them:

| `type`                   | When                                            | Useful fields                                                     |
| ------------------------ | ----------------------------------------------- | ----------------------------------------------------------------- |
| `epayse:payment:success` | The buyer's payment succeeded inside the widget | `transactionId`, `status`, `message`, `redirectUrl`               |
| `epayse:payment:failed`  | The payment was declined or failed              | `transactionId`, `status`, `message`, `code`, `cooldownRemaining` |
| `epayse:height:change`   | The widget content height changed               | `height` (pixels)                                                 |

`epayse:payment:cancel` and `epayse:theme:sync` are declared in `postMessageEvents` but no widget emits them yet, so do not depend on either. The older message types `PAYMENT_SUCCESS`, `PAYMENT_ERROR` and `IFRAME_HEIGHT` are still posted next to the new ones so existing integrations keep working; new code should use the `epayse:*` names. A widget can also navigate the whole page to your `redirectUrl` after success, and PSPs that require a hosted page keep a deliberate top-level redirect even in iframe mode.

Messages are posted with target origin `*` (a widget cannot know its parent's origin), so the payload is a small fixed allowlist (ids, status, message, code, redirect URL, height) and never card, customer or fraud data. Always check `event.origin` and `event.source`, use messages only for UI (resizing, a "processing" state), and confirm payment from the webhook. Never fulfil an order from a message.

```javascript
window.addEventListener('message', (event) => {
  // Verify the origin matches your EPaySe gateway URL AND the message comes from your iframe
  if (event.origin !== 'https://sandbox-gateway.epayse.com') return;
  if (event.source !== iframe.contentWindow) return;

  const { type, height, message } = event.data || {};
  // UI only. Never fulfil the order here;
  // wait for the payment.paid webhook (or GET /api/v1/transaction/{id}).
  if (type === 'epayse:height:change' && height) {
    iframe.style.height = height + 'px';
  } else if (type === 'epayse:payment:success') {
    showProcessing(); // then poll your own order status, which the webhook updates
  } else if (type === 'epayse:payment:failed') {
    showError(message);
  }
});
```

### Redirect URL Parameters

After payment, the customer is redirected with query parameters:

**Success redirect:** `status`, `ref`, `transactionId`, `amount`, `paidAmount`, `currency`, `attemptCount`, `maxAttempts`, `message`

**Cancel redirect:** `status=CANCEL`, `ref`, `transactionId`

**IMPORTANT:** Do NOT trust redirect parameters for order fulfillment. Always verify payment status via webhook or the GET transaction API.

**Return URLs must be side-effect free.** `redirectUrl` and `cancelUrl` are plain `GET` endpoints that can be hit by anyone with the URL, and the gateway itself may request your `cancelUrl` shortly after the transaction is created (before the buyer has done anything). Never cancel an order, restock, void a transaction or change any state when one of these URLs is requested. Treat them as display-only: look the order up, show a message or redirect to the right page, and let the webhook (or `GET /transaction/{id}`) move the order. A cancel page that cancels the order will cancel every order the moment the gateway probes it.

---

## Step 4: Webhook Processing

EPaySe sends POST requests to your webhook URL when events occur.

### Webhook Headers

| Header                 | Description                       |
| ---------------------- | --------------------------------- |
| `Content-Type`         | `application/json`                |
| `X-Webhook-Event-Id`   | Unique event ID                   |
| `X-Webhook-Event-Type` | Event type (e.g., `payment.paid`) |
| `X-Webhook-Timestamp`  | Unix timestamp                    |
| `X-Webhook-Signature`  | HMAC-SHA256 signature             |
| `User-Agent`           | `EPaySe-Webhooks/1.0`             |

### Webhook Events

| Event                     | Description                                        |
| ------------------------- | -------------------------------------------------- |
| `payment.paid`            | Payment completed successfully                     |
| `payment_attempt.failed`  | Payment failed                                     |
| `payment.requires_action` | Payment needs extra buyer action (e.g. 3-D Secure) |
| `payment.expired`         | Transaction expired before it was paid             |
| `refund.created`          | Refund initiated                                   |
| `refund.pending_approval` | Refund is waiting for manual approval              |
| `refund.approved`         | Refund was approved                                |
| `refund.rejected`         | Refund was rejected; no money is returned          |
| `refund.completed`        | Refund completed                                   |
| `refund.failed`           | Refund failed                                      |
| `dispute.created`         | Dispute/chargeback created                         |

Return `200` for event types you do not handle. A `400` is treated as a permanent failure and the event is not retried, so rejecting an unfamiliar type makes you lose the events you do care about when new types appear.

### Webhook Payload

```json
{
  "id": "evt_01HQKZ...",
  "object": "event",
  "type": "payment.paid",
  "created": "2025-01-16T08:19:55Z",
  "livemode": true,
  "api_key_id": "01abc...",
  "data": {
    "object": {
      "id": "txn_01HQKZ...",
      "object": "transaction",
      "merchant_ref": "ORDER-2025-001",
      "amount": 100.0,
      "currency": "USD",
      "status": "SUCCESS",
      "paid_amount": 100.0,
      "net_amount": 97.0,
      "refunded_amount": 0,
      "customer": {
        "email": "[email protected]",
        "name": "John Doe"
      },
      "metadata": { "orderId": "123" },
      "created_at": "2025-01-16T08:19:55Z",
      "updated_at": "2025-01-16T08:19:55Z"
    }
  },
  "api_version": "2024-01-01"
}
```

### Signature Verification

**CRITICAL: The webhook signature is `HMAC-SHA256(webhookSecret, timestamp + payload)` with NO DOT between timestamp and payload.**

```
HMAC-SHA256(webhook_secret, TIMESTAMP + RAW_JSON_BODY)
```

This is different from the API request signature format. Do NOT add a dot separator.

### Receiving webhooks reliably

- **One secret per webhook endpoint.** Each webhook you create in the dashboard has its own signing secret, shown ONCE when it is created and not retrievable afterwards. Copy it straight into your configuration; if you lose it, create a new webhook and update your configuration. A secret from a different webhook of the same merchant will fail verification.
- **Never process an unsigned webhook.** If no webhook secret is configured, reject the request (for example `503`) rather than skipping verification. Anyone can POST to a public URL, and "payment completed" is the payload an attacker would send.
- **Match the transaction, then check it.** In the payload `data.object.id` is the transaction id with a `txn_` prefix, while the API returns `transactionId` without it; strip the prefix when you look up your order. Refund and attempt events carry the transaction in `data.object.transaction_id`, and the refund/attempt itself in `data.object.id`. Compare `amount` and `currency` with your order and require them to be present.
- **De-duplicate on the event id** (`id` such as `evt_...`, also sent as `X-Webhook-Event-Id`), not on transaction + type + time, so two different refunds of one transaction in the same second are both processed. Record an event as processed only AFTER you handled it successfully; if you record it first and then fail, the gateway's retry is answered as "already processed" and the event is lost.
- **Two webhooks with the same URL deliver every event twice** (each with its own event id). Keep one webhook per URL, or make your handler idempotent on the transaction + object id + status as well.
- **Order of events is not guaranteed.** A late `payment_attempt.failed` (an earlier declined try) can arrive after `payment.paid`. Never move an order that is already paid back to failed or on-hold, and decide explicitly what to do with a `payment.paid` for an order you have already cancelled (flag it for review; do not just return an error, which makes the gateway retry forever and then lose the event).
- **Duplicate payment.** If a second `payment.paid` arrives for an order that is already paid with a different `transactionId`, record it and alert the merchant so the extra payment can be refunded.
- **Your endpoint must accept `POST`**, answer within the gateway timeout, and respond `200` quickly; do the heavy work asynchronously.

### PHP Webhook Handler

```php
// Webhook handler endpoint (e.g., POST /webhook/epayse)
$webhookSecret = 'your-webhook-secret';

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$eventType = $_SERVER['HTTP_X_WEBHOOK_EVENT_TYPE'] ?? '';

// Verify timestamp is within 5 minutes
if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    echo json_encode(['error' => 'Request expired']);
    exit;
}

// Verify signature: timestamp + payload (NO DOT separator)
$expectedSignature = hash_hmac('sha256', $timestamp . $payload, $webhookSecret);

if (!hash_equals($expectedSignature, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

$event = json_decode($payload, true);

switch ($eventType) {
    case 'payment.paid':
        $transaction = $event['data']['object'];
        $orderId = $transaction['metadata']['orderId'] ?? null;
        $transactionId = $transaction['id'];
        $amount = $transaction['paid_amount'];
        // Mark order as paid in your database
        break;

    case 'payment_attempt.failed':
        $transaction = $event['data']['object'];
        $orderId = $transaction['metadata']['orderId'] ?? null;
        // Mark order as failed
        break;

    case 'refund.completed':
        $transaction = $event['data']['object'];
        $refundedAmount = $transaction['refunded_amount'];
        // Process refund in your system
        break;

    case 'dispute.created':
        // Handle dispute/chargeback
        break;
}

// Respond with 200 to acknowledge receipt
http_response_code(200);
echo json_encode(['received' => true]);
```

### Node.js Webhook Handler (Express)

```javascript
const crypto = require('crypto');
const express = require('express');
const app = express();

const WEBHOOK_SECRET = 'your-webhook-secret';

// IMPORTANT: Use raw body for signature verification
app.post('/webhook/epayse', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = req.body.toString();
  const signature = req.headers['x-webhook-signature'] || '';
  const timestamp = req.headers['x-webhook-timestamp'] || '';
  const eventType = req.headers['x-webhook-event-type'] || '';

  // Verify timestamp is within 5 minutes
  if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp)) > 300) {
    return res.status(401).json({ error: 'Request expired' });
  }

  // Verify signature: timestamp + payload (NO DOT separator)
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(timestamp + payload)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(payload);

  switch (eventType) {
    case 'payment.paid': {
      const transaction = event.data.object;
      const orderId = transaction.metadata?.orderId;
      const amount = transaction.paid_amount;
      // Mark order as paid in your database
      console.log(`Payment succeeded for order ${orderId}: $${amount}`);
      break;
    }

    case 'payment_attempt.failed': {
      const transaction = event.data.object;
      const orderId = transaction.metadata?.orderId;
      // Mark order as failed
      break;
    }

    case 'refund.completed': {
      const transaction = event.data.object;
      // Process refund
      break;
    }

    case 'dispute.created':
      // Handle dispute
      break;
  }

  res.status(200).json({ received: true });
});
```

### Python Webhook Handler (Flask)

```python
import hashlib
import hmac
import json
import time

from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = "your-webhook-secret"


@app.route("/webhook/epayse", methods=["POST"])
def epayse_webhook():
    payload = request.get_data(as_text=True)
    signature = request.headers.get("X-Webhook-Signature", "")
    timestamp = request.headers.get("X-Webhook-Timestamp", "")
    event_type = request.headers.get("X-Webhook-Event-Type", "")

    # Verify timestamp is within 5 minutes
    if abs(int(time.time()) - int(timestamp)) > 300:
        return jsonify({"error": "Request expired"}), 401

    # Verify signature: timestamp + payload (NO DOT separator)
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        (timestamp + payload).encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected_signature, signature):
        return jsonify({"error": "Invalid signature"}), 401

    event = json.loads(payload)

    if event_type == "payment.paid":
        transaction = event["data"]["object"]
        order_id = transaction.get("metadata", {}).get("orderId")
        amount = transaction["paid_amount"]
        # Mark order as paid in your database

    elif event_type == "payment_attempt.failed":
        transaction = event["data"]["object"]
        # Mark order as failed

    elif event_type == "refund.completed":
        transaction = event["data"]["object"]
        # Process refund

    elif event_type == "dispute.created":
        # Handle dispute
        pass

    return jsonify({"received": True}), 200
```

### Go Webhook Handler

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"math"
	"net/http"
	"strconv"
	"time"
)

const webhookSecret = "your-webhook-secret"

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Cannot read body", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	payload := string(body)
	signature := r.Header.Get("X-Webhook-Signature")
	timestamp := r.Header.Get("X-Webhook-Timestamp")
	eventType := r.Header.Get("X-Webhook-Event-Type")

	// Verify timestamp is within 5 minutes
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		http.Error(w, `{"error":"Request expired"}`, http.StatusUnauthorized)
		return
	}

	// Verify signature: timestamp + payload (NO DOT separator)
	mac := hmac.New(sha256.New, []byte(webhookSecret))
	mac.Write([]byte(timestamp + payload))
	expectedSignature := hex.EncodeToString(mac.Sum(nil))

	if !hmac.Equal([]byte(expectedSignature), []byte(signature)) {
		http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized)
		return
	}

	var event map[string]interface{}
	if err := json.Unmarshal(body, &event); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	switch eventType {
	case "payment.paid":
		// Mark order as paid in your database
	case "payment_attempt.failed":
		// Mark order as failed
	case "refund.completed":
		// Process refund
	case "dispute.created":
		// Handle dispute
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"received":true}`))
}

func main() {
	http.HandleFunc("/webhook/epayse", webhookHandler)
	http.ListenAndServe(":8080", nil)
}
```

---

## Step 5: Payment Methods Discovery

Retrieve available payment methods to control the checkout experience.

> **Current availability:** card processing (`group: CARDS`) is the only
> method live in production today. `BANK_TRANSFER`, `E_WALLET`, `CRYPTO`,
> `BNPL`, and `OTHER` are defined enum values the `group`/`family` filters
> accept, reserved for future payment methods — they will not return any
> results until a method in that group is enabled. Call the endpoint below to
> see what's actually available at any given time rather than assuming any
> group beyond CARDS is active.

**Endpoint:** `GET /api/v1/payment-methods`

### Query Parameters (all optional)

| Parameter  | Description                                                          |
| ---------- | -------------------------------------------------------------------- |
| `group`    | Filter by group: CARDS, BANK_TRANSFER, E_WALLET, CRYPTO, BNPL, OTHER |
| `family`   | Filter by family (same values as group)                              |
| `currency` | Filter by currency code (e.g., USD)                                  |

### Example (PHP)

```php
// Get all payment methods
$methods = $client->request('GET', 'api/v1/payment-methods');

// Filter by group
$cardMethods = $client->request('GET', 'api/v1/payment-methods?group=CARDS');
```

### Response

```json
{
  "status": "SUCCESS",
  "data": {
    "object": "list",
    "count": 3,
    "payment_methods": [
      {
        "key": "card_visa",
        "display_name": "Visa",
        "group": "CARDS",
        "family": "CARDS",
        "icon": "visa.svg",
        "allowed_card_brands": ["VISA"]
      }
    ]
  }
}
```

### Controlling Checkout Payment Methods

When creating a transaction, use these fields to control which methods appear:

```php
$response = $client->request('POST', 'api/v1/transaction/create', [
    // ... required fields ...

    // Whitelist: ONLY show these methods
    'paymentMethods' => json_encode(['card_visa', 'card_mastercard']),

    // OR blacklist: hide these methods
    'paymentMethodsFilter' => json_encode(['card_amex']),

    // Custom display order
    'paymentMethodsSorter' => json_encode(['card_visa', 'card_mastercard']),

    // Pre-select a method on checkout
    'preferredPaymentMethod' => 'card_visa',
]);
```

- If both `paymentMethods` and `paymentMethodsFilter` are empty, the gateway uses auto-routing
- `paymentMethods` (whitelist) takes precedence over `paymentMethodsFilter` (blacklist)
- Method keys must match the `key` field from the payment methods API response

---

## Step 6: Refunds

**Endpoint:** `POST /api/v1/refund/create`

### Required Fields

| Field           | Type   | Description                                                                      |
| --------------- | ------ | -------------------------------------------------------------------------------- |
| `transactionId` | string | Original transaction ID                                                          |
| `refundAmount`  | number | Amount to refund (min: 0.1, max: remaining refundable amount)                    |
| `reason`        | string | Refund reason, required (max 255 chars, alphanumeric and basic punctuation only) |

`reason` must be a non-empty string. If your platform lets the operator leave the reason blank (WooCommerce does), send a default such as `Refund for order #1234` instead of an empty string, otherwise the call fails with `422` and an `errors.reason` entry.

### Refund lifecycle

A successful `refund/create` means the refund was **accepted**, not that money moved. The response carries the new refund id in `data.refundId` (webhook ids use the `rfd_` prefix) and may carry an approval state. Depending on the merchant balance and approval rules a refund can wait for manual approval or for balance before it completes; follow it through the webhooks (`refund.pending_approval`, `refund.approved`, `refund.rejected`, `refund.completed`, `refund.failed`) or `GET /api/v1/refund/{refundId}`.

On your side, store the refund id with your own refund record, and only mark it completed on `refund.completed`. On `refund.rejected` or `refund.failed`, flag it for the merchant: the money was NOT returned, even though your platform may already show the refund as issued.

Use a distinct `Idempotency-Key` per refund (see "Idempotency-Key"): two identical partial refunds must not share one.

### PHP

```php
$response = $client->request('POST', 'api/v1/refund/create', [
    'transactionId' => '01kd096yg112e40pcyfwsahyev',
    'refundAmount' => 25.00,
    'reason' => 'Customer request - item returned',
]);
```

### Node.js

```javascript
const response = await client.request('POST', 'api/v1/refund/create', {
  transactionId: '01kd096yg112e40pcyfwsahyev',
  refundAmount: 25.0,
  reason: 'Customer request - item returned',
});
```

### Python

```python
response = client.request("POST", "api/v1/refund/create", {
    "transactionId": "01kd096yg112e40pcyfwsahyev",
    "refundAmount": 25.00,
    "reason": "Customer request - item returned",
})
```

### Go

```go
response, err := client.Request("POST", "api/v1/refund/create", map[string]interface{}{
    "transactionId": "01kd096yg112e40pcyfwsahyev",
    "refundAmount":  25.00,
    "reason":        "Customer request - item returned",
})
```

### Get Refund Details

```
GET /api/v1/refund/{refundId}
```

---

## Step 7: Additional APIs

### Get Transaction Details

```
GET /api/v1/transaction/{transactionId}
```

Use this to verify payment status as a backup to webhooks.

### Expire Transaction

```
POST /api/v1/transaction/expire
Body: {"id": "01kd096yg112e40pcyfwsahyev"}
```

### Merchant Balance

```
GET /api/v1/merchant/balance
GET /api/v1/merchant/balances
```

### Currencies

```
GET /api/v1/currencies
GET /api/v1/currencies/codes
GET /api/v1/currencies/{code}
GET /api/v1/currencies/{code}/check
```

### Exchange Rates

```
GET /api/v1/exchange-rates
GET /api/v1/exchange-rates/rate?from=USD&to=EUR
POST /api/v1/exchange-rates/bulk
```

### Get Dispute Details

```
GET /api/v1/dispute/{disputeId}
```

### System Status (public, no auth required)

```
GET https://status.epayse.com/api
```

---

## Transaction Statuses

| Status       | Description                               |
| ------------ | ----------------------------------------- |
| `INCOMPLETE` | Created, customer has not started payment |
| `PENDING`    | Payment in progress                       |
| `SUCCESS`    | Payment completed                         |
| `FAIL`       | Payment failed                            |
| `EXPIRE`     | Transaction expired                       |
| `REFUND`     | Transaction refunded                      |

---

## Common Mistakes

1. **Amount format:** Send `10.50` for $10.50 (dollars with decimals, NOT `1050` cents)
2. **Server IP vs customer IP:** `customerDetails.ip` must be the end user's IP address, not your server's IP
3. **Duplicate ref:** Each transaction must have a unique `ref` per merchant. Error: "The ref has already been taken" (422). Append timestamp or attempt number for retries.
4. **Trusting redirect params:** NEVER use redirect URL parameters to fulfill orders. Always verify via webhook or GET transaction API.
5. **Leading slash in signature path:** Use `api/v1/transaction/create` NOT `/api/v1/transaction/create`
6. **Localhost URLs rejected:** `websiteUrl`, `redirectUrl`, `cancelUrl` cannot use localhost/127.0.0.1. Use ngrok or similar for development, and register that public domain as an approved website of your merchant account (Settings → Websites); an unapproved `websiteUrl` fails with `Domain '<host>' is not in your list of allowed websites`. Platform plugins that send the shop's home URL must be browsed through that same public URL.
7. **Webhook signature format:** The signature is `HMAC-SHA256(secret, timestamp + payload)` with NO DOT between timestamp and payload. Using `timestamp . '.' . payload` will fail.
8. **Private IP addresses rejected:** All URLs must be publicly accessible on the internet
9. **Missing required customerDetails fields:** All customerDetails fields (firstName, lastName, email, country, ip, phoneCode, phoneNumber) are required
10. **JSON encoding inconsistency:** Use unescaped slashes and unescaped unicode in JSON body to match signature verification. PHP: `json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)`
11. **Same Idempotency-Key for two different refunds:** identical partial refunds (same transaction, amount, reason) collapse into one on the gateway while your platform records two. Salt the key with your own refund id/sequence and treat `X-Idempotency-Replayed: true` as "already done".
12. **Rebuilding the checkout URL:** `/checkout/{transactionId}` without the `expires`/`signature` query returns `403 Unauthorized Access`. Keep the signed `checkoutUrl` from the create response.
13. **Embedding `fullCheckoutUrl` in an iframe:** the full checkout page cannot be framed. Use the per-method widget URLs from `iframe.paymentMethods[]`, or use redirect mode.
14. **Reading only `message` on a 422:** it is always `Invalid request`; the cause is in `errors` (amount out of range, unsupported currency, missing `reason`, unapproved domain, ...).
15. **Processing webhooks without verifying them, or marking an event processed before handling it:** the first lets anyone fake a payment; the second turns a transient error into a permanently lost event (the retry is answered "already processed").
16. **Letting a late failure event overwrite a paid order:** `payment_attempt.failed` for an earlier attempt can arrive after `payment.paid`.
17. **Empty refund `reason`:** it is required; send a default string when the operator leaves it blank.
18. **State changes on `redirectUrl`/`cancelUrl`:** these are `GET` requests the gateway may call itself, so cancelling or completing an order from them cancels or completes orders nobody touched. Keep them read-only and drive state from webhooks.

---

## Error Codes

| HTTP Status | Description                                                               |
| ----------- | ------------------------------------------------------------------------- |
| 200         | Success                                                                   |
| 400         | Bad request                                                               |
| 401         | Unauthorized (invalid signature, expired timestamp, inactive API key)     |
| 403         | Forbidden (IP not whitelisted; also a checkout URL without its signature) |
| 404         | Not found                                                                 |
| 422         | Validation error (check `errors` field for details)                       |
| 422         | `code: "NO_PAYMENT_METHODS"` (no `errors` field, see note below)          |
| 429         | Rate limited or duplicate request detected                                |
| 500         | Server error                                                              |

`NO_PAYMENT_METHODS` means the merchant account has no payment method available (or none is left after your `paymentMethods` / `paymentMethodsFilter`) on a create-transaction call. Nothing was created. Tell the buyer payment cannot be taken right now; retry only after support has assigned a method.

### Rate Limits

| Endpoint           | Limit         |
| ------------------ | ------------- |
| Transaction create | 100/min       |
| Refund create      | 100/min       |
| S2S payment        | 50/min        |
| Other endpoints    | 100/min       |
| Overall            | 60/min per IP |

---

## Test Cards (Sandbox Only)

| Card Number        | Result             |
| ------------------ | ------------------ |
| `4242424242424242` | Success            |
| `4000000000000002` | Declined           |
| `4000000000009995` | Insufficient funds |
| `4000000000000069` | Expired card       |
| `4000000000000127` | Incorrect CVC      |

- CVV `123` = Success, CVV `999` = Decline
- Any future expiry date = Success

---

## Testing Checklist

- [ ] HMAC signature generates correctly (matches server expectation)
- [ ] Transaction creates successfully in sandbox
- [ ] Redirect to checkout works and customer can complete payment
- [ ] Webhook endpoint receives notifications
- [ ] Webhook signature verifies correctly (timestamp + payload, NO DOT)
- [ ] Payment status verified via GET /api/v1/transaction/{id} after webhook
- [ ] Refund creates successfully
- [ ] Error handling for all API responses (401, 422, 429, 500)
- [ ] Idempotency-Key sent on all POST requests
- [ ] Timestamp drift handled (within 5 minutes)
- [ ] Duplicate ref handling with unique suffixes for retries
- [ ] `websiteUrl` domain is an approved website of the merchant account
- [ ] Amounts below the minimum and above the merchant's maximum are caught before the call, and a `422` shows the `errors` detail in your logs
- [ ] The signed `checkoutUrl` is stored and reused on retry (a bare `/checkout/{id}` is `403`)
- [ ] Webhook with a bad signature, and webhook with no secret configured, are rejected and change nothing
- [ ] The same webhook delivered twice (and two webhooks with the same URL) changes the order once
- [ ] A late `payment_attempt.failed` after `payment.paid` does not flip a paid order
- [ ] A failure inside your webhook handler does not mark the event processed (the retry is handled)
- [ ] Two identical partial refunds create two refunds on the gateway; a replay (`X-Idempotency-Replayed: true`) is not recorded as a new refund
- [ ] Requesting your `cancelUrl` and `redirectUrl` directly (no buyer action) leaves the order status unchanged
- [ ] Refund with an empty reason in your UI still succeeds (default reason sent)
- [ ] A refund that stays pending or is rejected is visible to the merchant, not shown as completed

---

## Building a platform plugin (WooCommerce, Shopify, and similar)

Lessons from building and verifying the WooCommerce plugin against the sandbox. Use this as a pre-release checklist on top of the steps above.

1. **Sign exactly what you send.** Build the JSON once, sign that string, send that same string. Query strings and numbers are normalised by the server (see "Signature Format"). Log request metadata (method, path, status, request id) but never the signature, nonce, API secret or webhook secret.
2. **Register the shop's public domain** as an approved website before the first test, and test through that URL. The plugin sends the shop's home URL as `websiteUrl`, so a local-only address cannot work.
3. **Check before you offer the payment method.** Hide it (or explain) when the cart currency is unsupported or the total is outside the accepted amount range, rather than failing after Place Order.
4. **Make order handling idempotent.** An order can be retried, the buyer can pay in a second tab, and webhooks can repeat, arrive late, or arrive before the buyer returns. Store the gateway transaction id (without the `txn_` prefix) and signed checkout URL on the order; verify transaction id and amount on every webhook; never downgrade a paid order.
5. **Confirm payment from the webhook (or `GET /transaction/{id}`), never from the redirect.** The return page may be reached by anyone with the URL; show a "processing" state until the webhook lands.
6. **Refunds are a workflow, not a call.** Send a unique `Idempotency-Key` and a non-empty `reason`, store the gateway refund id, show the real state (submitted, pending approval, completed, rejected) in the order notes, and handle `refund.rejected`/`refund.failed` so the platform's refund record does not claim money moved when it did not.
7. **Show the buyer a clear, neutral message and keep the detail in the log.** Out-of-range amount, unsupported currency and an unapproved domain are three different `422`s; the buyer does not need the technical text but the merchant does.
8. **Never trust data carried through the browser** (URL hash, query string, `postMessage`) to decide where to send the buyer or what to frame. Take the URL from a server-side response tied to the order, accept messages only from the gateway origin AND your own iframe, and only navigate to https URLs on origins you expect.
9. **Developer experience.** A "Test connection" button should report success or the real error; configuration screens must not echo secrets back; environment settings that relax TLS or URL checks must be impossible to enable on a production site.
10. **Verify end to end in the sandbox before release:** redirect payment with a test card, webhook updates the order, partial refund (twice, identical), cancelled and expired transactions, a duplicate webhook, an out-of-range cart, and a currency the gateway does not accept.

What the Skill Covers

HMAC-SHA256 Authentication

Ready-to-use client classes for PHP, Node.js, Python, and Go with correct signature generation.

Transaction Management

Create transactions, handle checkout redirect/iframe, process refunds, and query payment status.

Webhook Processing

Complete webhook handlers with signature verification for all event types across 4 languages.

Common Pitfalls

Built-in knowledge of 10 most common integration mistakes — Claude will avoid them automatically.

Frequently Asked Questions