# Go + PrintedSimply

Source: https://portal.printedsimply.com/docs/quickstarts/go

Build a high-throughput print-on-demand service on the PrintedSimply REST API. A lightweight HTTP client, handlers, and real signed-webhook verification — no SDK required, live on a $0 test key.

$0 sandbox key

Idempotent orders

Signed webhooks

1. Initialize Project

```
go mod init myapp
go get github.com/joho/godotenv
```

Add your API key to `.env`:

```
# .env
PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY
PRINTEDSIMPLY_WEBHOOK_SECRET=whsec_YOUR_SECRET
```

2. Create the API Client

A lightweight HTTP client for the PrintedSimply API:

```
// ps/client.go
package ps

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

const baseURL = "https://portal.printedsimply.com/api/v1"

type Client struct {
	apiKey string
	http   *http.Client
}

func NewClient(apiKey string) *Client {
	return &Client{apiKey: apiKey, http: &http.Client{}}
}

func (c *Client) get(path string) (json.RawMessage, error) {
	req, _ := http.NewRequest("GET", baseURL+path, nil)
	req.Header.Set("x-api-key", c.apiKey)
	return c.do(req)
}

func (c *Client) post(path string, body any, idempotencyKey string) (json.RawMessage, error) {
	data, _ := json.Marshal(body)
	req, _ := http.NewRequest("POST", baseURL+path, bytes.NewReader(data))
	req.Header.Set("x-api-key", c.apiKey)
	req.Header.Set("Content-Type", "application/json")
	if idempotencyKey != "" {
		req.Header.Set("Idempotency-Key", idempotencyKey)
	}
	return c.do(req)
}

func (c *Client) do(req *http.Request) (json.RawMessage, error) {
	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)
	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, raw)
	}
	return raw, nil
}

func (c *Client) ListProducts() (json.RawMessage, error) {
	return c.get("/products")
}

func (c *Client) GetProduct(sku string) (json.RawMessage, error) {
	return c.get("/products/" + sku)
}

// CreateOrder places an order. The same idempotencyKey on a retry returns the
// first result instead of placing a second order.
func (c *Client) CreateOrder(order map[string]any, idempotencyKey string) (json.RawMessage, error) {
	return c.post("/order", order, idempotencyKey)
}

func (c *Client) GetOrder(id string) (json.RawMessage, error) {
	return c.get("/order/" + id)
}

func (c *Client) Estimate(body map[string]any) (json.RawMessage, error) {
	return c.post("/pricing/estimate", body, "")
}
```

3. Add HTTP Handlers

Products, orders, and a simple HTTP server. It also registers the webhook handler you add in the next step:

```
// main.go
package main

import (
	"encoding/json"
	"log"
	"myapp/ps"
	"net/http"
	"os"
	"strings"

	"github.com/joho/godotenv"
)

var client *ps.Client

func main() {
	godotenv.Load()
	client = ps.NewClient(os.Getenv("PRINTEDSIMPLY_API_KEY"))

	http.HandleFunc("/products", handleProducts)
	http.HandleFunc("/orders", handleCreateOrder)
	http.HandleFunc("/orders/", handleGetOrder)
	http.HandleFunc("/webhooks/printedsimply", handleWebhook) // in webhook.go

	log.Println("Server running on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleProducts(w http.ResponseWriter, r *http.Request) {
	data, err := client.ListProducts()
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Write(data)
}

func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		http.Error(w, "Method not allowed", 405)
		return
	}
	var body map[string]any
	json.NewDecoder(r.Body).Decode(&body)

	// reference is your own order id. It is stamped on the order, and it makes
	// the Idempotency-Key: a retry of the same order returns the first result
	// instead of placing a second one.
	reference, _ := body["reference"].(string)
	if reference == "" {
		http.Error(w, "reference (your order id) is required", 400)
		return
	}

	data, err := client.CreateOrder(body, "order-"+reference)
	if err != nil {
		http.Error(w, err.Error(), 400)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(201)
	w.Write(data)
}

func handleGetOrder(w http.ResponseWriter, r *http.Request) {
	id := strings.TrimPrefix(r.URL.Path, "/orders/")
	data, err := client.GetOrder(id)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Write(data)
}
```

4. Handle Webhooks

Create `webhook.go` beside `main.go`, in the same package. It verifies `X-PS-Signature-V2` over the raw body and rejects a stale timestamp, so a captured delivery cannot be replayed; skips an `X-PS-Event-Id` it has already handled; and reads the order from the event's `data`. Run the package (`go run .`), so both files are built.

```
// webhook.go
package main

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

// Every delivery is {"id", "event", "timestamp", "data"}; the order is in data.
type webhookEvent struct {
	ID    string `json:"id"`
	Event string `json:"event"`
	Data  struct {
		OrderID        string `json:"order_id"`
		Reference      string `json:"reference"`
		Status         string `json:"status"`
		TrackingNumber string `json:"tracking_number"`
		TrackingURL    string `json:"tracking_url"`
	} `json:"data"`
}

// Delivery is at-least-once: a retry or a redelivery sends the same event
// again, with the same X-PS-Event-Id. Skip an id you have already handled.
// In production, keep handled ids in your database under a unique index;
// this map stands in for it.
var (
	handledMu sync.Mutex
	handled   = map[string]bool{}
)

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}
	// The raw bytes, exactly as signed.
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	signature := r.Header.Get("X-PS-Signature-V2")
	if !verifyWebhook(body, signature, os.Getenv("PRINTEDSIMPLY_WEBHOOK_SECRET")) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	eventID := r.Header.Get("X-PS-Event-Id")
	handledMu.Lock()
	seen := handled[eventID]
	handledMu.Unlock()
	if seen {
		w.WriteHeader(http.StatusOK)
		return
	}

	var event webhookEvent
	if err := json.Unmarshal(body, &event); err != nil {
		http.Error(w, "Bad payload", http.StatusBadRequest)
		return
	}
	switch event.Event {
	case "order.shipped":
		log.Printf("Shipped! Order %s, tracking %s", event.Data.OrderID, event.Data.TrackingNumber)
		// Update your database
	case "order.delivered":
		log.Printf("Delivered! Order %s", event.Data.OrderID)
	}
	// Answer 200 to events you do not handle too: anything else is a failed delivery.

	handledMu.Lock()
	handled[eventID] = true
	handledMu.Unlock()
	w.WriteHeader(http.StatusOK)
}

const toleranceSeconds = 300

// verifyWebhook checks the raw X-PS-Signature-V2 value, e.g. "t=1754222400,v1=abc123...".
// During a secret rotation the header carries one v1 per live secret
// ("t=...,v1=<new>,v1=<old>"), so collect them all and accept ANY match.
func verifyWebhook(rawBody []byte, header, secret string) bool {
	t := ""
	var v1s []string
	for _, part := range strings.Split(header, ",") {
		key, value, found := strings.Cut(strings.TrimSpace(part), "=")
		if !found {
			continue
		}
		if key == "t" {
			t = value
		} else if key == "v1" {
			v1s = append(v1s, value)
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || len(v1s) == 0 {
		return false
	}

	// Reject a captured request replayed outside the window. This check is the
	// reason to prefer V2 — a body-only signature never expires.
	age := time.Now().Unix() - ts
	if age > toleranceSeconds || age < -toleranceSeconds {
		return false
	}

	// The timestamp is signed along with the body, so it cannot be swapped out.
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	for _, v1 := range v1s {
		// hmac.Equal is constant-time and returns false on a length mismatch.
		if hmac.Equal([]byte(v1), []byte(expected)) {
			return true
		}
	}
	return false
}
```

5. Run It

```
go run .
```

Test with curl: `curl http://localhost:8080/products`

Safe retries, $0 to test

Send an `Idempotency-Key` on `POST /order` so a retry never double-charges. On an `ps_test_` key the full order lifecycle runs for $0.
