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.
What is a Claude Code Skill?
.claude/skills/ directory. When Claude Code works on your codebase, it reads these files as context — giving it domain-specific knowledge without needing to search documentation. Think of it as teaching Claude your project's integration patterns. Get Started in 3 Steps
Copy the skill content
Click the copy button on the code block below to copy the entire skill file to your clipboard.
Create the file in your project
Create the file .claude/skills/epayse-integration.md in your project root directory.
mkdir -p .claude/skills && touch .claude/skills/epayse-integration.mdStart 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.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 (METHOD|PATH|TIMESTAMP|BODY with NO leading slash), 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 (Stripe, PayPal, Adyen) or general payment/HMAC concepts unrelated to EPaySe."
---
# EPaySe Payment Gateway Integration
## Success Criteria
A successful EPaySe integration should:
- Generate correct HMAC-SHA256 signatures (METHOD|PATH|TIMESTAMP|BODY format, 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-Client-ID` | Your API Client ID |
| `X-Timestamp` | Unix timestamp (seconds) |
| `X-Signature` | HMAC-SHA256 signature |
| `Idempotency-Key` | Required for POST/PUT/PATCH requests. Use a deterministic hash of the request. |
### Signature Format
```
HMAC-SHA256(secret_key, "METHOD|PATH|TIMESTAMP|BODY")
```
**Critical rules:**
- PATH must NOT have a leading slash (use `api/v1/transaction/create` not `/api/v1/transaction/create`)
- BODY is empty string for GET requests
- BODY is the exact JSON string for POST/PUT requests
- Timestamp must be within 5 minutes of server time
- JSON body should use unescaped slashes and unescaped unicode for consistency
### 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 $body JSON body (empty string for GET)
* @return string Hex-encoded HMAC signature
*/
public function generateSignature(string $method, string $path, int $timestamp, string $body = ''): string
{
$dataToSign = sprintf(
'%s|%s|%s|%s',
strtoupper($method),
ltrim($path, '/'),
$timestamp,
$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);
$signature = $this->generateSignature($method, $path, $timestamp, $body);
$headers = [
'Content-Type: application/json',
'X-Client-ID: ' . $this->clientId,
'X-Timestamp: ' . $timestamp,
'X-Signature: ' . $signature,
];
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} body - JSON body (empty string for GET)
* @returns {string} Hex-encoded signature
*/
generateSignature(method, path, timestamp, body = '') {
const dataToSign = [
method.toUpperCase(),
path.replace(/^\//, ''),
timestamp.toString(),
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 signature = this.generateSignature(method, path, timestamp, body);
const url = new URL(`/${path.replace(/^\//, '')}`, this.baseUrl);
const headers = {
'Content-Type': 'application/json',
'X-Client-ID': this.clientId,
'X-Timestamp': timestamp.toString(),
'X-Signature': signature,
};
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
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, body: str = ""
) -> str:
"""
Generate HMAC-SHA256 signature.
Args:
method: HTTP method (GET, POST, etc.)
path: API path WITHOUT leading slash
timestamp: Unix timestamp (seconds)
body: JSON body (empty string for GET)
Returns:
Hex-encoded HMAC-SHA256 signature
"""
data_to_sign = "|".join([
method.upper(),
path.lstrip("/"),
str(timestamp),
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=(",", ":"))
signature = self.generate_signature(method, path, timestamp, body)
headers = {
"Content-Type": "application/json",
"X-Client-ID": self.client_id,
"X-Timestamp": str(timestamp),
"X-Signature": signature,
}
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 if method != "GET" else None,
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/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.
// path must NOT have a leading slash.
// body is empty string for GET requests.
func (c *Client) GenerateSignature(method, path string, timestamp int64, body string) string {
dataToSign := fmt.Sprintf("%s|%s|%d|%s",
strings.ToUpper(method),
strings.TrimLeft(path, "/"),
timestamp,
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 {
jsonBytes, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("marshal error: %w", err)
}
body = string(jsonBytes)
bodyReader = bytes.NewReader(jsonBytes)
}
signature := c.GenerateSignature(method, path, timestamp, 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-Client-ID", c.ClientID)
req.Header.Set("X-Timestamp", fmt.Sprintf("%d", timestamp))
req.Header.Set("X-Signature", signature)
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 (must be publicly accessible, no localhost) |
| `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) |
### 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=..."
}
}
```
---
## 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": ["payment.success", "payment.failed", "payment.cancelled"]
}
}
}
```
Embed the iframe and listen for postMessage events:
```javascript
window.addEventListener('message', (event) => {
// Verify origin matches your EPaySe gateway URL
if (event.origin !== 'https://sandbox-gateway.epayse.com') return;
const { type, data } = event.data;
switch (type) {
case 'payment.success':
// Payment completed - wait for webhook to confirm
window.location.href = '/payment/success?ref=' + data.ref;
break;
case 'payment.failed':
window.location.href = '/payment/failed?ref=' + data.ref;
break;
case 'payment.cancelled':
window.location.href = '/payment/cancel?ref=' + data.ref;
break;
}
});
```
### 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.
---
## 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.succeeded`) |
| `X-Webhook-Timestamp` | Unix timestamp |
| `X-Webhook-Signature` | HMAC-SHA256 signature |
| `User-Agent` | `EPaySe-Webhooks/1.0` |
### Webhook Events
| Event | Description |
|-------|-------------|
| `payment.succeeded` | Payment completed successfully |
| `payment.failed` | Payment failed |
| `payment.processing` | Payment is processing |
| `refund.created` | Refund initiated |
| `refund.completed` | Refund completed |
| `refund.failed` | Refund failed |
| `dispute.created` | Dispute/chargeback created |
### Webhook Payload
```json
{
"id": "evt_01HQKZ...",
"object": "event",
"type": "payment.succeeded",
"created": "2025-01-16T08:19:55Z",
"livemode": true,
"api_client_id": "01abc...",
"data": {
"object": {
"id": "txn_01HQKZ...",
"object": "transaction",
"merchant_ref": "ORDER-2025-001",
"amount": 100.00,
"currency": "USD",
"status": "SUCCESS",
"paid_amount": 100.00,
"net_amount": 97.00,
"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.
### 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.succeeded':
$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.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.succeeded': {
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.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.succeeded":
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.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.succeeded":
// Mark order as paid in your database
case "payment.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.
**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(['crypto_btc']),
// Custom display order
'paymentMethodsSorter' => json_encode(['card_visa', 'bank_transfer_ach']),
// 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) |
### Optional Fields
| Field | Type | Description |
|-------|------|-------------|
| `reason` | string | Refund reason (max 255 chars, alphanumeric and basic punctuation only) |
### 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.00,
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.
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)`
---
## Error Codes
| HTTP Status | Description |
|-------------|-------------|
| 200 | Success |
| 400 | Bad request |
| 401 | Unauthorized (invalid signature, expired timestamp, inactive API key) |
| 403 | Forbidden (IP not whitelisted) |
| 404 | Not found |
| 422 | Validation error (check `errors` field for details) |
| 429 | Rate limited or duplicate request detected |
| 500 | Server error |
### 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
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.
