The Importance of Your API Supporting a Tracking ID

This is not the TraceID (or trace_id) used in distributed tracing. The TraceID identifies a trace and is propagated between services; it can even reach your API coming from the client. And tracing also works with queues, as shown by the OpenTelemetry conventions for messaging.
But if your API is asynchronous, processing events in queues, you need an operation tracking ID to correlate events that belong to the same job, even when they go through different traces. In this contract, instead of being generated at the API's entry point, the tracking ID must be generated and sent by the client, following the ID generation rules you define. The client needs to save this ID before the first request and reuse it on retries of the same operation.
A clear example is systems that create payment orders. The client sends requests for the API to create these orders, and each order has a unique tracking ID, generated by the client. This ID is then used to correlate all events related to that order. For example, your system generated the order, sent it correctly to the API, and the API only performed a basic validation and placed the order in the queue to be processed.
From there, you'll receive events via webhook or some other mechanism to know the order's state and then update its status in your system. It can be approved, rejected, canceled, paid, etc. All these events will have the same tracking ID that you sent in the initial request.
The problem is that, if your API doesn't support a tracking ID and there's some communication issue, the client may end up not knowing whether the request was accepted. The API may have registered the order and even started processing it; it's the client who's left without confirmation. For example, if your server is overloaded and can't respond in time, the client will get a timeout. And if the request was accepted, the client didn't receive the ID of the order that was created in order to track its status.
That's why the ID needs to be generated by the client, before sending, and not by your API. You can accept the request and fail to return the ID to the client. Without knowing what happened, they may try again 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, the token, and the ID are fictitious; the tracking_id represents the ID that 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
}Note that I set a 5-second timeout for the request. 5 seconds is an eternity for an API that only needs to validate and enqueue an order. But the client needs to set some limit there. In Go, the http.Client.Timeout includes the connection, redirects, and reading the response body. The zero value means the client doesn't impose an overall limit. Without a timeout or a deadline in the request's context, it can wait indefinitely, consuming resources. A single instability is enough to pile up hanging requests.
Ideally, you should always allow the client to send its own tracking ID. This makes it easier to correlate requests and events from the same operation and also allows for building small diagnostic tools: all you need is an endpoint to check the operation's status from that ID. The API needs to persist the association between the ID sent by the client and the order created. Just putting this ID in the log doesn't solve it.
Additionally, this ID can serve as an idempotency key, but that needs to be part of the API's contract. Receiving the ID and returning it in the webhook doesn't prevent duplicates. For that, repeating the same operation with the same ID needs to retrieve the existing operation, without creating another order, even when two attempts arrive at the same time. The check and the record need to be atomic. That's idempotency, not debounce.
Define the uniqueness scope of the ID, for example, by client and operation type, and for how long the API guarantees deduplication. Reusing the same ID with different data should result in an error. Stripe's idempotency documentation shows a contract with parameter comparison and a retention period. When querying by ID, also validate whether the order belongs to the authenticated client; knowing the ID doesn't grant permission to access the order.
Translated from the Brazilian Portuguese original · Read the original





