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.
go mod init myapp
go get github.com/joho/godotenvAdd your API key to .env:
# .env
PRINTEDSIMPLY_API_KEY=ps_test_YOUR_KEY
PRINTEDSIMPLY_WEBHOOK_SECRET=whsec_YOUR_SECRETA 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, "")
}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)
}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
}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.