Skip to main content

Morph x402 Developer Integration Guide

1. Overview

Morph x402 API is built on the Coinbase x402 protocol, providing on-chain payment settlement services for the Morph network.

Base URL: https://morph-rails.morph.network/x402

Authentication Model

EndpointAuth
POST /v2/verifyHMAC signature
POST /v2/settleHMAC signature
GET /v2/supportedNone

Prerequisites

Obtain your Access Key and Secret Key from the Morph x402 Console.

Important: Store your Secret Key securely upon creation. It will only be shown once. To retrieve it later, contact the Morph team.


2. Getting Your API Credentials

  1. Visit the Morph x402 Console
  2. Connect your wallet (MetaMask or any EIP-1193 compatible wallet)
  3. Sign the login message when prompted
  4. Click Create to generate your API Key pair

You will receive:

  • Access Key (morph_ak_...) — included in every API request header
  • Secret Key (morph_sk_...) — used to compute HMAC signatures, never sent over the wire

Important: The Secret Key is only displayed once at creation time. Store it securely. To retrieve it later, contact the Morph team.

Each wallet address can only create one API Key.


3. HMAC Signature Authentication

All requests to /v2/verify and /v2/settle must include HMAC signature headers.

3.1 Required Headers

HeaderDescription
MORPH-ACCESS-KEYYour Access Key
MORPH-ACCESS-TIMESTAMPRequest timestamp in milliseconds, must be within ±30s of server time
MORPH-ACCESS-SIGNBase64-encoded HMAC-SHA256 signature

3.2 Signature Algorithm

All signing elements are placed into a single JSON map. The map is serialized with keys sorted in lexicographic order, producing a compact JSON string (no whitespace). The signature is then computed over this string.

Signature map structure:

{
"MORPH-ACCESS-BODY": { "paymentPayload": { ... }, "paymentRequirements": { ... }, "x402Version": 2 },
"MORPH-ACCESS-KEY": "morph_ak_a1b2c3d4e5f6g7h8",
"MORPH-ACCESS-METHOD": "POST",
"MORPH-ACCESS-PATH": "/x402/v2/settle",
"MORPH-ACCESS-TIMESTAMP": "1738056600000",
"chain": ["morph"],
"version": ["2"]
}
FieldDescriptionRules
MORPH-ACCESS-KEYAccess Key, same as the header valueRequired
MORPH-ACCESS-TIMESTAMPTimestamp in milliseconds, same as the header valueRequired
MORPH-ACCESS-METHODHTTP method, uppercase (e.g. POST)Required
MORPH-ACCESS-PATHFull URL path including gateway prefix (e.g. /x402/v2/settle), excluding query stringRequired
Query paramsFlattened into top-level map; values are string[] arraysOmit if no query
MORPH-ACCESS-BODYRequest body parsed as JSON objectOmit if no body

Computation:

signContent = JSON.serialize(signMap, sortKeys=true, compact=true)
signature = Base64( HMAC-SHA256( secretKey, signContent ) )

3.3 Deterministic Serialization

The only requirement for a correct signature is: all JSON keys must be recursively sorted in lexicographic order and output in compact format (no spaces, no newlines).

LanguageHow keys are sorted
Gojson.Marshal(map[string]interface{}) sorts keys automatically
JavaScript / TypeScriptJSON.stringify preserves insertion order; you must recursively sort keys before serializing
Pythonjson.dumps(obj, sort_keys=True, separators=(',', ':')) sorts keys automatically

4. Business Endpoints

Request/response formats are fully compatible with the Coinbase x402 SDK. These endpoints return raw x402 responses (not wrapped in code/data/message).

4.1 POST /v2/verify (Auth Required)

Verify whether a payment payload is valid.

Request

{
"x402Version": 2,
"paymentPayload": { ... },
"paymentRequirements": { ... }
}

Responsex402.VerifyResponse

{
"isValid": true,
"invalidReason": "",
"payer": "0x..."
}

4.2 POST /v2/settle (Auth Required)

Submit an on-chain settlement.

Request

{
"x402Version": 2,
"paymentPayload": { ... },
"paymentRequirements": { ... }
}

Responsex402.SettleResponse

{
"success": true,
"errorReason": "",
"payer": "0x...",
"transaction": "0x...",
"network": "eip155:2818"
}

4.3 GET /v2/supported (No Auth)

Query supported payment methods and networks.

curl 'https://morph-rails.morph.network/x402/v2/supported'

Responsex402.SupportedResponse

{
"kinds": [
{ "x402Version": 2, "scheme": "exact", "network": "eip155:2818" }
],
"extensions": [],
"signers": {
"eip155:*": ["0xb22C2E02997B10bc481907f05475C90047e84697"]
}
}

5. Code Examples

5.1 Go — Integration with Coinbase x402 SDK

The recommended approach is to implement a custom http.RoundTripper that automatically signs requests at the transport layer, requiring zero changes to SDK usage code.

Signing Utilities

json.Marshal on map[string]interface{} automatically sorts keys in Go. No extra sorting is needed.

package morph

import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/url"
)

func BuildSignContent(accessKey, timestamp, method, path, rawQuery, rawBody string) []byte {
signMap := map[string]interface{}{
"MORPH-ACCESS-KEY": accessKey,
"MORPH-ACCESS-TIMESTAMP": timestamp,
"MORPH-ACCESS-METHOD": method,
"MORPH-ACCESS-PATH": path,
}
if rawQuery != "" {
values, _ := url.ParseQuery(rawQuery)
for k, vs := range values {
signMap[k] = vs
}
}
if rawBody != "" {
var bodyObj interface{}
if err := json.Unmarshal([]byte(rawBody), &bodyObj); err == nil {
signMap["MORPH-ACCESS-BODY"] = bodyObj
}
}
content, _ := json.Marshal(signMap)
return content
}

func Sign(accessKey, secretKey, timestamp, method, path, rawQuery, rawBody string) string {
content := BuildSignContent(accessKey, timestamp, method, path, rawQuery, rawBody)
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write(content)
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

Signing Transport

package morph

import (
"bytes"
"io"
"net/http"
"strconv"
"time"
)

type MorphSignTransport struct {
accessKey string
secretKey string
base http.RoundTripper
}

func NewMorphSignTransport(accessKey, secretKey string) *MorphSignTransport {
return &MorphSignTransport{
accessKey: accessKey,
secretKey: secretKey,
base: http.DefaultTransport,
}
}

func (t *MorphSignTransport) RoundTrip(req *http.Request) (*http.Response, error) {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
method := req.Method
path := req.URL.Path
rawQuery := req.URL.RawQuery

var rawBody string
if req.Body != nil {
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
rawBody = string(bodyBytes)
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}

sign := Sign(t.accessKey, t.secretKey, timestamp, method, path, rawQuery, rawBody)

req.Header.Set("MORPH-ACCESS-KEY", t.accessKey)
req.Header.Set("MORPH-ACCESS-TIMESTAMP", timestamp)
req.Header.Set("MORPH-ACCESS-SIGN", sign)

return t.base.RoundTrip(req)
}

Using with Coinbase x402 SDK

package main

import (
"net/http"
"time"

x402http "github.com/coinbase/x402/go"
"your-project/morph"
)

func main() {
httpClient := &http.Client{
Transport: morph.NewMorphSignTransport("morph_ak_...", "morph_sk_..."),
Timeout: 30 * time.Second,
}

facilitatorClient := x402http.NewFacilitatorClient(&x402http.FacilitatorConfig{
URL: "https://morph-rails.morph.network/x402/v2",
HTTPClient: httpClient,
})

// Signing is handled automatically at the transport layer
verifyResp, err := facilitatorClient.Verify(ctx, payloadBytes, requirementsBytes)
settleResp, err := facilitatorClient.Settle(ctx, payloadBytes, requirementsBytes)
}

Complete Single-File Example (Gin + x402 Paywall)

A runnable example with HMAC signing, transport, and Gin router integrated with the Coinbase x402 SDK. Test environment values are pre-filled; replace accessKey and secretKey with your own.

package main

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"strconv"
"time"

x402 "github.com/coinbase/x402/go"
x402http "github.com/coinbase/x402/go/http"
ginmw "github.com/coinbase/x402/go/http/gin"
evmServer "github.com/coinbase/x402/go/mechanisms/evm/exact/server"
"github.com/gin-gonic/gin"
)

var (
facilitatorURL = "https://..."
accessKey = "morph_ak_..." // Replace with your Access Key
secretKey = "morph_sk_..." // Replace with your Secret Key

chainID = int64(2910)
tokenAddress = "0xEcF966Cc754BC411E1F1106fbb4e343b835E85E4"
tokenName = "HoodiTestToken"
tokenVersion = "1.0"
tokenDecimals = 18
payToAddress = "0x98a55f86E1a57bBf28e4eA9dD719874075Fe6513"
price = "0.01"
port = "8080"
requestTimeout = 10 * time.Second
)

func morphBuildSignContent(accessKey, timestamp, method, path, rawQuery, rawBody string) []byte {
signMap := map[string]interface{}{
"MORPH-ACCESS-KEY": accessKey,
"MORPH-ACCESS-TIMESTAMP": timestamp,
"MORPH-ACCESS-METHOD": method,
"MORPH-ACCESS-PATH": path,
}
if rawQuery != "" {
values, _ := url.ParseQuery(rawQuery)
for k, vs := range values {
signMap[k] = vs
}
}
if rawBody != "" {
var bodyObj interface{}
if err := json.Unmarshal([]byte(rawBody), &bodyObj); err == nil {
signMap["MORPH-ACCESS-BODY"] = bodyObj
}
}
content, _ := json.Marshal(signMap)
return content
}

func morphSign(accessKey, secretKey, timestamp, method, path, rawQuery, rawBody string) string {
content := morphBuildSignContent(accessKey, timestamp, method, path, rawQuery, rawBody)
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write(content)
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

type morphSignTransport struct {
accessKey string
secretKey string
base http.RoundTripper
}

func (t *morphSignTransport) RoundTrip(req *http.Request) (*http.Response, error) {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
var rawBody string
if req.Body != nil {
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
rawBody = string(bodyBytes)
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
sign := morphSign(t.accessKey, t.secretKey, timestamp, req.Method, req.URL.Path, req.URL.RawQuery, rawBody)

req.Header.Set("MORPH-ACCESS-KEY", t.accessKey)
req.Header.Set("MORPH-ACCESS-TIMESTAMP", timestamp)
req.Header.Set("MORPH-ACCESS-SIGN", sign)
return t.base.RoundTrip(req)
}

func main() {
httpClient := &http.Client{
Transport: &morphSignTransport{accessKey: accessKey, secretKey: secretKey, base: http.DefaultTransport},
Timeout: requestTimeout,
}

facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{
URL: facilitatorURL,
HTTPClient: httpClient,
})

evmServerScheme := evmServer.NewExactEvmScheme()
evmServerScheme.RegisterMoneyParser(func(amount float64, _ x402.Network) (*x402.AssetAmount, error) {
amountInSmallestUnits := math.Round(amount * math.Pow10(tokenDecimals))
return &x402.AssetAmount{
Amount: fmt.Sprintf("%.0f", amountInSmallestUnits),
Asset: tokenAddress,
Extra: map[string]interface{}{
"name": tokenName,
"version": tokenVersion,
},
}, nil
})

network := x402.Network(fmt.Sprintf("eip155:%d", chainID))

routes := x402http.RoutesConfig{
"GET /api/resource": {
Accepts: x402http.PaymentOptions{
{
Scheme: "exact",
PayTo: payToAddress,
Price: price,
Network: network,
},
},
Description: "Demo API Resource",
MimeType: "application/json",
},
}

router := gin.Default()
router.Use(ginmw.X402Payment(ginmw.Config{
Routes: routes,
Facilitator: facilitator,
Schemes: []ginmw.SchemeConfig{{Network: network, Server: evmServerScheme}},
SyncFacilitatorOnStart: true,
Timeout: requestTimeout,
}))

router.GET("/api/resource", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "Payment successful, access granted.",
"data": "This is the protected data.",
})
})

log.Printf("Server starting on port %s", port)
if err := router.Run(":" + port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

5.2 JavaScript / TypeScript

JSON.stringify preserves insertion order. You must recursively sort keys before serializing.

import crypto from "crypto";

const ACCESS_KEY = "morph_ak_...";
const SECRET_KEY = "morph_sk_...";
const API_ORIGIN = "https://morph-rails.morph.network";
const API_PREFIX = "/x402";

function sortObject(obj: any): any {
if (Array.isArray(obj)) {
return obj.map(sortObject);
}
if (obj !== null && typeof obj === "object") {
return Object.keys(obj)
.sort()
.reduce((sorted: any, key: string) => {
sorted[key] = sortObject(obj[key]);
return sorted;
}, {});
}
return obj;
}

function sign(
accessKey: string,
secretKey: string,
timestamp: string,
method: string,
path: string,
rawQuery: string,
rawBody: string
): string {
const signMap: Record<string, any> = {
"MORPH-ACCESS-KEY": accessKey,
"MORPH-ACCESS-TIMESTAMP": timestamp,
"MORPH-ACCESS-METHOD": method,
"MORPH-ACCESS-PATH": path,
};

if (rawQuery) {
const params = new URLSearchParams(rawQuery);
params.forEach((v, k) => {
if (signMap[k]) {
(signMap[k] as string[]).push(v);
} else {
signMap[k] = [v];
}
});
}

if (rawBody) {
signMap["MORPH-ACCESS-BODY"] = JSON.parse(rawBody);
}

const content = JSON.stringify(sortObject(signMap));
const hmac = crypto.createHmac("sha256", secretKey);
hmac.update(content);
return hmac.digest("base64");
}

async function settlePayment(payload: object, requirements: object) {
const timestamp = Date.now().toString();
const method = "POST";
const endpoint = "/v2/settle";
const fullPath = API_PREFIX + endpoint; // /x402/v2/settle — used for signing
const body = JSON.stringify({
x402Version: 2,
paymentPayload: payload,
paymentRequirements: requirements,
});

const signature = sign(ACCESS_KEY, SECRET_KEY, timestamp, method, fullPath, "", body);

const resp = await fetch(`${API_ORIGIN}${fullPath}`, {
method,
headers: {
"Content-Type": "application/json",
"MORPH-ACCESS-KEY": ACCESS_KEY,
"MORPH-ACCESS-TIMESTAMP": timestamp,
"MORPH-ACCESS-SIGN": signature,
},
body,
});

return resp.json();
}

6. Rate Limiting

  • Default limit: 10 QPS per Access Key
  • When exceeded, HTTP 429 is returned:
{
"isValid": false,
"invalidReason": "rate limit exceeded",
"success": false,
"errorReason": "rate limit exceeded"
}

7. Error Responses

Auth and rate-limit errors on /v2/* endpoints use the Coinbase x402 SDK compatible format:

{
"isValid": false,
"invalidReason": "<error_description>",
"success": false,
"errorReason": "<error_description>"
}
HTTP StatusErrorDescription
401missing auth headersOne or more required HMAC headers are missing
401invalid timestampTimestamp is not a valid integer
401timestamp expiredTimestamp differs from server time by more than ±30s
401invalid access keyAccess Key does not exist
401invalid signatureHMAC signature mismatch
403access key disabledAccess Key has been disabled
429rate limit exceededRequest rate exceeded

8. FAQ

Q: Does /v2/supported require authentication? A: No. It is a public endpoint.

Q: My signature keeps failing. How do I debug? A: Checklist:

  1. Verify all fixed keys use the MORPH-ACCESS- prefix (e.g. MORPH-ACCESS-KEY, MORPH-ACCESS-METHOD)
  2. Verify query parameters are flattened into the top-level map (not nested), with values as string[] arrays
  3. Verify all keys in the sign map (including nested objects) are recursively sorted in lexicographic order
  4. Verify the output is compact JSON (no extra spaces or newlines)
  5. Verify the timestamp is in milliseconds (not seconds)
  6. Verify the HTTP method is uppercase (POST, not post)
  7. Verify MORPH-ACCESS-PATH is the full URL path including the /x402 prefix (e.g. /x402/v2/settle, not /v2/settle), and does not include the query string
  8. Verify MORPH-ACCESS-BODY is omitted when there is no request body

Q: What is the Morph network identifier? A: CAIP-2 format: eip155:2818.

Q: What if my Access Key is disabled? A: Contact the Morph team. Access Keys can only be manually enabled/disabled.

Q: I lost my Secret Key. How do I get it back? A: Contact the Morph team. The Secret Key is only displayed once at creation time.