Build an Idempotent Payment API in Go
Build a race-safe Go payment API that prevents duplicate processing on retries.
Introduction
30 Second Summary
A payment can succeed even when its confirmation never reaches the customer. If the app retries blindly, one purchase can become two charges.
In this project, you will build a local payment simulation HTTP API using Go. You will turn a visible duplicate-payment bug into a tested idempotency contract.
What You'll Build
Your finished API returns one processed payment ID when the same keyed JSON request arrives twice, giving you retry-safe behavior you can demonstrate from the terminal.
By the end of this project, you'll have:
- Sent JSON payment requests to /payments and received generated payment IDs with a processed status.
- Reproduced the duplicate-processing bug by retrying one request and observing two different payment IDs.
- Proved the idempotency contract by sending one Idempotency-Key twice and seeing the original payment ID replayed.
- Secret Mission: Launch ten concurrent retries with one idempotency key to prove they create exactly one payment under the Go race detector.
Are there any prerequisites?
Your Linux device is enough to begin. Step 1 walks you through setting up Go 1.27.3, Visual Studio Code, and curl.
Before We Start
Before any hands-on work, commit to the payment failure you are about to reproduce. Naming why retries can create duplicate charges gives the fix a clear purpose.
Set Up the Go Workspace
A duplicate-payment bug is difficult to investigate when the development tools add their own uncertainty. A reproducible Go 1.27.3 workspace gives every later request the same foundation.
In this step, you’ll install the required toolchain. You’ll also create a pinned module for the payment API.
In this step, get ready to:
- Install Go 1.27.3 for your Linux architecture.
- Verify curl and Visual Studio Code.
- Create the idempotent-payments module.
Install Go 1.27.3
Go publishes separate Linux archives for x86-64 processors and ARM64 processors. Checking the architecture first prevents you from installing a toolchain that your device cannot run.
- Identify your Linux architecture by running this command:
uname -m
What does this command show?
The command prints your machine architecture. An x86_64 result uses the AMD64 archive.
An aarch64 result uses the ARM64 archive.
- Check the Go version currently available by running:
go version
What does this check prove?
The command reports whether the Go toolchain is available through your terminal. Its output also identifies the installed version.
✔️ I see Go 1.27.3
Your Go toolchain already matches this project. Keep this terminal available for the next check.
ⓧ I see an older Go version
The existing toolchain needs to be replaced so the module uses Go 1.27.3 consistently.
x86-64
- Open the official Go downloads page in your browser.
- Download go1.27.3.linux-amd64.tar.gz.
ARM64
- Open the official Go downloads page in your browser.
- Download go1.27.3.linux-arm64.tar.gz.
Replacing the old toolchain only changes the Go installation under /usr/local/go. Your existing project files stay untouched.
- Open the official Linux installation guide.
- Remove the previous /usr/local/go installation using the documented Linux procedure.
- Extract your downloaded archive into /usr/local using the documented Linux procedure.
- Add this line to $HOME/.profile using your Linux text editor:
export PATH=$PATH:/usr/local/go/bin
Why update PATH?
The PATH setting tells the terminal where to find executable programs. This entry makes the toolchain under /usr/local/go/bin available as go.
- Close the current terminal.
- Open a new terminal from your Linux application menu.
- Confirm the replacement by running:
go version
What should the version check show?
The output should include go1.27.3. It should also report either amd64 or arm64 for the selected archive.
ⓧ Go is not installed
You need the official Linux archive that matches the architecture reported earlier.
x86-64
- Open the official Go downloads page in your browser.
- Download go1.27.3.linux-amd64.tar.gz.
ARM64
- Open the official Go downloads page in your browser.
- Download go1.27.3.linux-arm64.tar.gz.
The installation writes the toolchain under /usr/local/go. Your downloaded archive remains available after extraction.
- Open the official Linux installation guide.
- Remove any partial /usr/local/go installation using the documented Linux procedure.
- Extract your downloaded archive into /usr/local using the documented Linux procedure.
- Add this line to $HOME/.profile using your Linux text editor:
export PATH=$PATH:/usr/local/go/bin
Why update PATH?
The profile entry makes the Go executable available whenever a new terminal starts. Your shell can then resolve the go command from /usr/local/go/bin.
- Close the current terminal.
- Open a new terminal from your Linux application menu.
- Confirm the installation by running:
go version
What should the version check show?
The output should include go1.27.3. It should also report either amd64 or arm64 for the selected archive.
Go still unavailable?
- Confirm that $HOME/.profile contains the /usr/local/go/bin entry.
- Restart the terminal so it reloads the profile.
- Use your distribution’s administrator privileges if the extraction step reports a permissions problem.
Still stuck? Help me troubleshoot my Go 1.27.3 installation on Linux
Install curl and Visual Studio Code
curl sends the HTTP requests that exercise your payment handler. Visual Studio Code gives the module a shared editor and terminal workspace.
- Check whether curl is available by running:
curl --version
What does this check prove?
A successful response prints curl version details. It also lists the protocols and features supported by your installed build.
✔️ I see curl details
curl is ready to send payment requests once the local server is running.
ⓧ Command not found
Install curl from your Linux distribution’s package repository. Select the route that matches your package manager.
Debian or Ubuntu
- Refresh the available package information by running:
sudo apt update
What does this command do?
The package manager refreshes its local list of available software. This helps it locate the current curl package from the configured repositories.
- Install curl by running:
sudo apt install curl
What does this command do?
The package manager installs curl from the configured Debian or Ubuntu repository. Approve the installation if the terminal asks for confirmation.
Fedora or RPM
- Install curl from the configured repository by running:
sudo dnf install curl
What does this command do?
The package manager downloads curl from the configured RPM repository. Approve the installation if the terminal asks for confirmation.
Another distribution
- Open your distribution’s package manager documentation.
- Find the repository package named curl.
- Install that package using the documented command for your distribution.
- Confirm the installation by running:
curl --version
What should you see?
The terminal should print curl version details without reporting a missing command.
- Check whether the Visual Studio Code command is available by running:
code --version
What does this check prove?
The command prints the installed Visual Studio Code version. It also confirms that the code command is available from your terminal.
✔️ I see a Visual Studio Code version
Visual Studio Code is installed. The terminal can launch it directly into a selected folder.
ⓧ Command not found
Microsoft provides Debian packages, RPM packages, and a Snap package for Linux. Select the route supported by your distribution.
Debian package
- Open Microsoft’s Visual Studio Code Linux setup page.
- Download the Debian package for your processor architecture.
- Install the downloaded package using your distribution’s package installer.
RPM package
- Open Microsoft’s Visual Studio Code Linux setup page.
- Configure Microsoft’s repository using the documented RPM instructions.
- Install Visual Studio Code by running:
sudo dnf install code
What does this command do?
The package manager installs Visual Studio Code from the Microsoft repository you configured. Approve the installation if the terminal asks for confirmation.
Snap package
- Install the official Snap package by running:
sudo snap install --classic code
What does this command do?
Snap installs Visual Studio Code with classic confinement. This access level allows the editor to work with project files across your home folder.
- Close the current terminal.
- Open a new terminal from your Linux application menu.
- Confirm the installation by running:
code --version
What should you see?
The terminal should print the Visual Studio Code version without reporting a missing command.
Still missing a client tool?
- Restart the terminal after installing curl or Visual Studio Code.
- Confirm that you used the package route for your Linux distribution.
- Review the package manager output for a repository or network problem.
Need help? Help me verify curl and Visual Studio Code on Linux
Create and open the Go module
A Go module records the project’s import path and required Go version. Pinning those values now keeps the workspace reproducible.
- Create the idempotent-payments folder on your Desktop by running:
mkdir -p ~/Desktop/idempotent-payments
cd ~/Desktop/idempotent-payments
pwd
What do these commands do?
- The first command creates the project folder on your Desktop.
- The second command moves the terminal into that folder.
- The final command prints the current location so you can confirm it ends with /Desktop/idempotent-payments.
- Initialize the module with its project path by running these commands:
go mod init example/idempotent-payments
go mod edit -go=1.27.3
cat go.mod
What do these commands create?
- The first command creates go.mod with the module path example/idempotent-payments.
- The second command pins the module to Go 1.27.3.
- The final command prints the saved file so you can inspect both values immediately.
Before you run the final check, do you expect all three tools to respond from inside the new module folder?
- Verify the toolchain and open the module by running:
go version
curl --version
code .
What does the final check prove?
- The first command confirms that the terminal resolves Go 1.27.3.
- The second command confirms that curl can send the API requests used later.
- The final command opens the current idempotent-payments folder in Visual Studio Code.
You should see go1.27.3 in the Go output. You should also see curl details before Visual Studio Code opens the module folder.
✔️ Awesome, I've got everything!
Your workspace is ready. Keep Visual Studio Code open with the terminal available in the idempotent-payments folder.
ⓧ I'd like to double check the full code
module example/idempotent-payments
go 1.27.3
What should match?
Your go.mod file should contain this module path and Go directive exactly. These two lines define the workspace identity for every file you add next.
That removes the tooling uncertainty from your reliability experiment. Next up, you’ll serve a payment request and see the API return its first processed response.
Serve Your First Payment
Your Go workspace is ready. The payment API still needs a visible result that proves a client can reach it.
In this step, you will build a small HTTP server that turns one payment request into a JSON response. Your first request creates payment ID 1 with a processed status.
In this step, get ready to:
- Model the payment request and response data.
- Build a handler that validates and processes one payment.
- Start the local server and create your first payment with curl.
Model the payment data
The server needs a clear shape for incoming payment details. It also needs a separate shape for the result sent back to the client.
- In Visual Studio Code, create main.go inside the open idempotent-payments folder using the new-file control in the file sidebar.
- Define the package and payment data shapes by pasting this code into main.go:
package main
import (
"encoding/json/v2"
"net/http"
)
type PaymentRequest struct {
AmountCents int `json:"amount_cents"`
Currency string `json:"currency"`
}
type Payment struct {
ID int `json:"id"`
AmountCents int `json:"amount_cents"`
Currency string `json:"currency"`
Status string `json:"status"`
}
What do these types represent?
- PaymentRequest describes the two values accepted from a client.
- Payment adds the generated ID and processing status returned by the server.
- The JSON tags connect Go field names such as AmountCents to request fields such as amount_cents.
- Save main.go.
- Confirm the editor shows PaymentRequest above Payment with both JSON field names visible.
Seeing syntax problems in the types?
- Check that each struct starts with an opening brace and ends with a closing brace.
- Check that the JSON tags use backticks around each field mapping.
Still stuck? Help me check my Go structs and JSON tags
A small in-memory service will assign each accepted payment an ID. The nextID field begins at 1 so the first response has an easy-to-spot identifier.
- Add the response error shape and payment service directly below Payment by pasting:
type errorResponse struct {
Error string `json:"error"`
}
type paymentService struct {
nextID int
}
func newPaymentService() *paymentService {
return &paymentService{nextID: 1}
}
How does ID generation work?
- errorResponse gives every rejected request a consistent JSON shape.
- paymentService owns the next payment ID.
- newPaymentService creates a fresh service whose first payment receives ID 1.
- Save main.go.
- Confirm the editor shows nextID inside paymentService.
Build the payment handler
A handler receives the request body and checks the payment details. It creates a payment only when the amount is positive and the currency is USD.
Why Go's standard library?
The standard library keeps routing and response writing visible. You can see how a request reaches the handler without a framework hiding those steps.
- Add the route registration method below newPaymentService by pasting:
func (s *paymentService) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/payments", s.handlePayments)
return mux
}
How does the route connect?
- http.NewServeMux creates the request router.
- The /payments route sends matching requests to handlePayments.
- Returning the router lets the server use it as an HTTP handler.
- Save main.go.
- Confirm the editor shows /payments inside routes().
The core handler decodes one request and rejects invalid payment values. A valid request consumes the current ID before advancing nextID.
- Add the first complete version of handlePayments below routes() by pasting:
func (s *paymentService) handlePayments(w http.ResponseWriter, r *http.Request) {
var input PaymentRequest
if err := json.UnmarshalRead(r.Body, &input); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "request body must be valid payment JSON"})
return
}
if input.AmountCents <= 0 {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "amount_cents must be greater than zero"})
return
}
if input.Currency != "USD" {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "currency must be USD"})
return
}
payment := Payment{
ID: s.nextID,
AmountCents: input.AmountCents,
Currency: input.Currency,
Status: "processed",
}
s.nextID++
writeJSON(w, http.StatusCreated, payment)
}
How does the handler process a payment?
- json.UnmarshalRead decodes the request body into PaymentRequest.
- The validation checks protect the service from invalid amounts and unsupported currencies.
- A valid request receives the current ID and a processed status.
- Incrementing nextID prepares a different ID for the next valid request.
- Save main.go.
- Confirm the handler contains separate checks for AmountCents and Currency.
Handler showing an incomplete block?
- Match each opening brace with a closing brace.
- Check that payment := Payment{ closes before s.nextID++.
Still stuck? Help me find the syntax problem in handlePayments
The route should accept payment creation through POST. Other methods receive a clear method error instead of reaching the payment logic.
- In handlePayments, place this method check immediately after the opening brace:
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
return
}
Why check the request method?
The method check keeps /payments focused on creating payments. It also tells the client that POST is the allowed method.
- Save main.go.
- Confirm the method check appears before var input PaymentRequest.
Every handler exit needs the same response-writing behavior. The helper below converts a Go value into JSON before sending the selected status code.
- Add writeJSON directly below handlePayments by pasting:
func writeJSON(w http.ResponseWriter, status int, body any) {
data, err := json.Marshal(body)
if err != nil {
http.Error(w, "could not encode response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(data)
}
What does the JSON helper do?
- json.Marshal converts the response value into JSON bytes.
- The content type tells the client that the body contains JSON.
- WriteHeader sends the chosen HTTP status before the body.
- The final write sends the encoded response to the client.
- Save main.go.
- Confirm writeJSON appears directly after the payment handler.
Start the server and create a payment
The final function creates the service and starts listening on localhost:8080. The running process keeps its terminal session busy while it waits for requests.
- In main.go, locate the current import block shown here:
import (
"encoding/json/v2"
"net/http"
)
What is changing in the imports?
The current imports support JSON decoding and HTTP handling. The server entry point also needs the standard logger for its startup message and fatal server errors.
- Replace the current import block with this final version:
import (
"encoding/json/v2"
"log"
"net/http"
)
What does the added import provide?
The log package prints the server's startup message. It also ends the process if the server cannot start.
- Add the server entry point below writeJSON by pasting:
func main() {
service := newPaymentService()
log.Println("payment API listening on localhost:8080")
log.Fatal(http.ListenAndServe("localhost:8080", service.routes()))
}
How does the server start?
- newPaymentService creates the in-memory payment state.
- The log message shows which local address the server uses.
- http.ListenAndServe connects localhost:8080 to the registered routes.
- Save main.go.
- Compare your file with the complete version below.
✔️ Awesome, I've got everything!
Your payment server code is complete. Make sure main.go is saved before starting it.
ⓧ I'd like to double check the full code
package main
import (
"encoding/json/v2"
"log"
"net/http"
)
type PaymentRequest struct {
AmountCents int `json:"amount_cents"`
Currency string `json:"currency"`
}
type Payment struct {
ID int `json:"id"`
AmountCents int `json:"amount_cents"`
Currency string `json:"currency"`
Status string `json:"status"`
}
type errorResponse struct {
Error string `json:"error"`
}
type paymentService struct {
nextID int
}
func newPaymentService() *paymentService {
return &paymentService{nextID: 1}
}
func (s *paymentService) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/payments", s.handlePayments)
return mux
}
func (s *paymentService) handlePayments(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
return
}
var input PaymentRequest
if err := json.UnmarshalRead(r.Body, &input); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "request body must be valid payment JSON"})
return
}
if input.AmountCents <= 0 {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "amount_cents must be greater than zero"})
return
}
if input.Currency != "USD" {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "currency must be USD"})
return
}
payment := Payment{
ID: s.nextID,
AmountCents: input.AmountCents,
Currency: input.Currency,
Status: "processed",
}
s.nextID++
writeJSON(w, http.StatusCreated, payment)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
data, err := json.Marshal(body)
if err != nil {
http.Error(w, "could not encode response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(data)
}
func main() {
service := newPaymentService()
log.Println("payment API listening on localhost:8080")
log.Fatal(http.ListenAndServe("localhost:8080", service.routes()))
}
How to compare the file
Check the order of the imports and functions. Pay close attention to closing braces around handlePayments and writeJSON.
Before you start the server, what message do you expect the terminal to show when it begins listening?
- Start the payment API from the terminal in the module folder by running:
go run .
What should you see?
The terminal should show payment API listening on localhost:8080. The command remains active because the server is waiting for requests.
- Leave the server running in this terminal session.
- Start a second terminal session in Visual Studio Code.
Before you send the request, which response fields do you think the server will generate for you?
- Send your first payment to the running server by executing this command in the second terminal:
curl --header "Content-Type: application/json" \
--data '{"amount_cents":2500,"currency":"USD"}' \
http://localhost:8080/payments
What does this request send?
- The header tells the server that the request body contains JSON.
- The data contains a payment amount of 2500 cents.
- The currency value is USD.
- The URL sends the request to the local /payments route.
The second terminal prints JSON containing "id":1 and "status":"processed". This proves the handler accepted the request and generated the first payment.
Payment request not returning JSON?
- Check that the first terminal still shows the running server process.
- Check that main.go uses localhost:8080 in the server entry point.
- Check that the request body keeps the field name amount_cents in lowercase.
Still stuck? Help me debug my local payment API request
You have your first visible win. Your local server now accepts payment JSON and returns a generated processed payment.
Your baby payment API works. Next, you will retry the same client request and discover why this version cannot protect a payment from duplicate processing.
Expose the Duplicate Payment Bug
Your local Go payment server is already running at localhost:8080. It can turn one valid request into a processed payment.
Retries are where payment reliability gets tested. This step asks whether the current handler can recognize that two identical requests represent one client intent.
In this step, get ready to:
- Send the same JSON payment request twice without changing its body.
- Compare the response IDs to identify how the server handles the retry.
Retry the same payment
A retry repeats an earlier request when a client still needs a definite result. Both requests below carry the same payment details.
Before you send the pair, do you think the server can recognize that both requests represent one payment?
- Send the identical payment request twice by running these commands:
curl --header "Content-Type: application/json" \
--data '{"amount_cents":2500,"currency":"USD"}' \
http://localhost:8080/payments
curl --header "Content-Type: application/json" \
--data '{"amount_cents":2500,"currency":"USD"}' \
http://localhost:8080/payments
What happened to the retry?
The first response has one id. The second response has a different id.
The handler increments nextID after every valid request. It has no idempotency contract for connecting a retry to the original payment.
Requests not returning two responses?
- Switch back to the server terminal if curl cannot connect.
- Confirm the server process is still running beneath payment API listening on localhost:8080.
- Run both requests against the same server process if the IDs do not increase.
Help me troubleshoot why my two identical payment requests are not returning different IDs.
Compare the two results
The pair of IDs is the evidence. One client intent produced two payment records.
- Compare the id value in the first response with the value in the second response.
- Confirm both responses contain a status value of processed.
You should see two different id values. Both responses should show processed.
Good catch. One client intent produced two payment IDs.
Next, you'll give that intent a stable identity so every retry returns the original payment.
Make Retries Idempotent
Your Go server exposed the exact reliability gap this step solves. One client intent produced two payment IDs because every retry looked like new work.
Idempotency gives a retry a stable identity. A mutex protects the shared state while the server checks that identity.
In this step, get ready to:
- Store payments using request identities.
- Reject requests that lack an idempotency key.
- Verify that repeated requests replay one payment.
Store request identities safely
The service needs a map that connects each request key to its payment. The mutex keeps the lookup plus the possible insert inside one protected operation.
- Return to the server terminal from the previous step.
- Stop the running process using your terminal's interrupt shortcut.
- Switch back to main.go in Visual Studio Code.
- Replace the import block in main.go with the code below:
import (
"encoding/json/v2"
"log"
"net/http"
"sync"
)
What Changed in the Imports?
The sync package provides the mutex used to protect shared payment state. Every request passes through that shared state.
- Find type paymentService struct in main.go.
- Select through the closing brace of newPaymentService.
- Replace the selected section with the code below:
type paymentService struct {
mu sync.Mutex
nextID int
paymentsByKey map[string]Payment
}
func newPaymentService() *paymentService {
return &paymentService{
nextID: 1,
paymentsByKey: make(map[string]Payment),
}
}
What Does This State Do?
- The mu field controls access to the service's shared state.
- The paymentsByKey map stores one payment for each request key.
- The newPaymentService function creates an empty map before the handler receives requests.
- Save main.go.
- Start the updated API by running this command:
go run .
What Does This Command Check?
The command compiles the updated service before starting it. A successful start confirms that the new map plus mutex have valid types.
Good progress. You'll see a log line containing payment API listening on localhost:8080, which confirms the synchronized service state compiles.
Server Not Starting?
- Check that "sync" appears inside the import block.
- Check that paymentsByKey uses the exact type map[string]Payment.
- Check that the map is initialized with make(map[string]Payment).
Still stuck? Help me debug the synchronized payment service state.
- Stop the running server using your terminal's interrupt shortcut.
Enforce the request contract
The Idempotency-Key header gives each payment intent its identity. Strict JSON decoding also rejects unexpected fields before they reach the payment logic.
- Find var input PaymentRequest inside handlePayments.
- Select the declaration plus its current JSON decode error block.
- Replace the selected section with the code below:
key := r.Header.Get("Idempotency-Key")
if key == "" {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "Idempotency-Key header is required"})
return
}
var input PaymentRequest
if err := json.UnmarshalRead(r.Body, &input, json.RejectUnknownMembers(true)); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "request body must be valid payment JSON"})
return
}
How Does the Contract Work?
- The header lookup stores the request identity in key.
- An empty key produces a bad request response before payment processing begins.
- The json.RejectUnknownMembers(true) option rejects unexpected JSON fields.
- Save main.go.
- Restart the API by running this command:
go run .
What Does This Restart Prove?
The restart compiles the new header requirement plus strict decoder. It also gives the in-memory service a fresh payment map.
You'll see a log line containing payment API listening on localhost:8080.
- Return to the curl terminal from the previous step.
- Send a keyed payment request by running this command:
curl --header "Content-Type: application/json" \
--header "Idempotency-Key: demo-123" \
--data '{"amount_cents":2500,"currency":"USD"}' \
http://localhost:8080/payments
What Did This Request Prove?
- The first --header identifies the body as JSON.
- The second --header assigns demo-123 to this payment intent.
- The --data option sends the payment body with a POST request.
The request now satisfies the key requirement. You'll see JSON containing "id":1 plus "status":"processed".
Request Rejected?
- Check that the request includes Idempotency-Key: demo-123 exactly.
- Check that the server terminal still shows the API running at localhost:8080.
- Check that the body contains a positive amount_cents value.
Still stuck? Help me debug the keyed payment request.
- Return to the server terminal.
- Stop the running process using your terminal's interrupt shortcut.
Replay the original payment
The lookup plus insert must remain protected by the same mutex. This closes the race condition where two requests could both miss the key before either stores a payment.
- Find the payment block beginning with payment := Payment{ inside handlePayments.
- Select through writeJSON(w, http.StatusCreated, payment).
- Replace the selected block with the code below:
s.mu.Lock()
if payment, found := s.paymentsByKey[key]; found {
s.mu.Unlock()
writeJSON(w, http.StatusOK, payment)
return
}
payment := Payment{
ID: s.nextID,
AmountCents: input.AmountCents,
Currency: input.Currency,
Status: "processed",
}
s.nextID++
s.paymentsByKey[key] = payment
s.mu.Unlock()
writeJSON(w, http.StatusCreated, payment)
How Does Replay Work?
- The s.mu.Lock() call begins the protected check-and-create operation.
- An existing key returns its stored payment with http.StatusOK.
- A new key stores its payment before releasing the mutex.
- The new payment returns with http.StatusCreated.
- Save main.go.
✔️ Awesome, I've got everything!
Your handler now identifies retries before it creates another payment.
ⓧ I'd like to double check the full code
- Compare main.go with the complete reference below.
package main
import (
"encoding/json/v2"
"log"
"net/http"
"sync"
)
type PaymentRequest struct {
AmountCents int `json:"amount_cents"`
Currency string `json:"currency"`
}
type Payment struct {
ID int `json:"id"`
AmountCents int `json:"amount_cents"`
Currency string `json:"currency"`
Status string `json:"status"`
}
type errorResponse struct {
Error string `json:"error"`
}
type paymentService struct {
mu sync.Mutex
nextID int
paymentsByKey map[string]Payment
}
func newPaymentService() *paymentService {
return &paymentService{
nextID: 1,
paymentsByKey: make(map[string]Payment),
}
}
func (s *paymentService) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/payments", s.handlePayments)
return mux
}
func (s *paymentService) handlePayments(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
return
}
key := r.Header.Get("Idempotency-Key")
if key == "" {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "Idempotency-Key header is required"})
return
}
var input PaymentRequest
if err := json.UnmarshalRead(r.Body, &input, json.RejectUnknownMembers(true)); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "request body must be valid payment JSON"})
return
}
if input.AmountCents <= 0 {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "amount_cents must be greater than zero"})
return
}
if input.Currency != "USD" {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "currency must be USD"})
return
}
s.mu.Lock()
if payment, found := s.paymentsByKey[key]; found {
s.mu.Unlock()
writeJSON(w, http.StatusOK, payment)
return
}
payment := Payment{
ID: s.nextID,
AmountCents: input.AmountCents,
Currency: input.Currency,
Status: "processed",
}
s.nextID++
s.paymentsByKey[key] = payment
s.mu.Unlock()
writeJSON(w, http.StatusCreated, payment)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
data, err := json.Marshal(body)
if err != nil {
http.Error(w, "could not encode response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(data)
}
func main() {
service := newPaymentService()
log.Println("payment API listening on localhost:8080")
log.Fatal(http.ListenAndServe("localhost:8080", service.routes()))
}
How to Use This Reference
Compare the imports plus service state first. Then compare the full handlePayments function.
- Restart the completed API by running this command:
go run .
What Does the Final Restart Do?
The command compiles the complete idempotent handler. It starts the server with a fresh in-memory payment map.
You'll see a log line containing payment API listening on localhost:8080.
Before you send the requests, do you expect the second response to contain a new payment ID or the original payment ID?
- Return to the curl terminal from earlier.
- Send the keyed payment request twice by running both commands below:
curl --header "Content-Type: application/json" \
--header "Idempotency-Key: demo-123" \
--data '{"amount_cents":2500,"currency":"USD"}' \
http://localhost:8080/payments
curl --header "Content-Type: application/json" \
--header "Idempotency-Key: demo-123" \
--data '{"amount_cents":2500,"currency":"USD"}' \
http://localhost:8080/payments
Why Did the IDs Match?
The first request stores its payment under demo-123. The second request finds that key inside the protected map.
The first request returns 201 for creation. The replay returns 200 with the original payment.
Both JSON responses contain the same payment ID. You should see "id":1 plus "status":"processed" in each response.
That's the reliability gap closed. A repeated client intent now produces one payment.
Seeing Different Payment IDs?
- Check that both requests use Idempotency-Key: demo-123 exactly.
- Check that s.paymentsByKey[key] = payment appears before s.mu.Unlock().
- Check that you saved main.go before restarting the server.
Still stuck? Help me debug why my idempotency replay creates another payment.
Your API can now distinguish new work from a retry. Next, you'll preserve that contract with automated handler tests.
Prove the Contract with Handler Tests
Your payment API now returns one stable payment for repeated keys. Clients can depend on that behavior.
Manual retries prove the idea once. Automated handler tests preserve the contract as the backend changes.
These tests call your HTTP handler with controlled requests. They check status codes, replay behavior, and invalid inputs without starting the local server.
In this step, get ready to:
- Build a reusable helper for sending payment requests to the real handler.
- Test payment creation, replay behavior, and invalid inputs.
- Run the complete suite and confirm every contract check passes.
Build the reusable test helper
The helper sends each request through the same handler used by the API. It also decodes successful responses into a `Payment` that each test can inspect.
- In the file sidebar of Visual Studio Code, create `main_test.go` inside the open `idempotent-payments` folder.
- Paste the package declaration and imports into `main_test.go`:
package main
import (
"encoding/json/v2"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
What Does This Code Set Up?
- `net/http/httptest` provides requests and response recorders designed for handler tests.
- `strings` turns each request body into a reader.
- `encoding/json/v2` decodes successful responses into the existing `Payment` type.
- `testing` provides the test runner and failure reporting.
- Save `main_test.go`.
- Confirm `main_test.go` appears beside `main.go` in the open module.
Can't Find the Test File?
Check that `main_test.go` sits inside the same `idempotent-payments` folder as `main.go`. Go only discovers this test file when its name ends with `_test.go`.
Still stuck? Help me place main_test.go in the correct Go module folder
- Place your cursor below the import block in `main_test.go`.
- Add the reusable `performPaymentRequest` helper:
func performPaymentRequest(t *testing.T, handler http.Handler, key, body string) (int, Payment) {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "/payments", strings.NewReader(body))
if key != "" {
request.Header.Set("Idempotency-Key", key)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
response := recorder.Result()
defer response.Body.Close()
var payment Payment
if response.StatusCode < http.StatusBadRequest {
if err := json.UnmarshalRead(response.Body, &payment); err != nil {
t.Fatalf("decode payment response: %v", err)
}
}
return response.StatusCode, payment
}
What Does This Helper Do?
- `httptest.NewRequest` builds a `POST` request for `/payments` from the supplied body.
- The helper adds `Idempotency-Key` when the test supplies a key.
- `httptest.NewRecorder` captures the status and body written by the real handler.
- Successful responses become `Payment` values that tests can compare.
- Save `main_test.go`.
- Switch back to the terminal from earlier.
- Press **Ctrl+C** if the local server still owns that terminal.
- Check that the helper compiles by running:
go test ./...
What Should You See?
The command completes without a compilation failure. This proves the helper can build against your real payment handler.
Helper Not Compiling?
- Compare every imported package with the import block above.
- Check that `performPaymentRequest` sits below the closing parenthesis of the import block.
- Confirm the helper uses the existing `Payment` type from `main.go`.
Need another pair of eyes? Help me debug my Go payment test helper
Test creation and replay
The central contract has two outcomes for one idempotency key. The first request creates a payment while the second request replays its ID.
- Add the replay test below `performPaymentRequest` in `main_test.go`:
func TestDuplicateIdempotencyKeyReplaysPayment(t *testing.T) {
handler := newPaymentService().routes()
body := "{\"amount_cents\":2500,\"currency\":\"USD\"}"
firstStatus, firstPayment := performPaymentRequest(t, handler, "demo-123", body)
secondStatus, secondPayment := performPaymentRequest(t, handler, "demo-123", body)
if firstStatus != http.StatusCreated {
t.Fatalf("first status = %d; want %d", firstStatus, http.StatusCreated)
}
if secondStatus != http.StatusOK {
t.Fatalf("second status = %d; want %d", secondStatus, http.StatusOK)
}
if firstPayment.ID != secondPayment.ID {
t.Fatalf("payment IDs differ: %d and %d", firstPayment.ID, secondPayment.ID)
}
}
How Does This Prove Replay Behavior?
- `newPaymentService().routes()` gives the test a fresh service with empty in-memory state.
- Both requests use `demo-123` with the same payment body.
- The test requires `201 Created` for the first request.
- The test requires `200 OK` with the original payment ID for the replay.
- Save `main_test.go`.
- Run the replay test through the module test suite:
go test ./...
What Should You See?
You should see `ok` beside `example/idempotent-payments`. The replay contract now has an automated check.
Replay Test Failing?
- Confirm both helper calls use the same handler instance.
- Check that both calls use the exact key `demo-123`.
- Compare the two status assertions with `http.StatusCreated` and `http.StatusOK`.
Need help reading the failure? Help me diagnose my idempotency replay test
Cover validation and run the suite
A reliable contract also defines which requests the API rejects. These checks protect the required key and the positive amount rule.
- Add the missing-key test below the replay test in `main_test.go`:
func TestMissingIdempotencyKeyReturnsBadRequest(t *testing.T) {
handler := newPaymentService().routes()
body := "{\"amount_cents\":2500,\"currency\":\"USD\"}"
status, _ := performPaymentRequest(t, handler, "", body)
if status != http.StatusBadRequest {
t.Fatalf("status = %d; want %d", status, http.StatusBadRequest)
}
}
What Does This Test Prove?
An empty key tells the helper to omit `Idempotency-Key`. The test requires the handler to reject that request with `400 Bad Request`.
- Save `main_test.go`.
- Run the expanded test suite:
go test ./...
What Should You See?
You should see `ok` beside the module path again. The suite now protects the required-key rule.
Missing-Key Test Failing?
- Confirm the helper call passes an empty string as its key.
- Check that the assertion expects `http.StatusBadRequest`.
Still failing? Help me debug my missing idempotency key test
- Add the non-positive amount test below the missing-key test in `main_test.go`:
func TestNonPositiveAmountReturnsBadRequest(t *testing.T) {
handler := newPaymentService().routes()
body := "{\"amount_cents\":0,\"currency\":\"USD\"}"
status, _ := performPaymentRequest(t, handler, "invalid-amount", body)
if status != http.StatusBadRequest {
t.Fatalf("status = %d; want %d", status, http.StatusBadRequest)
}
}
What Does This Test Prove?
This request includes a key but sets `amount_cents` to `0`. The test proves the amount validation returns `400 Bad Request` before any payment is created.
- Save `main_test.go`.
✔️ Awesome, I've got everything!
Your three contract tests are in place. Double-check that `main_test.go` is saved.
ⓧ I'd like to double check the full code
package main
import (
"encoding/json/v2"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func performPaymentRequest(t *testing.T, handler http.Handler, key, body string) (int, Payment) {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "/payments", strings.NewReader(body))
if key != "" {
request.Header.Set("Idempotency-Key", key)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
response := recorder.Result()
defer response.Body.Close()
var payment Payment
if response.StatusCode < http.StatusBadRequest {
if err := json.UnmarshalRead(response.Body, &payment); err != nil {
t.Fatalf("decode payment response: %v", err)
}
}
return response.StatusCode, payment
}
func TestDuplicateIdempotencyKeyReplaysPayment(t *testing.T) {
handler := newPaymentService().routes()
body := "{\"amount_cents\":2500,\"currency\":\"USD\"}"
firstStatus, firstPayment := performPaymentRequest(t, handler, "demo-123", body)
secondStatus, secondPayment := performPaymentRequest(t, handler, "demo-123", body)
if firstStatus != http.StatusCreated {
t.Fatalf("first status = %d; want %d", firstStatus, http.StatusCreated)
}
if secondStatus != http.StatusOK {
t.Fatalf("second status = %d; want %d", secondStatus, http.StatusOK)
}
if firstPayment.ID != secondPayment.ID {
t.Fatalf("payment IDs differ: %d and %d", firstPayment.ID, secondPayment.ID)
}
}
func TestMissingIdempotencyKeyReturnsBadRequest(t *testing.T) {
handler := newPaymentService().routes()
body := "{\"amount_cents\":2500,\"currency\":\"USD\"}"
status, _ := performPaymentRequest(t, handler, "", body)
if status != http.StatusBadRequest {
t.Fatalf("status = %d; want %d", status, http.StatusBadRequest)
}
}
func TestNonPositiveAmountReturnsBadRequest(t *testing.T) {
handler := newPaymentService().routes()
body := "{\"amount_cents\":0,\"currency\":\"USD\"}"
status, _ := performPaymentRequest(t, handler, "invalid-amount", body)
if status != http.StatusBadRequest {
t.Fatalf("status = %d; want %d", status, http.StatusBadRequest)
}
}
How Is This File Organized?
The file contains one request helper followed by three focused tests. Compare each function name, request body, key, and status assertion with your saved file.
Before you run the complete suite, make a quick prediction about whether all three tests will pass.
- Run every test in the module:
go test ./...
What Should You See?
You should see `ok` beside `example/idempotent-payments`. That result proves creation, replay, missing-key validation, and amount validation all satisfy the contract.
Suite Not Passing?
- Start with the first test named in the terminal output.
- Compare that test's request body with the full file above.
- Check the reported status against the status required by its assertion.
Need help interpreting the result? Help me debug my Go handler test suite
That is the contract locked in. Automated tests now protect the payment API as its backend changes.
Secret mission
Hammer the API with Concurrent Retries
Ten requests are about to reach one payment handler at nearly the same time. Build a concurrency test that proves they all receive one shared payment ID. Then run the Go race detector to confirm that the shared state stays safe.
Clean Up Your Resources
Clean Up Your Resources
This local project has no ongoing costs. Choose whether to keep your resources running, pause them for later, or delete them entirely.
Resources you used:
- Local Go module example/idempotent-payments inside the idempotent-payments folder.
- Temporary local server process listening on localhost:8080. This applies if the process is still running.
Keep everything running
No action is needed. Choose this if you want to keep testing the payment API.
- Keep the idempotent-payments folder on your device for future development.
- Leave the local server running only while you are testing requests against localhost:8080.
Pause - I'll come back to this later
Stop the running process to free localhost:8080. Your module files stay available for your next session.
- Return to the server terminal from earlier.
- Press Ctrl+C if the payment API is still running.
That frees the local port while preserving every file you created.
Delete - I don't want to use this again
Deleting the module is permanent. Keep the folder if you want to revisit your API or its tests.
- Press Ctrl+C in the server terminal from earlier if the payment API is still running.
- Confirm that you no longer need go.mod, main.go, main_test.go, or concurrent_test.go.
- Remove the local module from inside idempotent-payments by running these commands:
cd ..
rm -rf idempotent-payments
ls
What do these commands do?
- The cd .. command moves the terminal into the folder that contained your module.
- The rm -rf idempotent-payments command permanently removes the local module folder.
- The ls command lists the remaining folders so you can verify the deletion.
In the final command output, you should no longer see idempotent-payments.
Nice Work!
Nice Work!
You did it! Your local Go payment API now uses idempotency to turn duplicate retries into safe replays.
You've learned how to:
- Build a local JSON HTTP API. Accept a payment request at /payments. Return a generated payment ID with a processed status.
- Reproduce the duplicate-processing bug by retrying the same client intent. Prevent the duplicate through an Idempotency-Key contract. Protect the shared check-and-create operation with a mutex.
- Prove new-payment creation with automated handler tests. Test keyed replays. Test missing keys. Test non-positive amounts.
- Secret Mission: Use concurrency to launch ten retries with one Idempotency-Key. Prove every response shares one payment ID. Confirm the Go race detector reports no data race.
Ready to quiz yourself?