easyway
Sign inCreate free account

A crypto payment gateway API in Go: create an invoice and verify the webhook

Go is a common choice for payment backends, and it needs nothing extra to talk to easyway: the API is HTTPS and JSON, and the standard library has net/http, encoding/json and crypto/hmac. This post builds the two pieces every integration needs, creating an invoice and checking the signed webhook that says it was paid, as two small programs with no modules beyond the standard library. We vetted, built and ran both with Go 1.24; the webhook's method-and-path route needs Go 1.22 or later.

Three steps, one of them yours twice

StepWhoWhat happens
1. CreateYour Go servicePOST /v1/invoices with the amount; the response has an id and a payment_url.
2. PayYour customerOpens payment_url, picks a coin and network, and pays. The page follows the payment by itself.
3. Confirmeasyway → your serviceA signed invoice.paid webhook arrives. That delivery, not the customer returning to your site, is the proof.

The secret API key never leaves the server; the browser only sees the hosted checkout. Create a key in Dashboard → Developers. An sk_test_… key only ever touches sandbox data, so the whole integration can be built before real money moves.

1. Create the invoice

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
)

type invoiceRequest struct {
	Amount      string `json:"amount"` // a decimal string, never a float64
	Currency    string `json:"currency"`
	OrderID     string `json:"order_id"`
	Description string `json:"description,omitempty"`
	SuccessURL  string `json:"success_url,omitempty"`
}

type invoice struct {
	ID         string  `json:"id"`
	Status     string  `json:"status"`
	PaymentURL string  `json:"payment_url"`
	PayAsset   *string `json:"pay_asset"` // null until the customer picks a coin
}

type apiError struct {
	Error string `json:"error"`
}

func createInvoice(ctx context.Context, api, key, orderID, amount string) (*invoice, error) {
	body, _ := json.Marshal(invoiceRequest{
		Amount: amount, Currency: "USD", OrderID: orderID,
		Description: "Order #" + orderID, SuccessURL: "https://shop.example.com/thanks",
	})
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, api+"/v1/invoices", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+key)
	req.Header.Set("Idempotency-Key", "order-"+orderID) // a retry returns the same invoice
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode >= 400 {
		var e apiError
		_ = json.NewDecoder(res.Body).Decode(&e)
		return nil, fmt.Errorf("easyway %d: %s", res.StatusCode, e.Error)
	}
	var inv invoice
	if err := json.NewDecoder(res.Body).Decode(&inv); err != nil {
		return nil, err
	}
	return &inv, nil
}

func main() {
	api := os.Getenv("EASYWAY_API")
	if api == "" {
		api = "https://api.easyway.cash"
	}
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	inv, err := createInvoice(ctx, api, os.Getenv("EASYWAY_API_KEY"), "2048", "149.00")
	if err != nil {
		log.Fatal(err)
	}
	// Store inv.ID with the order, then redirect the customer to inv.PaymentURL.
	fmt.Println("invoice", inv.ID, "->", inv.PaymentURL)
}

What is worth knowing about this file in Go terms:

DetailWhy
Amount stringThe API takes and returns decimal strings. Keep money out of float64: store integer minor units or use a decimal package, and format to a string at the edge.
context.WithTimeoutBounds the whole call. The default http.Client has no timeout at all, so without a context a stalled connection hangs your handler.
Idempotency-KeyRetrying after a timeout with the same key and body returns the same invoice instead of creating a second one. 8–128 characters: letters, digits, _ - : .
*string for pay_assetThe invoice comes back with 201 Created; pay_asset, pay_amount and pay_address are null until the customer picks a coin, which a pointer represents honestly.
ErrorsAny status of 400 or above carries {"error": "…"}; the sample turns it into a Go error such as easyway 401: invalid_api_key.

To bill in a coin instead of USD, set currency to an asset id such as "USDT_TRC20"; to limit the coins on the checkout, add an AllowedAssets []string field tagged allowed_assets. Every field is in the API docs, and the OpenAPI document can generate Go types with a tool such as oapi-codegen if you would rather not write them by hand.

2. Send the customer to payment_url

In a handler that is http.Redirect(w, r, inv.PaymentURL, http.StatusSeeOther). The checkout works on phones and desktops, shows a QR code with the address and amount, and asks for the rest if the customer sends too little. Save inv.ID with the order so the webhook can be matched to it.

3. Verify the webhook

Each delivery carries X-Webhook-Signature: t=…,v1=…, where v1 is the lower-case hex HMAC-SHA256 of t + "." + raw body under the endpoint's signing secret. This server checks it and then decodes the event:

package main

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

var secret = []byte(os.Getenv("EASYWAY_WEBHOOK_SECRET")) // Dashboard → Developers → Webhooks

type event struct {
	ID   string `json:"id"`
	Type string `json:"type"`
	Data struct {
		Invoice struct {
			ID        string `json:"id"`
			OrderID   string `json:"order_id"`
			NetAmount string `json:"net_amount"`
			PayAsset  string `json:"pay_asset"`
		} `json:"invoice"`
	} `json:"data"`
}

// verify checks "t=…,v1=…": v1 must be hex HMAC-SHA256(secret, t + "." + raw body), t within 5 minutes.
func verify(header string, raw []byte, now time.Time) bool {
	var t, v1 string
	for _, kv := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(kv, "=")
		switch k {
		case "t":
			t = v
		case "v1":
			v1 = v
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || v1 == "" {
		return false
	}
	if d := now.Unix() - ts; d > 300 || d < -300 {
		return false
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(t + "."))
	mac.Write(raw)
	want := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(want), []byte(v1)) // constant-time compare
}

func handle(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<10)) // the RAW body is what was signed
	if err != nil || !verify(r.Header.Get("X-Webhook-Signature"), raw, time.Now()) {
		http.Error(w, "bad signature", http.StatusBadRequest)
		return
	}
	var ev event
	if err := json.Unmarshal(raw, &ev); err != nil {
		http.Error(w, "bad body", http.StatusBadRequest)
		return
	}
	// Store ev.ID and skip repeats: deliveries are retried until you answer 2xx.
	if ev.Type == "invoice.paid" {
		inv := ev.Data.Invoice
		log.Printf("order %s paid: %s %s", inv.OrderID, inv.NetAmount, inv.PayAsset)
		// go markOrderPaid(inv.OrderID, inv.NetAmount) — answer fast, do the work afterwards
	}
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("POST /webhooks/easyway", handle)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

We sent it deliveries signed by easyway's own signing function. The genuine one got 200 and logged the order; the same delivery with a changed amount, with a timestamp 400 seconds old, signed with another secret, or without the header got 400.

PitfallFix
Decoding before verifyingRead the body once with io.ReadAll, verify those exact bytes, then json.Unmarshal them. A json.Decoder on r.Body leaves you nothing to hash.
Comparing with ==hmac.Equal takes the same time however many characters match.
Unbounded bodieshttp.MaxBytesReader caps what a stranger can make you read; 256 KB is far above any real event.
Old timestampsReject anything more than five minutes from your clock, in either direction, and keep the host on NTP.
DuplicatesDeliveries are retried until you answer 2xx; store the event id and skip repeats.
Slow handlerAnswer within 10 seconds; hand the order update to a goroutine or a queue.

The same recipe in Node.js, PHP and Python, plus a way to sign a test request with openssl, is in the webhook signature guide; a JDK-only version of this post is the Java one.

Run it

go mod init shop && mkdir -p createinvoice webhook   # one main.go in each
EASYWAY_API_KEY=sk_test_… go run ./createinvoice
EASYWAY_WEBHOOK_SECRET=whsec_… go run ./webhook    # listens on :8080

With a test key, create an invoice, open its payment_url and press Simulate payment to receive a real invoice.paid delivery; the sandbox guide has the details. The webhook URL must be reachable from the internet, so expose port 8080 through a tunnel while testing.

Errors worth a branch

StatuserrorWhat to do
400validation_errorA field is missing or malformed; the response says which.
401invalid_api_keyWrong, revoked or test-vs-live key.
409request_in_progressThe same Idempotency-Key is still running: retry shortly.
422idempotency_key_reusedSame key, different body: a new order needs a new key.
429rate_limitedOver 300 requests per minute on the key: back off and retry.

Branch on the error code, never on the message text. For what happens between the checkout and your webhook, see how crypto payment gateways work; the fee is a flat 0.5% of each confirmed payment, see pricing.

Create free accountRead the API docsAlready have an account? Sign in
Crypto payment API in Go: invoices and webhooks | easyway