Yeah, you're right.
The hook does fix one thing — without it the whole method body reruns, so you charge the card again on every single retry. But that's all it fixes. It gets you one send per commit, not one send ever, and I wrote that section like it was the whole answer.
The rest is exactly as you describe. Once COMMIT returns the row is there whether or not the hook works. Hooks in this library log and swallow errors on purpose — the transaction already committed, so throwing at the caller would be lying about what happened — which gets you precisely the ledger-says-paid-with-no-charge case. And retrying by hand is its own trap, because a call that timed out might have gone through anyway and you can't tell from the outside.
So yeah: outbox row in the same transaction, drain it after commit, idempotency key on the send. The hook just makes the drain happen sooner instead of waiting for the next poll. Stripe takes an Idempotency-Key header which makes that part easy.
The docs did mention the outbox, but buried it in a section about hook scoping, and it never made it into the post at all. My fault. I've added it to both.
Deferring Stripe to a commit hook still leaves a window you cannot ROLLBACK. The row is durable when the hook throws, and you sit with a paid-looking ledger and no charge. Operators then retry the hook by hand, which lives on a different retry path than the callback, so a second charge lands if the first one actually went through. Put an idempotency key on the side effect itself or write an outbox row in the same transaction and drain it after COMMIT. Otherwise the hook just relocates the double-send one layer later.