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
| Step | Who | What happens |
|---|---|---|
| 1. Create | Your Go service | POST /v1/invoices with the amount; the response has an id and a payment_url. |
| 2. Pay | Your customer | Opens payment_url, picks a coin and network, and pays. The page follows the payment by itself. |
| 3. Confirm | easyway → your service | A 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:
| Detail | Why |
|---|---|
Amount string | The 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.WithTimeout | Bounds the whole call. The default http.Client has no timeout at all, so without a context a stalled connection hangs your handler. |
| Idempotency-Key | Retrying 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_asset | The 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. |
| Errors | Any 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.
| Pitfall | Fix |
|---|---|
| Decoding before verifying | Read 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 bodies | http.MaxBytesReader caps what a stranger can make you read; 256 KB is far above any real event. |
| Old timestamps | Reject anything more than five minutes from your clock, in either direction, and keep the host on NTP. |
| Duplicates | Deliveries are retried until you answer 2xx; store the event id and skip repeats. |
| Slow handler | Answer 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 :8080With 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
| Status | error | What to do |
|---|---|---|
| 400 | validation_error | A field is missing or malformed; the response says which. |
| 401 | invalid_api_key | Wrong, revoked or test-vs-live key. |
| 409 | request_in_progress | The same Idempotency-Key is still running: retry shortly. |
| 422 | idempotency_key_reused | Same key, different body: a new order needs a new key. |
| 429 | rate_limited | Over 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.