Why Your API Needs a Tracking ID
This isn’t the TraceID (or trace_id) used in distributed tracing. The TraceID identifies a trace and is propagated between services; it can even arrive at your API from the client. And tracing works with queues too, as the OpenTelemetry messaging conventions show.
But if your API is asynchronous, processing events through queues, you need an operation tracking ID to correlate events that belong to the same job, even when they span different traces. Under this contract, instead of being generated at the API entry point, the tracking ID must be generated and sent by the client, following whatever ID generation rules you define. The client needs to save this ID before the first request and reuse it when retrying the same operation.
Systems that create payment orders are a clear example. The client sends requests to the API to create these orders, and each order has a unique tracking ID generated by the client. That ID is then used to correlate all events related to that order. For example, your system created the order and sent it off to the API just fine, and the API did some basic validation and put the order in a queue for processing.
From there, you’ll receive events through a webhook or some other mechanism to find out the order’s state and update its status in your system. It can be approved, rejected, canceled, paid, and so on. All these events will carry the same tracking ID you sent in the initial request.
The problem is that if your API doesn’t support a tracking ID and something goes wrong with communication, the client may have no way of knowing whether the request was accepted. The API may have recorded the order and even started processing it; the client is the one left without confirmation. For example, if your server is overloaded and can’t respond in time, the client gets a timeout. And if the request was accepted, the client never received the ID of the order that was created, so it can’t track its status.
That’s why the ID needs to be generated by the client before sending the request, not by your API. You may accept the request and be unable to return the ID to the client. Without knowing what happened, the client may retry and create another order for the same payment.
A timeout doesn’t mean the operation failed.
Here’s a typical Go client that makes a POST request to an API and waits for a response. The URL, token, and ID are placeholders; tracking_id represents the ID the client has already generated and saved for this order. In this example, the API responds with 202 Accepted when it accepts the order for processing.
package main
import (
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
)
func main() {
err := createOrder()
if err != nil {
log.Fatal(err)
}
}
func createOrder() error {
client := &http.Client{
Timeout: 5 * time.Second,
}
req, err := http.NewRequest(
http.MethodPost,
"https://example.com/api/v1/orders",
strings.NewReader(`{"tracking_id":"order-123","amount":1000}`),
)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer ...")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error on request: %w", err)
}
body, err := io.ReadAll(resp.Body)
closeErr := resp.Body.Close()
if err != nil {
return fmt.Errorf("error reading response: %w", err)
}
if closeErr != nil {
return fmt.Errorf("error closing response: %w", closeErr)
}
if resp.StatusCode != http.StatusAccepted {
return fmt.Errorf("unexpected response: %s", resp.Status)
}
log.Printf("body: %s", body)
return nil
}
Notice that I set a 5-second timeout for the request. Five seconds is an eternity for an API that only needs to validate and queue an order. But the client has to set some limit. In Go, http.Client.Timeout covers the connection, redirects, and reading the response body. A value of zero means the client imposes no overall time limit. Without a timeout or a deadline in the request context, it can sit there waiting indefinitely, consuming resources. Some instability is all it takes for hanging requests to pile up.
Ideally, you should always let the client send its own tracking ID. This makes it easier to correlate requests and events for the same operation and lets you build small diagnostic tools: all you need is an endpoint to look up the operation’s status by that ID. The API needs to persist the association between the client-provided ID and the order it created. Just putting that ID in a log doesn’t solve the problem.
That ID can also serve as an idempotency key, but this needs to be part of the API contract. Accepting the ID and returning it in a webhook doesn’t prevent duplicates. To do that, repeating the same operation with the same ID must retrieve the existing operation without creating another order, even when two attempts arrive at the same time. The check and the write need to be atomic. That’s idempotency, not debouncing.
Define the ID’s uniqueness scope, for example, per client and operation type, and how long the API guarantees deduplication. Reusing the same ID with different data must return an error. Stripe’s idempotency documentation shows a contract with parameter comparison and a retention period. When looking up an order by ID, also check that it belongs to the authenticated client; knowing the ID doesn’t grant access to the order.