Payments and Emails With TaskFused, Using the Operation ID as the Idempotency Key
A shop charges a customer’s card when they confirm an order, then sends the receipt. The confirm button gets double-clicked, the request gets retried, and a worker might crash at any point. Two charges for one order is the one outcome that must never happen, and a second receipt is embarrassing.
TaskFused already stops the double click and the retry from doing anything twice: they join the same operation. But it runs each operation at least once, and that leaves one gap. A worker can charge the card and then die before it saves “done”. The lease runs out, another worker takes the operation over, and that worker charges again. This post is about closing that gap.

One ID for Every Attempt
Every attempt at an operation has its own attempt ID and token, but the operation ID stays the same through crashes and takeovers. That makes it the right thing to hand to the outside world. Payment providers have a place for it: Stripe, for one, accepts an Idempotency-Key header, and a repeated request with the same key returns the first result instead of charging again.
Handle: func(ctx context.Context, c taskfused.Claim) ([]byte, error) {
var order Order
json.Unmarshal(c.Operation.Input, &order)
charge, err := payments.Charge(ctx, payments.Request{
Amount: order.Total,
Customer: order.Customer,
IdempotencyKey: c.Operation.ID, // the same on every attempt
})
if decline, ok := payments.AsDecline(err); ok {
return json.Marshal(Result{Declined: true, Reason: decline.Reason})
}
if err != nil {
return nil, err // network trouble: TaskFused retries with the same key
}
return json.Marshal(charge)
},
The first attempt charges the card and crashes. The second sends the identical request with the identical key, the provider sees it has already done this one, and returns the existing charge. The order is charged once and the operation finishes with the charge ID as its result.
Check how long your provider remembers keys. They don’t keep them forever, so this protects retries within hours, which is what crash recovery needs. It doesn’t make a key safe to reuse weeks later.
Declines Aren’t Errors
A declined card is a real answer, and retrying it won’t change it. That’s what the AsDecline check in the handler above is for. Many providers report a decline as an error (Stripe answers with a card error), so look for that error first and return the decline as a result. The operation then succeeds with “declined”, and nobody’s card is tried three more times.
Return an error only for problems a retry can fix, such as a timeout talking to the provider.
Emails Are the Same Problem
Sending the receipt has the same gap with no provider to lean on: most email APIs don’t deduplicate. Keep your own record, keyed by the operation ID, and check it before sending:
if alreadySent(db, c.Operation.ID) {
return []byte("sent"), nil
}
sendReceipt(ctx, order)
markSent(db, c.Operation.ID)
A crash between the send and the mark can still send twice. That window is a few milliseconds instead of the whole attempt, and for receipts that’s usually fine. Where it isn’t, use a provider that accepts a key, like the payment call.
What TaskFused Guards and What You Guard
TaskFused guards its own records. A worker that lost its lease can’t save a result, renew, or overwrite a cancellation: its lease has expired or been replaced, and its writes are turned away. What it can’t do is reach out and undo something a dead worker already did in another system. Those effects need a key on the far side, and the operation ID is that key.
So the split is simple. Equal requests join, which TaskFused handles. A crash mid-effect is handled by the operation ID as the idempotency key, which is your part. The about page and the contract say the same thing in fewer words.