woocommerce_thankyou reads like a completion event and is a page-render hook. The order-received page has an ordinary URL with the order key in it, so it is bookmarked, refreshed, shared and prefetched — and everything hanging off that hook runs again each time. The symptom is duplicate rows in whatever the callback was writing to.
add_action( 'woocommerce_thankyou', function ( $order_id ) {
if ( get_post_meta( $order_id, '_erp_exported', true ) ) {
return;
}
$order = new WC_Order( $order_id );
catalogue_export_to_erp( $order );
update_post_meta( $order_id, '_erp_exported', current_time( 'mysql' ) );
} );
The meta flag is doing the work the hook does not: it makes the callback idempotent, and storing a timestamp rather than 1 means the row also answers when it happened. Write the flag after the side effect, not before, or a failed export marks itself done. The broader rule is that anything which must happen exactly once should hang off a state change rather than off a view — woocommerce_payment_complete and the woocommerce_order_status_* transitions fire when the order moves, which is far closer to once, and the thank-you hook is then left doing what its name suggests: deciding what the customer sees. Payment gateways that redirect back through the page more than once make this immediately obvious; the ones that do not will get there eventually.